🔑

Java 21/Spring Boot 3で実装する最新JWT認証ガイド

2025/03/02に公開

こんにちは!今回はJava 21とSpring Boot 3を使った最新のJWT認証実装について詳しく解説します。セキュアな認証システムの構築に役立つベストプラクティスも紹介しますので、ぜひ最後までご覧ください。

サンプルで示したソースコードは「高度な機能拡張」以外、jwt-sampleに動作するSprinBoot3アプリケーションとしてプロジェクト一式を上げてあるので是非ご参照ください。

目次

  • JWTとは何か
  • 従来の認証との違い
  • JWTの構造
  • Spring Boot 3でのJWT実装
  • Java 21の新機能を活用したコード改善
  • セキュリティベストプラクティス
  • 実装チェックリスト

JWTとは何か

JWTはJSON Web Tokenの略で、クロスドメイン認証のためのオープンスタンダード(RFC 7519)です。ウェブアプリケーションにおいて、安全で便利な認証と情報転送を可能にする重要な役割を果たします。特にマイクロサービスアーキテクチャで広く採用されています。

JWTが解決する問題

従来の認証はクライアント側のクッキーとサーバー側のセッションに依存していました。これは単一サーバーのアプリケーションでは問題なく機能しますが、モダンなアプリケーションアーキテクチャでは以下のような課題があります。

  1. セッション共有の問題

マイクロサービスや分散システムでは、各サーバーが独立したセッションを維持します。ユーザーがサービス間を移動すると、ログイン状態の一貫性が失われる可能性があります。

  1. スケーラビリティの制限

セッションベースの認証は、特にクラウド環境や自動スケーリングを行う環境では効率的にスケールできません。

  1. クロスドメイン認証の難しさ

クッキーはドメインに紐づいており、異なるドメイン間で直接共有できないため、マルチドメインシステムでの統一認証が難しくなります。

JWTの利点

JWTはこれらの問題を以下のように解決します。

  1. ステートレス認証

JWTはアプリケーションをステートレスにし、セッション共有の必要性を回避します。必要なユーザー情報がトークン自体に含まれているため、サーバーはセッションを保存する必要がありません。

  1. スケーラビリティの向上

ステートレスな特性により、水平スケーリングが容易になります。新しいサーバーインスタンスを追加しても、認証情報は常にクライアントから提供されます。

  1. クロスドメイン認証

JWTはHTTPヘッダーで送信できるため、ドメインの制限を受けずに異なるサービス間で認証情報を共有できます。

  1. マイクロサービス互換性

各マイクロサービスは共通の秘密鍵または公開/秘密鍵ペアを使用してトークンを検証でき、認証サーバーへの追加のリクエストが不要になります。

従来の認証とJWT認証の比較

従来の認証フロー (セッションベース)

  1. ユーザーがログイン情報を送信
  2. サーバーが認証を行い、セッションを生成・保存
  3. セッションIDをクッキーでクライアントに送信
  4. 以降のリクエストで、クライアントがクッキーを送信
  5. サーバーがセッションIDを検証し、対応するセッションを検索

JWT認証フロー (トークンベース)

  1. ユーザーがログイン情報を送信
  2. サーバーが認証を行い、JWTを生成
  3. JWTをクライアントに返す
  4. 以降のリクエストで、クライアントがJWTをAuthorizationヘッダーに含めて送信
  5. サーバーがJWTの署名を検証し、トークン内の情報を使用して認証

JWTの構造

JWTは、ドットで区切られた3つの部分から構成されています

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

ヘッダー

ヘッダーは、トークンのタイプと使用する署名アルゴリズムを指定するJSONオブジェクトです。

{
  "alg": "HS256", // 使用する暗号化アルゴリズム(HMAC SHA-256など)
  "typ": "JWT"    // トークンのタイプ
}

このJSONオブジェクトはBase64Urlでエンコードされ、JWTの最初の部分となります。

ペイロード

ペイロードはトークンに含める情報(クレーム)を保持します。クレームには以下の3種類があります。

  1. 登録済みクレーム: JWT仕様で定義された標準フィールド

    • iss (発行者)
    • sub (サブジェクト)
    • exp (有効期限)
    • iat (発行時間)
    • nbf (有効開始時間)
    • jti (JWT ID)
    • aud (対象者)
  2. 公開クレーム: JWT使用者が自由に定義できるが、衝突を避けるためにIANA JWT Registryに登録するか、URI形式にすべきもの

  3. プライベートクレーム: 当事者間で合意した独自のクレーム

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022,
  "exp": 1516242622
}

このペイロードもBase64Urlでエンコードされ、JWTの2番目の部分となります。

署名

署名は、エンコードされたヘッダー、エンコードされたペイロード、秘密鍵、そしてヘッダーで指定されたアルゴリズムを使用して作成されます。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

この署名はトークンのデータが改ざんされていないことを検証するために使用されます。

Spring Boot 3でのJWT実装

Spring Boot 3とJava 21を使って、JWTベースの認証を実装してみましょう。まずは必要な依存関係から始めます。

依存関係の設定

build.gradleファイル

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
    // その他の依存関係
}

またはpom.xmlファイル

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <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>
</dependencies>

アプリケーション設定

application.propertiesファイルに必要な設定を追加します。

# JWT設定
jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
jwt.expiration=86400000
jwt.refresh-expiration=604800000

セキュリティのために実際の秘密鍵は環境変数から取得することをお勧めします。

JWTサービスの実装

まず、JWTトークンの生成と検証を行うサービスを実装します。Java 21のRecord機能とTextBlocksを活用しています。

package com.example.security.service.jwt;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import com.example.security.config.JwtConfig;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;

/**
 * JWTサービス
 */
@Service
@RequiredArgsConstructor
public class JwtService {

    private final JwtConfig jwtConfig;

    /**
     * トークンレスポンス
     */
    public record JwtToken(String token, String refreshToken, Date expiresAt) {
    }

    /**
     * トークンの抽出
     */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * クレームの抽出
     */
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    /**
     * トークンの生成
     */
    public JwtToken generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /**
     * 追加のクレームを含むトークンを生成
     */
    public JwtToken generateToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails) {
        // アクセストークンの生成
        String accessToken = buildToken(extraClaims, userDetails, jwtConfig.getExpiration());

        // リフレッシュトークンの生成(通常は最小限のクレームを含む)
        String refreshToken = buildToken(new HashMap<>(), userDetails, jwtConfig.getRefreshExpiration());

        // トークンの有効期限
        Date expiresAt = new Date(System.currentTimeMillis() + jwtConfig.getExpiration());

        return new JwtToken(accessToken, refreshToken, expiresAt);
    }

    /**
     * トークンのビルド
     */
    private String buildToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails,
            long expiration) {
        return Jwts
                .builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSignInKey())
                .compact();
    }

    /**
     * トークンの有効性を確認
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    /**
     * トークンの期限切れを確認
     */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    /**
     * トークンの期限切れを抽出
     */
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts
                .parser()
                .verifyWith(getSignInKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    /**
     * SignInKeyの取得
     */
    private javax.crypto.SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtConfig.getSecret());
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

JWTフィルターの実装

次に、リクエストごとにJWTトークンを検証するフィルターを作成します。

package com.example.security.filter;

import java.io.IOException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.example.security.service.jwt.JwtService;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

/**
 * JWT認証フィルター
 */
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    /**
     * ロガー
     */
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    /**
     * JWTサービス
     */
    private final JwtService jwtService;

    /**
     * ユーザー詳細サービス
     */
    private final UserDetailsService userDetailsService;

    /**
     * フィルター内部
     */
    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;

        // Authorizationヘッダーがないか、形式が無効な場合は次のフィルターに進む
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // "Bearer "の後のトークン部分を抽出
        jwt = authHeader.substring(7);

        try {
            // トークンからユーザー名(またはEメール)を抽出
            userEmail = jwtService.extractUsername(jwt);

            // ユーザー名が存在し、まだ認証されていない場合
            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

                // トークンが有効な場合
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    // 認証オブジェクトを作成
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities());

                    // リクエスト詳細を設定
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request));

                    // SecurityContextに認証情報を設定
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception e) {
            // トークンが無効な場合は、エラーを記録するだけで認証は行わない
            logger.error("JWT token validation failed: {}", e.getMessage());
        }

        // フィルターチェーンを続行
        filterChain.doFilter(request, response);
    }
}

Spring Securityの設定

Spring Security 6の新しい設定スタイルを使用して、セキュリティ設定を行います。

package com.example.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.example.security.filter.JwtAuthenticationFilter;

import lombok.RequiredArgsConstructor;

/**
 * セキュリティ設定
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

        /**
         * JWT認証フィルター
         */
        private final JwtAuthenticationFilter jwtAuthFilter;

        /**
         * 認証プロバイダー
         */
        private final AuthenticationProvider authenticationProvider;

        /**
         * セキュリティフィルターチェーン
         */
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
                // Java 21のTextBlock機能を使用して、URL許可リストを定義
                String[] publicUrls = """
                                /api/v1/auth/**
                                /v3/api-docs/**
                                /swagger-ui/**
                                /swagger-ui.html
                                /swagger-resources/**
                                /actuator/**
                                """.trim().split("\\s+");

                return http
                                .csrf(AbstractHttpConfigurer::disable)
                                .authorizeHttpRequests(auth -> auth
                                                .requestMatchers(publicUrls).permitAll()
                                                .anyRequest().authenticated())
                                .sessionManagement(session -> session
                                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                                .authenticationProvider(authenticationProvider)
                                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                                .build();
        }
}

認証サービスとコントローラー

最後に、認証を処理するサービスとコントローラーを実装します。

package com.example.security.service.auth;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.example.security.model.Role;
import com.example.security.model.User;
import com.example.security.repository.user.UserRepository;
import com.example.security.service.jwt.JwtService;
import com.example.security.service.jwt.JwtService.JwtToken;

import lombok.RequiredArgsConstructor;

/**
 * 認証サービス
 */
@Service
@RequiredArgsConstructor
public class AuthenticationService {
    private final UserRepository repository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    /**
     * 認証リクエスト
     * 
     * @param email メールアドレス
     * @param password パスワード
     */
    public record AuthenticationRequest(String email, String password) {
    }

    /**
     * 登録リクエスト
     * 
     * @param firstname 名前
     * @param lastname 姓
     * @param email メールアドレス
     * @param password パスワード
     */
    public record RegisterRequest(String firstname, String lastname, String email, String password) {
    }

    /**
     * 認証レスポンス
     * 
     * @param accessToken アクセストークン
     * @param refreshToken リフレッシュトークン
     */
    public record AuthenticationResponse(String accessToken, String refreshToken) {
    }

    /**
     * 登録
     * 
     * @param request 登録リクエスト
     * @return 認証レスポンス
     */
    public AuthenticationResponse register(RegisterRequest request) {
        var user = User.builder()
                .firstname(request.firstname())
                .lastname(request.lastname())
                .email(request.email())
                .password(passwordEncoder.encode(request.password()))
                .role(Role.USER)
                .build();
        repository.save(user);

        JwtToken jwtToken = jwtService.generateToken(user);

        return new AuthenticationResponse(jwtToken.token(), jwtToken.refreshToken());
    }

    /**
     * 認証
     * 
     * @param request 認証リクエスト
     * @return 認証レスポンス
     */
    public AuthenticationResponse authenticate(AuthenticationRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.email(),
                        request.password()));

        var user = repository.findByEmail(request.email())
                .orElseThrow();

        JwtToken jwtToken = jwtService.generateToken(user);

        return new AuthenticationResponse(jwtToken.token(), jwtToken.refreshToken());
    }
}

コントローラー

package com.example.security.controller.auth;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.security.service.auth.AuthenticationService;
import com.example.security.service.auth.AuthenticationService.AuthenticationRequest;
import com.example.security.service.auth.AuthenticationService.AuthenticationResponse;
import com.example.security.service.auth.AuthenticationService.RegisterRequest;

import lombok.RequiredArgsConstructor;

/**
 * 認証関連のコントローラー
 */
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    /**
     * 認証サービス
     */
    private final AuthenticationService service;

    /**
     * 新規登録
     * 
     * @param request 登録リクエスト
     * @return 認証レスポンス
     */
    @PostMapping("/register")
    public ResponseEntity<AuthenticationResponse> register(
            @RequestBody RegisterRequest request) {
        return ResponseEntity.ok(service.register(request));
    }

    /**
     * 認証
     * 
     * @param request 認証リクエスト
     * @return 認証レスポンス
     */
    @PostMapping("/authenticate")
    public ResponseEntity<AuthenticationResponse> authenticate(
            @RequestBody AuthenticationRequest request) {
        return ResponseEntity.ok(service.authenticate(request));
    }
}

リフレッシュトークンを処理するエンドポイントも追加しましょう。

package com.example.security.controller.token;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.security.repository.user.UserRepository;
import com.example.security.service.jwt.JwtService;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

/**
 * トークンリフレッシュコントローラー
 */
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class TokenRefreshController {

    /**
     * JWTサービス
     */
    private final JwtService jwtService;

    /**
     * ユーザーリポジトリ
     */
    private final UserRepository userRepository;

    /**
     * トークンリフレッシュレスポンス
     * 
     * @param accessToken  アクセストークン
     * @param refreshToken リフレッシュトークン
     */
    public record TokenRefreshResponse(String accessToken, String refreshToken) {
    }

    /**
     * トークンリフレッシュ
     * 
     * @param request リクエスト
     * @return トークンリフレッシュレスポンス
     */
    @PostMapping("/refresh-token")
    public ResponseEntity<TokenRefreshResponse> refreshToken(
            HttpServletRequest request) {
        // Authorizationヘッダーからリフレッシュトークンを取得
        final String authHeader = request.getHeader("Authorization");
        final String refreshToken;
        final String userEmail;

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return ResponseEntity.badRequest().build();
        }

        refreshToken = authHeader.substring(7);
        userEmail = jwtService.extractUsername(refreshToken);

        if (userEmail != null) {
            var user = userRepository.findByEmail(userEmail)
                    .orElseThrow();

            if (jwtService.isTokenValid(refreshToken, user)) {
                var jwtToken = jwtService.generateToken(user);

                return ResponseEntity.ok(
                        new TokenRefreshResponse(
                                jwtToken.token(),
                                jwtToken.refreshToken() // 新しいリフレッシュトークンを返す
                        ));
            }
        }

        return ResponseEntity.badRequest().build();
    }
}

Java 21の新機能を活用したコード改善

Java 21では、コードをより簡潔で読みやすくするための多くの新機能が導入されています。JWTの実装において特に役立つ機能をいくつか紹介します。

Record

Recordは、イミュータブルなデータクラスを簡潔に定義できる機能です。DTOやリクエスト/レスポンスモデルに最適です。

// 従来のクラス
public class JwtResponse {
    private final String token;
    private final String refreshToken;
    private final Date expiresAt;
    
    // コンストラクタ、ゲッター、equals、hashCode、toStringなど...多くのボイラープレートコード
}

// Java 21のRecord
public record JwtResponse(String token, String refreshToken, Date expiresAt) {}

Recordを使用すると、コンストラクタ、getterメソッド、equals、hashCode、toStringメソッドが自動的に生成されるため、コードが大幅に簡潔になります。

Pattern Matching for instanceof

型チェックとキャストを一度に行うことができる機能です。

// 従来のコード
if (obj instanceof Authentication) {
    Authentication auth = (Authentication) obj;
    // authを使用...
}

// Java 21のパターンマッチング
if (obj instanceof Authentication auth) {
    // 直接authを使用可能
}

Text Blocks

複数行の文字列を読みやすく記述できる機能です。

// 従来の文字列
String[] publicUrls = new String[] {
    "/api/v1/auth/**",
    "/v3/api-docs/**",
    "/swagger-ui/**",
    "/swagger-ui.html"
};

// Java 21のテキストブロック
String[] publicUrls = """
    /api/v1/auth/**
    /v3/api-docs/**
    /swagger-ui/**
    /swagger-ui.html
    """.trim().split("\\s+");

Switch Expressions

より簡潔で安全なswitch文を記述できる機能です。

// 従来のswitch
String role;
switch (userType) {
    case "ADMIN":
        role = "ROLE_ADMIN";
        break;
    case "MANAGER":
        role = "ROLE_MANAGER";
        break;
    default:
        role = "ROLE_USER";
}

// Java 21のSwitch Expression
String role = switch (userType) {
    case "ADMIN" -> "ROLE_ADMIN";
    case "MANAGER" -> "ROLE_MANAGER";
    default -> "ROLE_USER";
};

セキュリティベストプラクティス

JWTを使用する際は、以下のセキュリティベストプラクティスを考慮することが重要です。

トークン管理

  1. 短い有効期限を設定する

アクセストークンの有効期限は15分〜1時間程度に設定し、リスクを最小限に抑えます。

  1. リフレッシュトークンを実装する

ユーザーエクスペリエンスを向上させるため、より長い有効期限(24時間程度)を持つリフレッシュトークンを使用します。

  1. セキュアな秘密鍵を使用する

十分に長く、ランダムな秘密鍵を使用し、環境変数から取得するようにします。

トークン設計

  1. 必要最小限のクレームを使用する

トークンサイズを小さく保ち、必要な情報のみを含めます。

  1. 機密情報を含めない

パスワードやAPIキーなどの機密情報はJWTに含めるべきではありません。

  1. 適切な標準クレームを使用する

isssubexpiatjtiなどの標準クレームを適切に使用します。

実装のセキュリティ

  1. HTTPS必須

JWTはHTTPSで送信し、トークンの盗難を防ぎます。

  1. XSS対策

クライアント側でのトークン保存には、HttpOnly属性を持つクッキーまたはメモリ内ストレージを使用します。

  1. CSRF対策

Stateless JWT認証を使用する場合でも、CSRF対策を忘れないようにします。

認証フロー

  1. ブルートフォース対策

ログイン試行回数を制限し、アカウントロックアウトメカニズムを実装します。

  1. レート制限

APIエンドポイントにレート制限を設け、DoS攻撃を防ぎます。

  1. トークン無効化メカニズム

ユーザーがログアウトした場合や、セキュリティ侵害が発生した場合にトークンを無効化する仕組みを用意します。

実装チェックリスト

JWT認証を実装する際のチェックリストとして、以下の項目を確認しましょう。

  • 依存関係の追加(Spring Security、JJWT)
  • 秘密鍵の安全な管理設定
  • JWTサービスの実装
  • 認証フィルターの実装
  • Spring Security設定
  • 認証コントローラーとサービス実装
  • リフレッシュトークンメカニズム
  • Java 21の新機能活用
  • 単体テストの実装
  • セキュリティのレビュー

APIエンドポイント設計

JWT認証の実装では、以下のようなAPIエンドポイントを設計することが一般的です。

エンドポイント メソッド 説明 認証要否
/api/v1/auth/register POST 新規ユーザー登録 不要
/api/v1/auth/authenticate POST ユーザー認証・JWT取得 不要
/api/v1/auth/refresh-token POST トークン更新 必要 (リフレッシュトークン)
/api/v1/auth/logout POST ログアウト 必要
/api/v1/user/profile GET ユーザー情報取得 必要
/api/v1/admin/** * 管理者専用API 必要 (ADMIN権限)

実装の主要ポイント

  1. アクセストークンとリフレッシュトークン

    • アクセストークン:短い有効期限(15分〜1時間)
    • リフレッシュトークン:長い有効期限(1日程度)
    • アクセストークンが期限切れになった場合、リフレッシュトークンを使用して新しいアクセストークンを取得
  2. トークンの保存場所

    • フロントエンド:HttpOnlyクッキー(推奨)またはlocalStorage/sessionStorage
    • バックエンド:トークンのブラックリスト(Redis等)を使用して、無効化されたトークンを管理
  3. エラーハンドリング

    • トークン検証失敗:401 Unauthorized
    • 権限不足:403 Forbidden
    • 一般的なエラー:適切なHTTPステータスコードとエラーメッセージ

高度な機能拡張

基本的なJWT認証の実装ができたら、以下のような高度な機能を追加することでセキュリティと使いやすさを向上させることができます。

透過的トークン更新

クライアントに負担をかけない自動リフレッシュの実装

@Component
@RequiredArgsConstructor
public class TokenRefreshInterceptor implements ClientHttpRequestInterceptor {

    private final TokenService tokenService;

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, 
                                        ClientHttpRequestExecution execution) throws IOException {
        // 現在のアクセストークンを取得
        String accessToken = tokenService.getCurrentAccessToken();
        
        // トークンが期限切れに近い場合(例:残り5分以内)
        if (tokenService.isTokenNearExpiration(accessToken)) {
            // バックグラウンドでトークンを更新
            tokenService.refreshTokenAsync();
        }
        
        // リクエストにトークンを追加
        request.getHeaders().set("Authorization", "Bearer " + accessToken);
        
        return execution.execute(request, body);
    }
}

デバイス管理

複数デバイスからのログイン管理

public record DeviceInfo(
    String deviceId,
    String deviceType,
    String ipAddress,
    String userAgent,
    LocalDateTime lastAccess
) {}

@Service
@RequiredArgsConstructor
public class DeviceManagementService {

    private final DeviceRepository deviceRepository;
    
    public List<DeviceInfo> getUserDevices(String userId) {
        return deviceRepository.findByUserId(userId);
    }
    
    public void revokeDevice(String userId, String deviceId) {
        deviceRepository.deleteByUserIdAndDeviceId(userId, deviceId);
        // 関連するリフレッシュトークンを無効化
    }
    
    public void recordDeviceAccess(String userId, HttpServletRequest request, String deviceId) {
        DeviceInfo deviceInfo = new DeviceInfo(
            deviceId,
            determineDeviceType(request.getHeader("User-Agent")),
            request.getRemoteAddr(),
            request.getHeader("User-Agent"),
            LocalDateTime.now()
        );
        
        deviceRepository.save(userId, deviceInfo);
    }
}

監査ログ

認証イベントの詳細なログ記録

@Service
@RequiredArgsConstructor
public class AuthAuditService {

    private final AuditLogRepository auditLogRepository;
    
    public void logAuthEvent(String userId, String eventType, String details, String ipAddress) {
        AuthAuditLog log = AuthAuditLog.builder()
            .userId(userId)
            .eventType(eventType)
            .details(details)
            .ipAddress(ipAddress)
            .timestamp(LocalDateTime.now())
            .build();
            
        auditLogRepository.save(log);
    }
    
    public List<AuthAuditLog> getUserAuthHistory(String userId, int limit) {
        return auditLogRepository.findByUserIdOrderByTimestampDesc(userId, PageRequest.of(0, limit));
    }
}

多要素認証(MFA)

追加の認証レイヤーによるセキュリティ強化

@Service
@RequiredArgsConstructor
public class MfaService {

    private final OtpRepository otpRepository;
    private final EmailService emailService;
    
    public void sendOtpCode(String email) {
        String otpCode = generateOtpCode();
        
        // OTPコードを保存(有効期限付き)
        otpRepository.save(email, otpCode, LocalDateTime.now().plusMinutes(10));
        
        // メールでOTPコードを送信
        emailService.sendOtpEmail(email, otpCode);
    }
    
    public boolean verifyOtpCode(String email, String otpCode) {
        return otpRepository.findByEmail(email)
            .filter(otp -> otp.getCode().equals(otpCode))
            .filter(otp -> otp.getExpiresAt().isAfter(LocalDateTime.now()))
            .isPresent();
    }
    
    private String generateOtpCode() {
        // 6桁のランダムな数字を生成
        return String.format("%06d", new Random().nextInt(1000000));
    }
}

まとめ

この記事では、Java 21とSpring Boot 3を使用したJWT認証の実装について詳しく解説しました。JWTの基本概念から始まり、具体的な実装方法、セキュリティベストプラクティス、そして高度な機能拡張まで幅広くカバーしています。

Java 21の新機能(Record、Pattern Matching、Text Blocks、Switch Expressions)を活用することで、よりクリーンで保守性の高いコードを書くことができます。また、Spring Security 6の新しい設定スタイルを使うことで、セキュリティ設定もより直感的になりました。

JWT認証は、特にマイクロサービスアーキテクチャやSPAフロントエンドを持つアプリケーションに最適なソリューションですが、実装する際にはセキュリティに細心の注意を払う必要があります。この記事で紹介したベストプラクティスを適用することで、安全で堅牢な認証システムを構築できるでしょう。

最後に、認証はアプリケーションセキュリティの一部に過ぎないことを忘れないでください。完全なセキュリティソリューションには、適切な認可メカニズム、セキュアなコーディングプラクティス、定期的なセキュリティレビューなど、多層的なアプローチが必要です。

参考リソース

Discussion