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の実装については省略します。
userId
やemail
を利用してデータの存在確認を行います。
今回の場合は、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を作成するごとにつける必要があって面倒なのです。
本来はアノテーションを使用せずに同様の機能を作成するのが良かったのだと思います。
機会があれば改良して、もっと使いやすいものを作成したいと思います。