REST APIでJWT認証を実現するには、リクエストごとにトークンを検証するカスタムフィルターが必要です。本記事では、Spring SecurityのOncePerRequestFilterを継承したJWT認証フィルターの実装方法を解説します。Bearer Tokenの抽出からSecurityContextへの認証情報設定、トークン期限切れや不正トークンの例外ハンドリングまで、本番環境で使用できるレベルの実装を目指します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Security 6.4.x
jjwt 0.13.0
ビルドツール Maven または Gradle

事前に以下の知識があると理解がスムーズです。

  • Spring SecurityのFilterChainアーキテクチャ
  • JWTの構造と検証の仕組み
  • Servlet Filterの基本概念

OncePerRequestFilterとは

OncePerRequestFilterは、Spring Frameworkが提供する抽象クラスで、リクエストごとに1回だけ実行されることを保証するFilterです。JWT認証フィルターを実装する際のベースクラスとして最適です。

なぜOncePerRequestFilterを使うのか

通常のServlet Filterは、フォワードやインクルードが発生すると同一リクエスト内で複数回実行される可能性があります。OncePerRequestFilterはこの問題を解決し、以下のメリットを提供します。

特徴 説明
1回実行保証 リクエスト内で複数回呼び出されても1回のみ実行
HttpServletRequestのみ対応 ServletRequestではなくHttpServletRequestを直接扱える
非同期対応 非同期ディスパッチでの再実行を防止
スキップ条件設定 特定条件でフィルター処理をスキップ可能

OncePerRequestFilterの実行フロー

OncePerRequestFilterの内部動作を以下に示します。

flowchart TD
    A[リクエスト到着] --> B{既に実行済み?}
    B -->|はい| C[スキップして次のFilterへ]
    B -->|いいえ| D{スキップ対象?}
    D -->|はい| C
    D -->|いいえ| E[doFilterInternalを実行]
    E --> F[実行済みマークを設定]
    F --> G[次のFilterへ]
    C --> G

JWT認証フィルターの設計

JWT認証フィルターが担う責務を明確にし、設計方針を定めます。

フィルターの責務

JWT認証フィルターは以下の処理を担当します。

  1. Authorizationヘッダーの検査: Bearer Tokenの有無を確認
  2. トークンの抽出: Bearer プレフィックスを除去してJWTを取得
  3. トークンの検証: 署名と有効期限を検証
  4. 認証情報の設定: SecurityContextにAuthenticationオブジェクトを設定
  5. 例外ハンドリング: トークン検証失敗時の適切なエラーレスポンス

認証フローの全体像

sequenceDiagram
    participant Client as クライアント
    participant Filter as JwtAuthenticationFilter
    participant JwtService as JwtService
    participant UDS as UserDetailsService
    participant Context as SecurityContext
    participant Chain as FilterChain
    
    Client->>Filter: リクエスト + Authorization: Bearer xxx
    Filter->>Filter: Authorizationヘッダー抽出
    Filter->>Filter: Bearer プレフィックス除去
    Filter->>JwtService: トークン検証・ユーザー名抽出
    alt トークン有効
        JwtService-->>Filter: ユーザー名
        Filter->>UDS: loadUserByUsername()
        UDS-->>Filter: UserDetails
        Filter->>Context: Authentication設定
        Filter->>Chain: doFilter()
    else トークン無効
        JwtService-->>Filter: 例外発生
        Filter-->>Client: 401 Unauthorized
    end

JWT認証フィルターの実装

OncePerRequestFilterを継承したJWT認証フィルターを実装します。

基本構造

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package com.example.security.filter;

import com.example.security.service.JwtService;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";
    
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    
    public JwtAuthenticationFilter(
            JwtService jwtService,
            UserDetailsService userDetailsService
    ) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }
    
    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        
        // Authorizationヘッダーからトークンを抽出
        String token = extractToken(request);
        
        // トークンが存在しない場合は認証をスキップ
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }
        
        try {
            // トークンからユーザー名を抽出
            String username = jwtService.extractUsername(token);
            
            // まだ認証されていない場合のみ処理
            if (username != null && !isAlreadyAuthenticated()) {
                authenticateUser(request, token, username);
            }
            
            filterChain.doFilter(request, response);
            
        } catch (ExpiredJwtException e) {
            handleExpiredToken(response, e);
        } catch (SignatureException e) {
            handleInvalidSignature(response, e);
        } catch (MalformedJwtException e) {
            handleMalformedToken(response, e);
        } catch (JwtException e) {
            handleJwtException(response, e);
        } catch (UsernameNotFoundException e) {
            handleUserNotFound(response, e);
        }
    }
    
    /**
     * Authorizationヘッダーからトークンを抽出する
     */
    private String extractToken(HttpServletRequest request) {
        String authHeader = request.getHeader(AUTHORIZATION_HEADER);
        
        if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
            return null;
        }
        
        return authHeader.substring(BEARER_PREFIX.length());
    }
    
    /**
     * 既に認証済みかどうかを確認する
     */
    private boolean isAlreadyAuthenticated() {
        return SecurityContextHolder.getContext().getAuthentication() != null;
    }
    
    /**
     * ユーザーを認証し、SecurityContextに設定する
     */
    private void authenticateUser(
            HttpServletRequest request,
            String token,
            String username
    ) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        
        if (jwtService.isTokenValid(token, userDetails)) {
            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
            
            authToken.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request)
            );
            
            SecurityContextHolder.getContext().setAuthentication(authToken);
            
            log.debug("ユーザー '{}' の認証に成功しました", username);
        }
    }
    
    // 例外ハンドリングメソッドは後述
}

メソッドの役割

メソッド 役割
doFilterInternal フィルターのメイン処理
extractToken Authorizationヘッダーからトークンを抽出
isAlreadyAuthenticated 既に認証済みかどうかを確認
authenticateUser ユーザーを認証しSecurityContextに設定

AuthorizationヘッダーからのBearer Token抽出

Bearer Tokenの抽出処理を詳しく解説します。

Bearer認証スキームの仕様

RFC 6750で定義されたBearer認証スキームでは、トークンは以下の形式でAuthorizationヘッダーに含められます。

1
Authorization: Bearer <token>

堅牢なトークン抽出の実装

エッジケースを考慮したトークン抽出処理を実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
 * Authorizationヘッダーからトークンを抽出する
 * 
 * @param request HTTPリクエスト
 * @return トークン文字列、または抽出できない場合はnull
 */
private String extractToken(HttpServletRequest request) {
    String authHeader = request.getHeader(AUTHORIZATION_HEADER);
    
    // ヘッダーが存在しない場合
    if (authHeader == null) {
        log.trace("Authorizationヘッダーが存在しません");
        return null;
    }
    
    // Bearerスキームでない場合
    if (!authHeader.startsWith(BEARER_PREFIX)) {
        log.trace("Bearer認証スキームではありません: {}", 
                authHeader.substring(0, Math.min(authHeader.length(), 10)));
        return null;
    }
    
    // トークンを抽出
    String token = authHeader.substring(BEARER_PREFIX.length()).trim();
    
    // 空のトークンチェック
    if (token.isEmpty()) {
        log.warn("空のBearerトークンを受信しました");
        return null;
    }
    
    return token;
}

抽出処理のフロー図

flowchart TD
    A[リクエスト受信] --> B{Authorizationヘッダーあり?}
    B -->|なし| C[null返却]
    B -->|あり| D{Bearer で開始?}
    D -->|いいえ| C
    D -->|はい| E[プレフィックス除去]
    E --> F[前後の空白を除去]
    F --> G{空文字列?}
    G -->|はい| C
    G -->|いいえ| H[トークン返却]

SecurityContextへの認証情報設定

認証成功時にSecurityContextへ認証情報を設定する処理を解説します。

UsernamePasswordAuthenticationTokenの構築

JWT認証では、パスワードなしのUsernamePasswordAuthenticationTokenを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * 認証トークンを構築してSecurityContextに設定する
 */
private void setAuthentication(
        HttpServletRequest request,
        UserDetails userDetails
) {
    // 認証済みトークンを作成(credentialsはnull)
    UsernamePasswordAuthenticationToken authToken =
            new UsernamePasswordAuthenticationToken(
                    userDetails,    // principal
                    null,           // credentials(JWTでは不要)
                    userDetails.getAuthorities()  // authorities
            );
    
    // リクエスト詳細を設定(IPアドレス、セッションIDなど)
    authToken.setDetails(
            new WebAuthenticationDetailsSource().buildDetails(request)
    );
    
    // SecurityContextに認証情報を設定
    SecurityContextHolder.getContext().setAuthentication(authToken);
}

コンストラクタの違い

UsernamePasswordAuthenticationTokenには2つのコンストラクタがあります。

コンストラクタ 用途 isAuthenticated
(principal, credentials) 認証前のトークン作成 false
(principal, credentials, authorities) 認証済みトークン作成 true

JWT認証フィルターでは、トークン検証後に認証済みトークンを作成するため、3引数のコンストラクタを使用します。

WebAuthenticationDetailsの重要性

WebAuthenticationDetailsSourceは、HTTPリクエストから以下の情報を抽出します。

1
2
3
4
public class WebAuthenticationDetails implements Serializable {
    private final String remoteAddress;  // クライアントIPアドレス
    private final String sessionId;       // セッションID(存在する場合)
}

これらの情報は、監査ログやセキュリティ分析に活用できます。

例外ハンドリングの実装

JWTトークン検証で発生する各種例外を適切に処理します。

発生しうる例外の種類

jjwtライブラリが送出する主要な例外は以下のとおりです。

例外クラス 発生条件 HTTPステータス
ExpiredJwtException トークンの有効期限切れ 401
SignatureException 署名が不正(改ざん検出) 401
MalformedJwtException トークン形式が不正 400
UnsupportedJwtException サポートしていないJWT形式 400
IllegalArgumentException トークンがnullまたは空 400

例外ハンドリングメソッドの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
 * トークン期限切れエラーの処理
 */
private void handleExpiredToken(
        HttpServletResponse response,
        ExpiredJwtException e
) throws IOException {
    log.warn("JWTトークンが期限切れです: {}", e.getMessage());
    
    sendErrorResponse(
            response,
            HttpServletResponse.SC_UNAUTHORIZED,
            "TOKEN_EXPIRED",
            "トークンの有効期限が切れています。再度ログインしてください。"
    );
}

/**
 * 署名不正エラーの処理
 */
private void handleInvalidSignature(
        HttpServletResponse response,
        SignatureException e
) throws IOException {
    log.error("JWTの署名が不正です: {}", e.getMessage());
    
    sendErrorResponse(
            response,
            HttpServletResponse.SC_UNAUTHORIZED,
            "INVALID_SIGNATURE",
            "トークンの署名が不正です。"
    );
}

/**
 * トークン形式不正エラーの処理
 */
private void handleMalformedToken(
        HttpServletResponse response,
        MalformedJwtException e
) throws IOException {
    log.warn("JWTの形式が不正です: {}", e.getMessage());
    
    sendErrorResponse(
            response,
            HttpServletResponse.SC_BAD_REQUEST,
            "MALFORMED_TOKEN",
            "トークンの形式が不正です。"
    );
}

/**
 * その他のJWT例外の処理
 */
private void handleJwtException(
        HttpServletResponse response,
        JwtException e
) throws IOException {
    log.error("JWT処理エラー: {}", e.getMessage());
    
    sendErrorResponse(
            response,
            HttpServletResponse.SC_UNAUTHORIZED,
            "INVALID_TOKEN",
            "トークンの検証に失敗しました。"
    );
}

/**
 * ユーザー未発見エラーの処理
 */
private void handleUserNotFound(
        HttpServletResponse response,
        UsernameNotFoundException e
) throws IOException {
    log.warn("ユーザーが見つかりません: {}", e.getMessage());
    
    sendErrorResponse(
            response,
            HttpServletResponse.SC_UNAUTHORIZED,
            "USER_NOT_FOUND",
            "認証に失敗しました。"
    );
}

エラーレスポンスの送信

統一されたエラーレスポンス形式で返却します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * エラーレスポンスを送信する
 */
private void sendErrorResponse(
        HttpServletResponse response,
        int status,
        String errorCode,
        String message
) throws IOException {
    response.setStatus(status);
    response.setContentType("application/json");
    response.setCharacterEncoding("UTF-8");
    
    String jsonResponse = String.format(
            "{\"error\":\"%s\",\"message\":\"%s\",\"timestamp\":\"%s\"}",
            errorCode,
            message,
            java.time.Instant.now().toString()
    );
    
    response.getWriter().write(jsonResponse);
}

エラーレスポンスDTO(オプション)

より構造化されたエラーレスポンスを返す場合は、DTOクラスを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.example.security.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;

public record ErrorResponse(
        String error,
        String message,
        String path,
        @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
        LocalDateTime timestamp
) {
    public ErrorResponse(String error, String message, String path) {
        this(error, message, path, LocalDateTime.now());
    }
}

ObjectMapperを使用した送信処理は以下のとおりです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private final ObjectMapper objectMapper;

private void sendErrorResponse(
        HttpServletRequest request,
        HttpServletResponse response,
        int status,
        String errorCode,
        String message
) throws IOException {
    response.setStatus(status);
    response.setContentType("application/json");
    response.setCharacterEncoding("UTF-8");
    
    ErrorResponse errorResponse = new ErrorResponse(
            errorCode,
            message,
            request.getRequestURI()
    );
    
    response.getWriter().write(
            objectMapper.writeValueAsString(errorResponse)
    );
}

特定パスのスキップ設定

認証不要なパス(ログインエンドポイントなど)をフィルター処理から除外します。

shouldNotFilterメソッドのオーバーライド

1
2
3
4
5
6
7
8
9
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    String path = request.getServletPath();
    
    // 認証不要なパスをスキップ
    return path.startsWith("/api/auth/") 
            || path.startsWith("/api/public/")
            || path.equals("/actuator/health");
}

AntPathMatcherを使用した柔軟な設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private final List<String> excludePatterns = List.of(
        "/api/auth/**",
        "/api/public/**",
        "/actuator/health",
        "/actuator/info",
        "/swagger-ui/**",
        "/v3/api-docs/**"
);

private final AntPathMatcher pathMatcher = new AntPathMatcher();

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    String path = request.getServletPath();
    
    return excludePatterns.stream()
            .anyMatch(pattern -> pathMatcher.match(pattern, path));
}

設定ファイルからの読み込み

1
2
3
4
5
6
7
@Component
@ConfigurationProperties(prefix = "security.jwt")
public class JwtFilterProperties {
    private List<String> excludePatterns = new ArrayList<>();
    
    // getter/setter
}
1
2
3
4
5
6
7
# application.yml
security:
  jwt:
    exclude-patterns:
      - /api/auth/**
      - /api/public/**
      - /actuator/health

SecurityFilterChainへの組み込み

実装したJWT認証フィルターをSpring Securityに組み込みます。

設定クラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.example.security.config;

import com.example.security.filter.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    
    public SecurityConfig(
            JwtAuthenticationFilter jwtAuthenticationFilter,
            JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint
    ) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
    }
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                // CSRF無効化(ステートレスAPIのため)
                .csrf(AbstractHttpConfigurer::disable)
                
                // 認可設定
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers("/api/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                
                // セッション管理をステートレスに
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                
                // 例外ハンドリング
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                )
                
                // JWTフィルターを追加
                .addFilterBefore(
                        jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class
                )
                
                .build();
    }
}

フィルターの配置位置

flowchart TD
    A[SecurityContextHolderFilter] --> B[HeaderWriterFilter]
    B --> C[CsrfFilter]
    C --> D[LogoutFilter]
    D --> E[JwtAuthenticationFilter]
    E --> F[UsernamePasswordAuthenticationFilter]
    F --> G[BasicAuthenticationFilter]
    G --> H[AnonymousAuthenticationFilter]
    H --> I[ExceptionTranslationFilter]
    I --> J[AuthorizationFilter]
    
    style E fill:#f9f,stroke:#333,stroke-width:2px

addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)により、JWT認証フィルターはUsernamePasswordAuthenticationFilterの直前に配置されます。

AuthenticationEntryPointの実装

認証されていないリクエストへのレスポンスをカスタマイズします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.security.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Instant;
import java.util.Map;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    private final ObjectMapper objectMapper;
    
    public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    
    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    ) throws IOException {
        
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        
        Map<String, Object> errorResponse = Map.of(
                "error", "UNAUTHORIZED",
                "message", "認証が必要です",
                "path", request.getRequestURI(),
                "timestamp", Instant.now().toString()
        );
        
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}

完全な実装コード

これまでの実装をすべて含んだ完全版のJWT認証フィルターを示します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
package com.example.security.filter;

import com.example.security.service.JwtService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";
    
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    private final ObjectMapper objectMapper;
    private final AntPathMatcher pathMatcher = new AntPathMatcher();
    
    private final List<String> excludePatterns = List.of(
            "/api/auth/**",
            "/api/public/**",
            "/actuator/health"
    );
    
    public JwtAuthenticationFilter(
            JwtService jwtService,
            UserDetailsService userDetailsService,
            ObjectMapper objectMapper
    ) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
        this.objectMapper = objectMapper;
    }
    
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getServletPath();
        return excludePatterns.stream()
                .anyMatch(pattern -> pathMatcher.match(pattern, path));
    }
    
    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        
        String token = extractToken(request);
        
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }
        
        try {
            String username = jwtService.extractUsername(token);
            
            if (username != null && !isAlreadyAuthenticated()) {
                authenticateUser(request, token, username);
            }
            
            filterChain.doFilter(request, response);
            
        } catch (ExpiredJwtException e) {
            handleExpiredToken(request, response, e);
        } catch (SignatureException e) {
            handleInvalidSignature(request, response, e);
        } catch (MalformedJwtException e) {
            handleMalformedToken(request, response, e);
        } catch (JwtException e) {
            handleJwtException(request, response, e);
        } catch (UsernameNotFoundException e) {
            handleUserNotFound(request, response, e);
        }
    }
    
    private String extractToken(HttpServletRequest request) {
        String authHeader = request.getHeader(AUTHORIZATION_HEADER);
        
        if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
            return null;
        }
        
        String token = authHeader.substring(BEARER_PREFIX.length()).trim();
        return token.isEmpty() ? null : token;
    }
    
    private boolean isAlreadyAuthenticated() {
        return SecurityContextHolder.getContext().getAuthentication() != null;
    }
    
    private void authenticateUser(
            HttpServletRequest request,
            String token,
            String username
    ) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        
        if (jwtService.isTokenValid(token, userDetails)) {
            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
            
            authToken.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request)
            );
            
            SecurityContextHolder.getContext().setAuthentication(authToken);
            log.debug("ユーザー '{}' の認証に成功", username);
        }
    }
    
    private void handleExpiredToken(
            HttpServletRequest request,
            HttpServletResponse response,
            ExpiredJwtException e
    ) throws IOException {
        log.warn("JWTトークン期限切れ: {}", e.getMessage());
        sendErrorResponse(request, response, 
                HttpServletResponse.SC_UNAUTHORIZED,
                "TOKEN_EXPIRED",
                "トークンの有効期限が切れています");
    }
    
    private void handleInvalidSignature(
            HttpServletRequest request,
            HttpServletResponse response,
            SignatureException e
    ) throws IOException {
        log.error("JWT署名不正: {}", e.getMessage());
        sendErrorResponse(request, response,
                HttpServletResponse.SC_UNAUTHORIZED,
                "INVALID_SIGNATURE",
                "トークンの署名が不正です");
    }
    
    private void handleMalformedToken(
            HttpServletRequest request,
            HttpServletResponse response,
            MalformedJwtException e
    ) throws IOException {
        log.warn("JWT形式不正: {}", e.getMessage());
        sendErrorResponse(request, response,
                HttpServletResponse.SC_BAD_REQUEST,
                "MALFORMED_TOKEN",
                "トークンの形式が不正です");
    }
    
    private void handleJwtException(
            HttpServletRequest request,
            HttpServletResponse response,
            JwtException e
    ) throws IOException {
        log.error("JWT処理エラー: {}", e.getMessage());
        sendErrorResponse(request, response,
                HttpServletResponse.SC_UNAUTHORIZED,
                "INVALID_TOKEN",
                "トークンの検証に失敗しました");
    }
    
    private void handleUserNotFound(
            HttpServletRequest request,
            HttpServletResponse response,
            UsernameNotFoundException e
    ) throws IOException {
        log.warn("ユーザー未発見: {}", e.getMessage());
        sendErrorResponse(request, response,
                HttpServletResponse.SC_UNAUTHORIZED,
                "USER_NOT_FOUND",
                "認証に失敗しました");
    }
    
    private void sendErrorResponse(
            HttpServletRequest request,
            HttpServletResponse response,
            int status,
            String errorCode,
            String message
    ) throws IOException {
        response.setStatus(status);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        
        Map<String, Object> errorResponse = Map.of(
                "error", errorCode,
                "message", message,
                "path", request.getRequestURI(),
                "timestamp", Instant.now().toString()
        );
        
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}

動作確認

実装したJWT認証フィルターの動作を確認します。

正常系:有効なトークンでのリクエスト

1
2
3
4
5
6
7
8
# ログインしてトークンを取得
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user","password":"password"}' | jq -r '.accessToken')

# 保護されたAPIにアクセス
curl -X GET http://localhost:8080/api/users/me \
  -H "Authorization: Bearer $TOKEN"

期待されるレスポンス:

1
2
3
4
5
{
  "id": 1,
  "username": "user",
  "email": "user@example.com"
}

異常系:トークンなしでのリクエスト

1
curl -X GET http://localhost:8080/api/users/me

期待されるレスポンス(401 Unauthorized):

1
2
3
4
5
6
{
  "error": "UNAUTHORIZED",
  "message": "認証が必要です",
  "path": "/api/users/me",
  "timestamp": "2026-01-12T03:00:00Z"
}

異常系:期限切れトークンでのリクエスト

1
2
3
4
5
6
{
  "error": "TOKEN_EXPIRED",
  "message": "トークンの有効期限が切れています",
  "path": "/api/users/me",
  "timestamp": "2026-01-12T03:00:00Z"
}

異常系:不正なトークンでのリクエスト

1
2
curl -X GET http://localhost:8080/api/users/me \
  -H "Authorization: Bearer invalid.token.here"

期待されるレスポンス(400 Bad Request):

1
2
3
4
5
6
{
  "error": "MALFORMED_TOKEN",
  "message": "トークンの形式が不正です",
  "path": "/api/users/me",
  "timestamp": "2026-01-12T03:00:00Z"
}

テストコード

JWT認証フィルターの単体テストを実装します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package com.example.security.filter;

import com.example.security.service.JwtService;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import jakarta.servlet.FilterChain;
import java.util.Collections;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class JwtAuthenticationFilterTest {
    
    @Mock
    private JwtService jwtService;
    
    @Mock
    private UserDetailsService userDetailsService;
    
    @Mock
    private FilterChain filterChain;
    
    private JwtAuthenticationFilter filter;
    private MockHttpServletRequest request;
    private MockHttpServletResponse response;
    
    @BeforeEach
    void setUp() {
        filter = new JwtAuthenticationFilter(
                jwtService, 
                userDetailsService,
                new com.fasterxml.jackson.databind.ObjectMapper()
        );
        request = new MockHttpServletRequest();
        response = new MockHttpServletResponse();
        SecurityContextHolder.clearContext();
    }
    
    @Test
    @DisplayName("有効なJWTで認証が成功する")
    void shouldAuthenticateWithValidToken() throws Exception {
        // Arrange
        String token = "valid.jwt.token";
        String username = "testuser";
        UserDetails userDetails = User.builder()
                .username(username)
                .password("password")
                .authorities(Collections.emptyList())
                .build();
        
        request.addHeader("Authorization", "Bearer " + token);
        
        when(jwtService.extractUsername(token)).thenReturn(username);
        when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails);
        when(jwtService.isTokenValid(token, userDetails)).thenReturn(true);
        
        // Act
        filter.doFilterInternal(request, response, filterChain);
        
        // Assert
        assertThat(SecurityContextHolder.getContext().getAuthentication())
                .isNotNull();
        assertThat(SecurityContextHolder.getContext().getAuthentication().getName())
                .isEqualTo(username);
        verify(filterChain).doFilter(request, response);
    }
    
    @Test
    @DisplayName("Authorizationヘッダーがない場合は認証をスキップ")
    void shouldSkipAuthenticationWhenNoAuthHeader() throws Exception {
        // Act
        filter.doFilterInternal(request, response, filterChain);
        
        // Assert
        assertThat(SecurityContextHolder.getContext().getAuthentication())
                .isNull();
        verify(filterChain).doFilter(request, response);
        verifyNoInteractions(jwtService);
    }
    
    @Test
    @DisplayName("期限切れトークンで401エラーを返す")
    void shouldReturn401WhenTokenExpired() throws Exception {
        // Arrange
        request.addHeader("Authorization", "Bearer expired.token");
        
        when(jwtService.extractUsername(anyString()))
                .thenThrow(new ExpiredJwtException(null, null, "Token expired"));
        
        // Act
        filter.doFilterInternal(request, response, filterChain);
        
        // Assert
        assertThat(response.getStatus()).isEqualTo(401);
        assertThat(response.getContentAsString()).contains("TOKEN_EXPIRED");
        verifyNoInteractions(filterChain);
    }
    
    @Test
    @DisplayName("不正形式のトークンで400エラーを返す")
    void shouldReturn400WhenTokenMalformed() throws Exception {
        // Arrange
        request.addHeader("Authorization", "Bearer malformed");
        
        when(jwtService.extractUsername(anyString()))
                .thenThrow(new MalformedJwtException("Malformed token"));
        
        // Act
        filter.doFilterInternal(request, response, filterChain);
        
        // Assert
        assertThat(response.getStatus()).isEqualTo(400);
        assertThat(response.getContentAsString()).contains("MALFORMED_TOKEN");
    }
}

まとめ

本記事では、Spring SecurityのOncePerRequestFilterを継承したJWT認証フィルターの実装方法を解説しました。

主なポイントは以下のとおりです。

  • OncePerRequestFilterはリクエストごとに1回だけ実行されることを保証する
  • Bearer Tokenの抽出ではエッジケースを考慮した堅牢な実装が重要
  • SecurityContextへの認証情報設定には3引数コンストラクタを使用
  • 例外の種類に応じた適切なHTTPステータスコードとエラーメッセージの返却
  • shouldNotFilterメソッドで認証不要パスをスキップ
  • addFilterBeforeでUsernamePasswordAuthenticationFilterの前に配置

JWT認証フィルターは、REST APIのセキュリティにおける重要なコンポーネントです。本記事の実装をベースに、プロジェクトの要件に合わせてカスタマイズしてください。

参考リンク