Spring Security 5.7 以降の書き方で認証後の JWT 生成と API の認証チェックを行う

Java
JavaSpringバックエンド

以下の記事で、API としてログイン処理をリクエストし、認証を行うところまで行きました。
この記事では、認証成功の後、後続の API 処理まで行う方法を説明します。

具体的には認証後に JWT を生成し、以降はその値を検証して認証されていることを確認します。なお、Spring Security に JWT 関連のクラスはあるが、Oauth用?みたいで使えそうにありませんでした。

また、以下の記事にあるように、HTTPでトークンを利用した認証認可をする手段は RFC で規定されていますが、今回やったのはそこまでは厳密ではなく、Authroization ヘッダに JWT をセットし、その JWT をチェックして問題ない場合に API の実行を許可する、というものです。

前提

  • Java 17
  • Spring Boot 2.7.8
  • Spring Security 5.7.6

コードベースは、冒頭の前の記事です。

実現方法

他の記事であったのは、認証は UsernamePasswordAuthenticationFilter を利用し、認証成功後に JWT を生成して返却する。その後、OncePerRequestFilter でヘッダ検証を行う方法でした。

それでも良いのでしょうが、Spring Security には、RequestHeaderAuthenticationFilter という認証済みを示す Filter があるので、今回はそちらを利用。

イメージとしては、

  1. ログイン時は UsernamePasswordAuthenticationFilterDaoAuthenticationProvider を利用して認証を行う
  2. ログイン後は RequestHeaderAuthenticationFilterPreAuthenticatedAuthenticationProvider を利用して HTTP Header から認証情報を取り出す。

実装

jwt ライブラリの追加

java-jwt を利用。

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

ログイン成功後に jwt を生成

認証成功後のハンドラ(setAuthenticationSuccessHandler)でJWT を生成します。

@Slf4j
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    public MyUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // "/api/login" の場合に認証を行うよう設定
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/login", "POST"));

        var issuedAt = new Date();
        this.setAuthenticationSuccessHandler((req, res, ex) -> {
            // JWT の作成
            String token = JWT.create()
                    .withIssuer("test-issuer")
                    // 発行日時
                    .withIssuedAt(issuedAt)
                    // 有効期限
                    .withExpiresAt(new Date(issuedAt.getTime() + 1000 * 60 * 60))
                    // ユーザ名
                    .withClaim("username", ex.getName())
                    // ロール情報
                    .withClaim("role", ex.getAuthorities().iterator().next().toString())
                    .sign(Algorithm.HMAC256("secret"));

            res.setHeader("X-AUTH-TOKEN", token);
            res.setStatus(200);

            // ユーザ情報を返却
            MyUserDetails user = (MyUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            res.getWriter().write((new ObjectMapper()).writeValueAsString(user.getPerson()));
        });
    }
    ...

認証チェック用の Filter の追加

今回は /api/hello に対して認証チェックをするようにしています。チェックする HTTP ヘッダは Authorization です。なお、setExceptionIfHeaderMissing を false にすると、Authorization ヘッダがない場合も例外は発生しません。

@Slf4j
public class MyRequestHeaderAuthenticationFilter extends RequestHeaderAuthenticationFilter {

    public MyRequestHeaderAuthenticationFilter(AuthenticationManager authenticationManager) {
        setPrincipalRequestHeader("Authorization");
        setExceptionIfHeaderMissing(false);
        setAuthenticationManager(authenticationManager);
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/hello"));

        this.setAuthenticationSuccessHandler((req, res, ex) -> {
            log.info("Success 2");
        });
        this.setAuthenticationFailureHandler((req, res, ex) -> {
            log.info("Failure 2");
        });
    }

}

認証チェック用の UserDetailService の追加

上の処理で setPrincipalRequestHeaderAuthorization を設定したので、PreAuthenticatedAuthenticationToken の principal に トークン文字列が入っています。
今回は bearer の文字に関しては割愛してますので、期待する形は「Authorization: <jwt>」です。

@Slf4j
@Service
public class MyAuthenticationUserDetailService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

    @Override
    public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
        DecodedJWT decodedJWT;

        try {
            decodedJWT = JWT.require(Algorithm.HMAC256("secret")).build().verify(token.getPrincipal().toString());
        } catch (JWTDecodeException ex) {
            throw new BadCredentialsException("Authorization header token is invalid");
        }

        if (decodedJWT.getToken().isEmpty()) {
            throw new UsernameNotFoundException("Authorization header must not be empty.");
        }

     // decode できた後は、jwt の情報からユーザ情報を作る
        // getClaim 下戻りは Claim クラスなので、本来の値の取り出しには文字列なら asString() が必要。
        Person person = new Person();
        person.setUsername(decodedJWT.getClaim("username").asString());
        person.setPassword("");
        person.setRoles(decodedJWT.getClaim("role").asString());
        return new MyUserDetails(person);
    }
}

Provider の設定と、HttpSecurity へ Filter を追加

最後はこれまで用意したものをつなぎ合わせる部分です。

ここの実装方法は悩み試行錯誤しましたが、最終的には以下のようにするといけました。デフォルトでは、DaoAuthenticationProvider のみですが、今回は PreAuthenticatedAuthenticationProvider も必要なので、両方を Provider として設定します。各々対応する UserDetailService も合わせてセットします。

最後に、MyRequestHeaderAuthenticationFilter を追加したら完成です。

なお、コード中の configureProvider は、こちら に書かれている通り、メソッド名は何でもよいです。

@EnableWebSecurity
@Configuration
public class MySecurityConfig {

    @Autowired
    public void configureProvider(
            AuthenticationManagerBuilder auth,
            MyUserDetailsService myUserDetailsService,
            MyAuthenticationUserDetailService myAuthenticationUserDetailService) throws Exception {
        var preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();
        preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(myAuthenticationUserDetailService);
        preAuthenticatedAuthenticationProvider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
        auth.authenticationProvider(preAuthenticatedAuthenticationProvider);

        var daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(myUserDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder(8));
        auth.authenticationProvider(daoAuthenticationProvider);
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                .antMatchers("/").permitAll()
                .antMatchers("/api/login").permitAll()
                .anyRequest().authenticated()
        );
        var authManager = authenticationManager(http.getSharedObject(AuthenticationConfiguration.class));
        http.addFilter(new MyUsernamePasswordAuthenticationFilter(authManager));
        http.addFilter(new MyRequestHeaderAuthenticationFilter(authManager));

        // セッションを使用しない
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.csrf().disable();

        return http.build();
    }

動作確認

今回は Advanced REST Client を利用しています。

ログイン

ログイン & パスワードで認証します。x-auth-token の HTTP ヘッダが返って来ているのが確認できます。

ちなみに、Spring Security が提供する ProviderManager の処理ですが、providers が2つになっていることが確認できます。

要認証 api へのアクセス(認証なし)の場合

チェックに失敗するので 403 が返ります。以下はヘッダがない場合ですが、Authorization ヘッダがあるけど値が正しくない場合も 403 になります。

要認証 api へのアクセス(認証あり)の場合

Authorization ヘッダに正しく JWT が設定されている場合は 200 でちゃんとレスポンスも返ってきました。

以下は MyAuthenticationUserDetailService.javaloadUserDetails の処理ですが、JWT が正しくデコードできて値が取れていることが確認できます。