【Spring Boot】JWT認証を実装する

Java

こんにちは、かつコーチです。

ここまでフォームログインとBCryptによるパスワード管理を扱ってきましたが、SPAやモバイルアプリ向けのAPIではセッションを持たないJWT認証を採用するケースが主流です。

この記事は上級者向けとして、フォームログイン・セッション認証の基礎知識がある前提で、JWT認証の実装とセッション認証との使い分けを解説します。

セッション認証とJWT認証の比較

セッション認証は、ログイン成功時にサーバー側でセッションを生成し、JSESSIONIDをCookieでクライアントに渡す方式です。

サーバー側にセッションの状態を保持するため、ステートフルな認証方式と呼ばれます。

一方JWT認証は、ログイン成功時にサーバーが署名付きのトークンを発行し、以降のリクエストではクライアントがそのトークンをヘッダーに乗せて送る方式です。

サーバー側は状態を持たず、トークンの署名検証だけで認証を完結できるため、ステートレスな認証方式と呼ばれます。

観点セッション認証JWT認証
サーバーの状態管理必要(セッションストア)不要
スケールアウトセッション共有の仕組みが必要サーバー間で共有不要
失効(ログアウト)の即時性容易(セッション削除)工夫が必要(後述)
主な用途従来型のWebアプリSPA・モバイル・マイクロサービス間連携

複数サーバーでスケールアウトする構成や、モバイルアプリ・別ドメインのSPAからAPIを呼ぶ構成では、JWT認証が有利です。

反対に、ログアウトを即座に反映させたい・単純な社内向けWebアプリであれば、セッション認証の方がシンプルに実装できます。

実装手順

手順1:JWTライブラリを依存関係に追加する

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

手順2:トークンを発行するユーティリティクラスを作る

@Component
public class JwtTokenProvider {

    private final SecretKey key;
    private final long expirationMillis = 1000 * 60 * 60; // 1時間

    public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(String username, List<String> roles) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + expirationMillis);

        return Jwts.builder()
                .subject(username)
                .claim("roles", roles)
                .issuedAt(now)
                .expiration(expiry)
                .signWith(key)
                .compact();
    }

    public String getUsername(String token) {
        return Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token).getPayload().getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

秘密鍵(jwt.secret)は必ずapplication.propertiesではなく環境変数で管理し、リポジトリにコミットしないでください。

手順3:リクエストごとにトークンを検証するフィルタを作る

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);

            if (jwtTokenProvider.validateToken(token)) {
                String username = jwtTokenProvider.getUsername(token);
                UsernamePasswordAuthenticationToken auth =
                        new UsernamePasswordAuthenticationToken(username, null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(request, response);
    }
}

手順4:SecurityFilterChainにJWTフィルタを組み込む

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

sessionCreationPolicy(SessionCreationPolicy.STATELESS)を指定し、Spring Securityにセッションを作らせないようにするのがJWT認証実装の要です。

CSRF対策はセッションCookieを前提とした攻撃なので、JWTのようにヘッダーでトークンを送る方式では基本的に不要になり、csrf(AbstractHttpConfigurer::disable)で無効化します。

つまずきやすいポイントと実務での注意点

addFilterBeforeの位置指定を誤ると認証が機能しない

筆者が最初にJWT認証を実装した際、addFilterBeforeの第二引数をBasicAuthenticationFilter.classにしてしまい、フィルタチェーンの順序がズレて認証情報が反映されないという不具合に遭遇しました。

UsernamePasswordAuthenticationTokenSecurityContextHolderにセットするタイミングは、Spring Securityの標準認証フィルタよりである必要があります。

addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)という組み合わせを、まずは定石として覚えておくとよいでしょう。

トークンの即時失効という弱点への対処

JWTはサーバー側に状態を持たないため、発行済みトークンを途中で無効化するのが原理的に難しいという弱点があります。

実務では以下のような対策を組み合わせます。

  • 有効期限を短く設定し(例:15分〜1時間)、リスクの発生時間を限定する
  • リフレッシュトークンを別途発行し、短命なアクセストークンを再発行する仕組みを用意する
  • 失効させたいトークンをRedisなどにブラックリストとして記録し、フィルタで照合する

「JWTだから何も考えなくてよい」ではなく、失効の即時性が求められる要件では、ブラックリスト方式やセッション認証との併用を検討する必要があります。

まとめ

この記事のポイント

  • JWT認証はステートレス、セッション認証はステートフルという根本的な違いがある
  • SessionCreationPolicy.STATELESSでセッション生成を止めるのがJWT実装の要
  • addFilterBeforeのフィルタ順序を誤ると認証が反映されない
  • JWTはトークンの即時失効が苦手なため、短い有効期限とリフレッシュトークンで補う

次に読むべき記事

JWT認証ではCSRF対策を無効化しましたが、セッション認証を使う場合はCSRF対策の理解が欠かせません。

→ 次の記事:CSRF対策とSpring Securityのデフォルト設定

タグ: #SpringBoot #上級者向け #認証

タイトルとURLをコピーしました