utamaro’s blog

誰かの役に立つ情報を発信するブログ

jwtを検証するアノテーションを作って認証済みか判定する

jwtを検証するアノテーションを作って認証済みか判定する

jwtをcookieに追加してAPIの実行を管理したいと思いました。 そのときにやった内容です。

ざっと説明すると、ログインしていたらjwtがcookieに入っていて、そのcookieを検証することで認証を管理しています。 少々めんどくさい実装になってしまったので改善が必要ですが、アノテーションの自作や、interceptor、cookieの取り扱いなど勉強になることが多々ありました。

@VerifyJwtというアノテーションを付けることで検証が可能になります。

WebMvcConfigurerの設定を行う

WebMvcConfigurerの設定を行います。 interceptorの適用範囲を定義しています。 この定義によってアノテーションを適用する範囲を決めています。

import com.xxx.xxx.settings.interceptor.LoginRequiredInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private LoginRequiredInterceptor loginRequiredInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginRequiredInterceptor)
                .addPathPatterns("/admin/**") // 適用対象のパス(パターン)を指定する
                .excludePathPatterns("/error")
                .excludePathPatterns("/static/**"); // 除外するパス(パターン)を指定する
    }
}

この設定によって/admin/**に対してアノテーションを有効にしています。 また、エラー時、静的ファイル要求時にinterceptorが無効になるように設定しています。 静的ファイルで無効にする方法として、interceptor内での実装方法もあるがこちらのほうが楽に設定できます。 また、有効時、無効時のパスがまとまるので見通しが良くなります。

LoginRequiredInterceptorの実装を行う

先程作成したWebMvcConfigで使用したloginRequiredInterceptorを作成します。 preHandleを実装してアノテーションのついている場合の処理を作成します。

このクラス内では以下のクラスの実装が必要です。

  • com.xxx.xxx.apps.admin.user.model.UserModel
  • com.xxx.xxx.apps.admin.user.service.UserService
  • com.xxx.xxx.jwt.model.JwtUserClaimModel
  • com.xxx.xxx.jwt.service.JwtService
  • com.xxx.xxx.settings.annotation.VerifyJwt
import com.xxx.xxx.apps.admin.user.model.UserModel;
import com.xxx.xxx.apps.admin.user.service.UserService;
import com.xxx.xxx.jwt.model.JwtUserClaimModel;
import com.xxx.xxx.jwt.service.JwtService;
import com.xxx.xxx.settings.annotation.VerifyJwt;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.Objects;

@Slf4j
@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private UserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Method method = ((HandlerMethod) handler).getMethod();
        VerifyJwt verifyJwtAnnotation = AnnotationUtils.findAnnotation(method, VerifyJwt.class);

        if (Objects.nonNull(verifyJwtAnnotation)) {
            log.info("do login required.");
            // ログインが必要で、jwtの検証が必要な場合の処理を行う
            String token = jwtService.takeJwt(request);
            if (Objects.nonNull(token)) {
                // tokenの検証を行う
                JwtUserClaimModel claimModel = jwtService.verify(token, JwtUserClaimModel.class);
                if (Objects.isNull(claimModel)) {
                    // トークンの検証が失敗した場合の処理。
                    if (StringUtils.isBlank(verifyJwtAnnotation.failureUrl())) {
                        return true;
                    } else {
                        response.sendRedirect(verifyJwtAnnotation.failureUrl());
                        return true;
                    }
                } else {
                    UserModel userModel = userService.fetchUser(claimModel.getUserId());
                    request.setAttribute("user", userModel);
                    if (StringUtils.isBlank(verifyJwtAnnotation.successUrl())) {
                        // 例えば、/adminが成功時に表示する画面としたときに、ログイン済みの状態で/adminにリクエストした場合、
                        // 成功時の画面に/adminを設定しているとリダイレクトし続けてしまう。
                        return true;
                    } else {
                        // 成功時のurlが設定されていればリダイレクトを行う。
                        response.sendRedirect(verifyJwtAnnotation.successUrl());
                        return true;
                    }
                }
            } else {
                if (StringUtils.isBlank(verifyJwtAnnotation.failureUrl())) {
                    return true;
                } else {
                    response.sendRedirect(verifyJwtAnnotation.failureUrl());
                    return true;
                }
            }
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}

正直ネストが深くなりすぎてる感があるので、だめだなぁと思ってます。

UserModelの実装を行う

toStringしたときにpasswordが表示されないようにすること。 これが意外と大事で、ログを出していたりすると忘れることがよくあります。

import lombok.Data;
import lombok.ToString;

import java.time.LocalDateTime;

@Data
public class UserModel {
    private int id;
    private String email;
    @ToString.Exclude
    private String password;
    private boolean isActive;
    private boolean isAdmin;
    private boolean isGeneral;
    private LocalDateTime modifiedAt;
    private LocalDateTime createdAt;
}

UserServiceの実装を行う

mapperの実装については省略します。 userIdemailを利用してデータの存在確認を行います。 今回の場合は、jwt内にuserIdを入れており、そのIDを使用してデータを取得しています。 本来はここでnullチェックが必要ですが、省略します。

import com.xxx.xxx.apps.admin.user.mapper.UserMapper;
import com.xxx.xxx.apps.admin.user.model.UserModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public UserModel fetchUser(long userId) {
        UserModel userModel = userMapper.selectUserById(userId);
        return userModel;
    }
}

JwtUserClaimModelを実装する

jwtに含めるデータを定義します。

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JwtUserClaimModel {
    private long userId;
    private boolean isActive;
    private boolean isAdmin;
    private boolean isGeneral;
}

JwtServiceを実装する

このServiceを利用してjwtの生成、削除、検証、cookieへの追加が可能となります。 Cookieの追加についてはjwtとは別の機能なので、utilクラスを用意するのが良いと思います。

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.xxx.xxx.settings.config.CommonPropertyConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.constraints.NotNull;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;

@Slf4j
@Service
public class JwtService {

    @Autowired
    private CommonPropertyConfig commonPropertyConfig;

    public String takeJwt(HttpServletRequest httpServletRequest) {
        Cookie[] cookies = httpServletRequest.getCookies();
        if (Objects.nonNull(cookies)) {
            for (Cookie cookie : cookies) {
                if (commonPropertyConfig.getJwtCookieKey().equals(cookie.getName())) {
                    String token = cookie.getValue();
                    log.debug("jwt found in cookies. token=[{}]", token);
                    return token;
                }
            }
        }
        log.debug("jwt token not found.");
        return null;
    }

    public String create(Object headerClaimModel) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(commonPropertyConfig.getJwtSecretKey());
            Map<String, Object> headerClaims = new HashMap<>();
            headerClaims.put("appName", headerClaimModel);
            Calendar calendar = Calendar.getInstance();
            calendar.add(Calendar.HOUR, 6);
            calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
            Date expiredAt = calendar.getTime();
            String token = JWT.create()
                    .withIssuer(commonPropertyConfig.getJwtIssuser())
                    .withHeader(headerClaims)
                    .withExpiresAt(expiredAt)
                    .sign(algorithm);
            log.debug(token);
            return token;
        } catch (JWTCreationException exception){
            //Invalid Signing configuration / Couldn't convert Claims.
            throw exception;
        }
    }

    public <T> T verify(@NotNull String token, Class<T> claimClass) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(commonPropertyConfig.getJwtSecretKey());
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(commonPropertyConfig.getJwtIssuser())
                    .build(); //Reusable verifier instance
            DecodedJWT jwt = verifier.verify(token);
            Claim claim = jwt.getHeaderClaim("appName");
            T claimModel = claim.as(claimClass);
            return claimModel;
        } catch (JWTDecodeException exception){
            //Invalid token
            log.warn("Invalid token. token=[{}]", token);
        } catch (JWTVerificationException exception){
            //Invalid signature/claims
            log.info("Invalid signature/claims token=[{}]", token);
        }
        return null;
    }
}

VerifyJwtアノテーションを実装する

successUrlとfailureUrlを用意したアノテーションを作成します。 ランタイムで動作し、メソッドに対して利用できます。

import javax.validation.constraints.NotBlank;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface VerifyJwt {
    @NotBlank String successUrl() default "";
    @NotBlank String failureUrl() default "";
}

controllerクラスでアノテーションを利用する

すべての準備が整いました。実際に利用します。

    @VerifyJwt(successUrl = "/admin")
    @RequestMapping(path = "/login")
    public String loginView(HttpServletRequest request, HttpServletResponse response) throws IOException {
        return "admin/login";
    }

    @RequestMapping(path = "/login", method = RequestMethod.POST)
    public String postLogin(@Validated LoginRequestParam param,
                            HttpServletRequest httpServletRequest,
                            HttpServletResponse httpServletResponse,
                            BindingResult bindingResult) {
        // bindingResultのエラーチェックを行うこと。今回は省いている。
        LoginDto dto = LoginDto.builder()
                .email(param.getEmail())
                .password(param.getPassword())
                .build();
        // アカウントの認証を行う。
        UserModel userModel = loginUserService.userExists(dto);
        if (Objects.nonNull(userModel)) {
            // ユーザーが存在するため、tokenを生成する
            JwtUserClaimModel claimModel = JwtUserClaimModel.builder()
                    .userId(userModel.getId())
                    .isActive(userModel.isActive())
                    .isAdmin(userModel.isAdmin())
                    .isGeneral(userModel.isGeneral())
                    .build();
            String token = jwtService.create(claimModel);
            Cookie cookie = new Cookie(commonPropertyConfig.getJwtCookieKey(), token);
            cookie.setMaxAge(commonPropertyConfig.getJwtCookieMaxAge());
            cookie.setPath("/");
            if ("https".equals(httpServletRequest.getScheme())) {
                cookie.setSecure(true);
            }
            cookie.setHttpOnly(true);
            httpServletResponse.addCookie(cookie);
            return "redirect:";
        } else {
            // 管理ページでのアカウント登録は行わないため、登録画面へのリダイレクトは行わない。
        }
        return "admin/login";
    }

    @VerifyJwt(failureUrl = "/admin/login")
    @RequestMapping()
    public String homeView(HttpServletRequest request) {
        // resources/templates/login.htmlが開かれます。
        return "admin/home";
    }

問題点

@VerifyJwtすごく便利!

と作ったときは思いました。

最終的にこのアノテーションは使っていません。

なぜかというと、アノテーションを付け忘れてバグとなるからです。 APIを作成するごとにつける必要があって面倒なのです。

本来はアノテーションを使用せずに同様の機能を作成するのが良かったのだと思います。

機会があれば改良して、もっと使いやすいものを作成したいと思います。