Java
spring-security
JWT
spring-boot

Spring Security & JWT with Spring Boot 2.0で簡単なRest APIを実装する

概要

この記事は、以前に投稿した「Spring Security with Spring Boot 2.0.1で簡単なRest APIを実装する」で利用したデモアプリケーションをJWT(Json Web Token)に対応させたときの変更点を説明する記事です。
JWTの仕様については詳しい記事がたくさんありますのでここでは触れません。

ソースコードはrubytomato/demo-security-jwt-spring2にあります。

環境

  • Windows 10 Professional
  • Java 1.8.0_172
  • Spring Boot 2.0.2
  • Spring Security 5.0.5
    • java-jwt 3.3.0

参考

デモアプリケーションの要件

認証の仕方

このデモアプリケーションは、メールアドレス・パスワードで認証を行います。具体的にはログインAPIにメールアドレスとパスワードをPOSTリクエストし、認証できればHTTPステータス200とJWTのトークンが返ります。
以降の認証、認可はリクエストヘッダーにセットしたJWTのトークンによって行います。

セキュリティの実装(JWT対応で変更のあった箇所)

依存関係の追加

複数あるJWTを扱うライブラリの中からjava-jwtを選びました。

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.3.0</version>
</dependency>

java-jwtの簡単な使い方

トークンのペイロードについて

トークンのペイロード(claim)の識別子には予約済み(Registered Claim Names)のものがあります。いずれもオプショナルの扱いなので設定するかどうかは利用するアプリケーションに依存します。

id name description
jti JWT ID JWTの一意の識別子
aud Audience JWTの利用者
iss Issuer JWTの発行者
sub Subject JWTの主体. JWTの発行者のコンテキスト内でユニークまたはグローバルユニークな値
iat Issued At JWTの発行時間
nbf Not Before JWTの有効期間の開始時間. この時間より前には利用できない
exp Expiration Time JWTの有効期間の終了時間. この時間より後には利用できない

トークンの生成

private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

public void build() {
     String secretKey = "secret";
     Date issuedAt = new Date(); 
     Date notBefore = new Date(issuedAt.getTime());
     Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);

     try {
         Algorithm algorithm = Algorithm.HMAC256(secretKey);
         String token = JWT.create()
             // registered claims
             //.withJWTId("jwtId")        //"jti" : JWT ID
             //.withAudience("audience")  //"aud" : Audience
             //.withIssuer("issuer")      //"iss" : Issuer
             .withSubject("test")         //"sub" : Subject
             .withIssuedAt(issuedAt)      //"iat" : Issued At
             .withNotBefore(notBefore)    //"nbf" : Not Before
             .withExpiresAt(expiresAt)    //"exp" : Expiration Time
             //private claims
             .withClaim("X-AUTHORITIES", "aaa")
             .withClaim("X-USERNAME", "bbb")
             .sign(algorithm);
         System.out.println("generate token : " + token);
     } catch (UnsupportedEncodingException e) {
         e.printStackTrace();
     }
}

この設定で生成されるトークンは以下のようになります。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY

JSON Web Tokens - jwt.ioというサイトでトークンをデコードすることができます。

d.png

トークンの検証

public void verify() {
    String secretKey = "secret";
    String token = "";

    try {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        JWTVerifier verifier = JWT.require(algorithm).build();

        DecodedJWT jwt = verifier.verify(token);

        // registered claims
        String subject = jwt.getSubject();
        Date issuedAt = jwt.getIssuedAt();
        Date notBefore = jwt.getNotBefore();
        Date expiresAt = jwt.getExpiresAt();
        System.out.println("subject : [" + subject + "] issuedAt : [" + issuedAt.toString() + "] notBefore : [" + notBefore.toString() + "] expiresAt : [" + expiresAt.toString() + "]");
        // subject : [test] issuedAt : [Thu Apr 12 13:19:00 JST 2018] notBefore : [Thu Apr 12 13:19:00 JST 2018] expiresAt : [Thu Apr 12 13:29:00 JST 2018]

        // private claims
        String authorities = jwt.getClaim("X-AUTHORITIES").asString();
        String username = jwt.getClaim("X-USERNAME").asString();
        System.out.println("private claim  X-AUTHORITIES : [" + authorities + "] X-USERNAME : [" + username + "]");
        // private claim  X-AUTHORITIES : [aaa] X-USERNAME : [bbb]

    } catch (UnsupportedEncodingException | JWTVerificationException e) {
        e.printStackTrace();
    }
}

verify時に発生し得る例外

すべて網羅していませんが起こりやすい(起こしやすい)例外には以下のものがあります。
いずれの例外もJWTVerificationExceptionのサブクラスです。

exception description
SignatureVerificationException シークレットキーが異なる場合など
AlgorithmMismatchException 署名アルゴリズムが異なる場合など
JWTDecodeException トークンが改ざんされている場合など
TokenExpiredException トークンの有効期限が切れいている場合
InvalidClaimException トークンの利用開始前の場合など

Spring Securityのコンフィグレーション

JWT対応で変更した箇所を示します。

  • ★1 署名時に使用するシークレットキーを設定ファイルから取り込みます
  • ★2 ログアウト時のセッション破棄やクッキー削除は無用になりました
  • ★3 CSRF対策を無効にしました
  • ★4 認可処理を行うフィルターを追加しました
  • ★5 セッションをステートレスに変更しました
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserRepository userRepository;

    // ★1
    @Value("${security.secret-key:secret}")
    private String secretKey = "secret";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/hello/**")
                    .permitAll()
                .mvcMatchers("/user/**")
                    .hasRole("USER")
                .mvcMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            .and()
            // EXCEPTION
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint())
                .accessDeniedHandler(accessDeniedHandler())
            .and()
            // LOGIN
            .formLogin()
                .loginProcessingUrl("/login").permitAll()
                    .usernameParameter("email")
                    .passwordParameter("pass")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
            .and()
            // ★2 LOGOUT
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
            .and()
            // ★3 CSRF
            .csrf()
                .disable()
            // ★4 AUTHORIZE
            .addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
            // ★5 SESSION
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            ;
        // @formatter:on
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.eraseCredentials(true)
                .userDetailsService(simpleUserDetailsService())
                .passwordEncoder(passwordEncoder());
    }

    @Bean("simpleUserDetailsService")
    UserDetailsService simpleUserDetailsService() {
        return new SimpleUserDetailsService(userRepository);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    GenericFilterBean tokenFilter() {
        return new SimpleTokenFilter(userRepository, secretKey);
    }

    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SimpleAuthenticationEntryPoint();
    }

    AccessDeniedHandler accessDeniedHandler() {
        return new SimpleAccessDeniedHandler();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SimpleAuthenticationSuccessHandler(secretKey);
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SimpleAuthenticationFailureHandler();
    }

    LogoutSuccessHandler logoutSuccessHandler() {
        return new HttpStatusReturningLogoutSuccessHandler();
    }

}

認証と成功・失敗時の処理

認証周りの設定自体に変更はありませんでした。

configuration
.formLogin()
    .loginProcessingUrl("/login").permitAll()
        .usernameParameter("email")
        .passwordParameter("pass")
    .successHandler(authenticationSuccessHandler())
    .failureHandler(authenticationFailureHandler())

successHandler()

変更前はHTTPステータス200を返すだけのハンドラでしたが、認証情報からトークンを生成しレスポンスヘッダーへセットするように変更しました。

AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new SimpleAuthenticationSuccessHandler(secretKey);
}

トークンのペイロード(claim)に設定する情報は誰にでもデコードできるので外部に露出しても問題ない情報を選びます。
この例ではsubjectにユーザーIDをセットし、認可時はトークンからユーザーIDを取得してユーザーを検索するという処理になります。

String token = JWT.create()
        .withIssuedAt(issuedAt)        // JWTの発行時間
        .withNotBefore(notBefore)      // JWTの有効期限の開始時間
        .withExpiresAt(expiresAt)      // JWTの有効期限の終了時間
        .withSubject(loginUser.getUser().getId().toString()) // JWTの主体、JWTの発行者のコンテキスト内でユニークまたはグローバルユニークな値
        .sign(this.algorithm);

generateTokenメソッドで認証情報からトークンを生成し、setTokenメソッドでAuthorizationヘッダーにトークンをセットしています。

SimpleAuthenticationSuccessHandler
@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    final private Algorithm algorithm;

    public SimpleAuthenticationSuccessHandler(String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        setToken(response, generateToken(auth));
        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

    private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

    private String generateToken(Authentication auth) {
        SimpleLoginUser loginUser = (SimpleLoginUser) auth.getPrincipal();
        Date issuedAt = new Date();
        Date notBefore = new Date(issuedAt.getTime());
        Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
        String token = JWT.create()
                .withIssuedAt(issuedAt)
                .withNotBefore(notBefore)
                .withExpiresAt(expiresAt)
                .withSubject(loginUser.getUser().getId().toString())
                .sign(this.algorithm);
        log.debug("generate token : {}", token);
        return token;
    }

    private void setToken(HttpServletResponse response, String token) {
        response.setHeader("Authorization", String.format("Bearer %s", token));
    }

    /**
     * Removes temporary authentication-related data which may have been stored in the
     * session during the authentication process.
     */
    private void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }

}

トークンの検証と認可

トークンの検証と認可を行うフィルターを新しく実装しました。
このフィルターは、ユーザー名・パスワードで認証を行うフィルター(UsernamePasswordAuthenticationFilter)より前に実行するように設定します。

configuration
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)

SimpleTokenFilter

GenericFilterBean tokenFilter() {
    return new SimpleTokenFilter(userRepository, secretKey);
}

リクエストヘッダー"Authorization"からトークンを取得し検証します。検証ではトークンが改ざんされていないかの他に設定した有効期限のチェックも行われます。
トークンが正常であればペイロードからユーザー情報を検索するためのユーザーIDを取得してユーザーを検索します。

SimpleTokenFilter
@Slf4j
public class SimpleTokenFilter extends GenericFilterBean {

    final private UserRepository userRepository;
    final private Algorithm algorithm;

    public SimpleTokenFilter(UserRepository userRepository, String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        this.userRepository = userRepository;
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String token = resolveToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            authentication(verifyToken(token));
        } catch (JWTVerificationException e) {
            log.error("verify token error", e);
            SecurityContextHolder.clearContext();
            ((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(ServletRequest request) {
        String token = ((HttpServletRequest) request).getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return null;
        }
        return token.substring(7);
    }

    private DecodedJWT verifyToken(String token) {
        JWTVerifier verifier = JWT.require(algorithm).build();
        return verifier.verify(token);
    }

    private void authentication(DecodedJWT jwt) {
        Long userId = Long.valueOf(jwt.getSubject());
        userRepository.findById(userId).ifPresent(user -> {
            SimpleLoginUser simpleLoginUser = new SimpleLoginUser(user);
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(simpleLoginUser, null, simpleLoginUser.getAuthorities()));
        });
    }

}

ログアウト時の処理

ログアウト時の設定はURLとハンドラだけになりました。
ログアウト時に発行したトークンを無効化したかったのですが、調べた範囲ではトークンを無効化することはできないようなのでハンドラはHTTPステータスを返すだけの処理になっています。

configuration
.logout()
    .logoutUrl("/logout")
    //.invalidateHttpSession(true)
    //.deleteCookies("JSESSIONID")
    .logoutSuccessHandler(logoutSuccessHandler())

トークンの無効化

無効化をサーバー側で対処する場合は、ログアウトしたユーザーのトークンを一定期間(トークンの有効期限まで)保持しておき、認可処理のときに突き合わせて一致した場合は認可しないといった方法があるようです。

CSRF

CSRFは利用しないので無効にしました。

configuration
.csrf()
    .disable()
    //.ignoringAntMatchers("/login")
    //.csrfTokenRepository(new CookieCsrfTokenRepository())

SessionManagement

セッション管理は行わないのでステートレスに設定しました。この設定にするとSpring SecurityがHttpSessionを利用することはありません。

configuration
.sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

APIの動作確認

JWTに変更したことによりセッションクッキーもCSRFトークンも不要になりました。代わりに認証後に受け取ったJWTのトークンをリクエストヘッダーにセットします。

ログインAPI

有効なアカウントの場合

認証に成功すると下記の例のとおり、Authorizationヘッダーにトークンがセットされます。

> curl -i -X POST "http://localhost:9000/app/login" -d "email=kkamimura@example.com" -d "pass=iWKw06pvj"

HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmJmIjoxNTIzNTExNjY5LCJleHAiOjE1MjM1MTIyNjksImlhdCI6MTUyMzUxMTY2OX0.E6HZShowNPUvNj84dYRHMyZROxIvYjsEP7e29
_QLXic
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Thu, 12 Apr 2018 05:41:09 GMT

無効なアカウントの場合

メールアドレス間違い、パスワード間違いなど

> curl -i -X POST "http://localhost:9000/app/login" -d "email=kkamimura@example.com" -d "pass=hogehoge"

HTTP/1.1 401

認証されているユーザーであればアクセスできるAPI

有効なトークンを指定した場合

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 200

トークンを指定しない場合

> curl -i "http://localhost:9000/app/memo/1"

HTTP/1.1 401

トークンを改ざんした場合

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

トークンの有効期限が切れている場合

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

認証とUSERロールが必要なAPI

USERロールを持っているユーザーの場合

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"

HTTP/1.1 200

認証とADMINロールが必要なAPI

ADMINロールを持っているユーザーの場合

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 200

ADMINロールをもっていないユーザーの場合

トークンが正常でもユーザーが必要なロールを持っていない場合はエラーになります。

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 403