Spring Security におけるカスタムトークン検証フィルタの実装

Spring Security を用いてリソースサーバー向けに独自のトークン検証ロジックを導入する場合、標準の JWT 検証に加えて、ビジネス要件に応じた追加チェック(例:ホワイトリスト検証、有効期限延長制御、IP ベース制限など)を挿入することがよくあります。以下は、Filter と AuthenticationProvider を組み合わせたモダンなアプローチで、Spring Boot 2.7+ および Spring Security 5.7+ の推奨スタイルに沿った実装方法です。

1. トークン抽出フィルタの作成

抽象クラス OncePerRequestFilter を継承し、リクエストヘッダーまたはクエリパラメータからトークンを安全に抽出します。このフィルタは認証フローの初期段階で実行されます。

public class BearerTokenExtractorFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain chain) throws ServletException, IOException {
        String rawToken = extractRawToken(request);
        if (rawToken != null) {
            // 認証リクエストオブジェクトを構築してセキュリティコンテキストに渡す
            Authentication authRequest = new PreAuthenticatedToken(rawToken);
            SecurityContextHolder.getContext().setAuthentication(authRequest);
        }
        chain.doFilter(request, response);
    }

    private String extractRawToken(HttpServletRequest request) {
        String header = request.getHeader(AUTHORIZATION_HEADER);
        if (header != null && header.startsWith(BEARER_PREFIX)) {
            return header.substring(BEARER_PREFIX.length()).trim();
        }
        // クエリパラメータからのフォールバック(例: ?access_token=...)
        return request.getParameter("access_token");
    }
}

2. 拡張可能な認証プロバイダの実装

AuthenticationProvider を実装し、トークンの構文検証、署名検証、およびアプリケーション固有のルール(例:ユーザー状態確認、スコープ権限付与)を一括処理します。

@Component
public class EnhancedJwtAuthenticationProvider implements AuthenticationProvider {

    private final JwtDecoder jwtDecoder;
    private final UserService userService; // ユーザー存在・有効性確認用

    public EnhancedJwtAuthenticationProvider(JwtDecoder jwtDecoder, UserService userService) {
        this.jwtDecoder = jwtDecoder;
        this.userService = userService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String rawToken = ((PreAuthenticatedToken) authentication).getCredentials().toString();

        try {
            Jwt jwt = jwtDecoder.decode(rawToken);

            // 標準JWT検証(署名、exp, iatなど)はdecoderが実施済み
            validateBusinessRules(jwt);

            // ユーザー情報と権限を含む完全な認証オブジェクトを生成
            Collection<GrantedAuthority> authorities = buildAuthorities(jwt);
            UserDetails userDetails = userService.loadUserByUsername(jwt.getSubject());

            return new UsernamePasswordAuthenticationToken(
                userDetails,
                rawToken,
                authorities
            );

        } catch (JwtException | IllegalArgumentException e) {
            throw new BadCredentialsException("Invalid or expired token", e);
        }
    }

    private void validateBusinessRules(Jwt jwt) {
        // カスタム検証:例 — 発行元(iss)のホワイトリストチェック
        if (!List.of("https://auth.example.com", "https://idp.internal").contains(jwt.getIssuer())) {
            throw new InvalidGrantException("Unauthorized issuer: " + jwt.getIssuer());
        }

        // 例 — 特定クライアントのみ許可
        if (!"web-app".equals(jwt.getClaimAsString("client_id"))) {
            throw new InsufficientAuthenticationException("Client not authorized");
        }
    }

    private Collection<GrantedAuthority> buildAuthorities(Jwt jwt) {
        return jwt.getClaimAsStringList("roles").stream()
                  .map(SimpleGrantedAuthority::new)
                  .collect(Collectors.toList());
    }

    @Override
    public boolean supports(Class authenticationType) {
        return PreAuthenticatedToken.class.isAssignableFrom(authenticationType);
    }
}

3. セキュリティ設定の統合

Spring Security の Java コンフィグレーションで、上記フィルタとプロバイダを登録します。`ResourceServerConfigurerAdapter` は非推奨のため、`SecurityFilterChain` Bean を使用します。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                          BearerTokenExtractorFilter extractorFilter,
                                          EnhancedJwtAuthenticationProvider provider) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/actuator/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(extractorFilter, UsernamePasswordAuthenticationFilter.class)
            .authenticationProvider(provider);

        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withPublicKey(
            readPublicKeyFromResource("/certs/jwt-public-key.pem")
        ).build();
    }

    private RSAPublicKey readPublicKeyFromResource(String path) {
        // PEM形式の公開鍵を読み込むロジック(省略)
        return null;
    }
}

4. コントローラーでの利用

検証済みの認証情報を `@AuthenticationPrincipal` で取得し、JWT のペイロードやユーザー属性にアクセスできます。

@RestController
@RequestMapping("/api")
public class DataController {

    @GetMapping("/profile")
    public ResponseEntity> getProfile(@AuthenticationPrincipal UserDetails user) {
        Map profile = new HashMap<>();
        profile.put("username", user.getUsername());
        profile.put("authorities", user.getAuthorities());
        return ResponseEntity.ok(profile);
    }

    @GetMapping("/token-info")
    public ResponseEntity> getTokenInfo(
            @AuthenticationPrincipal Jwt jwt) { // 直接Jwtオブジェクトも注入可能
        Map info = new HashMap<>();
        info.put("issuer", jwt.getIssuer());
        info.put("scopes", jwt.getClaimAsStringList("scope"));
        info.put("custom_field", jwt.getClaimAsString("x-custom-flag"));
        return ResponseEntity.ok(info);
    }
}

このアプローチでは、フィルタによるトークン抽出、デコーダによる暗号検証、プロバイダによるビジネスルール適用という明確な関心分離が実現され、テスト性・保守性・拡張性が向上します。また、OAuth2 Resource Server の旧式の `@EnableResourceServer` 依存を排除し、Spring Security の最新のセキュリティモデルに準拠しています。

タグ: spring-security JWT authentication-filter Java spring-boot

5月17日 01:09 投稿