REST APIやマイクロサービスにおいて、JWT(JSON Web Token)を使用したステートレス認証は、スケーラビリティとセキュリティを両立する標準的なアプローチです。本記事では、JWTの仕組みを理解し、Spring Security 6.4でJWTトークンの生成と検証を実装する方法を解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Security |
6.4.x |
| jjwt |
0.13.0 |
| ビルドツール |
Maven または Gradle |
| IDE |
VS Code または IntelliJ IDEA |
事前に以下の知識があると理解がスムーズです。
- Spring Securityの基本概念(認証・認可の違い)
- Spring Bootプロジェクトの基本的な構成
- REST APIの実装経験
JWTとは何か#
JWT(JSON Web Token)は、RFC 7519で定義された、JSONオブジェクトを使用して当事者間で安全に情報を伝達するためのコンパクトな方法です。JWTは署名されるため、送信者の真正性と内容の完全性を検証できます。
JWTの主な特徴#
JWTには以下の特徴があります。
| 特徴 |
説明 |
| コンパクト |
URLやHTTPヘッダーで容易に送信可能なサイズ |
| 自己完結型 |
トークン自体にユーザー情報を含む |
| 署名済み |
改ざんを検出可能 |
| 標準化 |
RFC 7519として標準化されており、多言語対応 |
JWTの構造#
JWTは3つの部分で構成され、それぞれがピリオド(.)で区切られています。
xxxxx.yyyyy.zzzzz
↑ ↑ ↑
Header Payload Signature
以下にJWTの構造を図示します。
flowchart LR
subgraph JWT["JWT Token"]
H["Header<br/>(Base64URL)"]
P["Payload<br/>(Base64URL)"]
S["Signature<br/>(Base64URL)"]
end
H --> |"."| P
P --> |"."| S
ヘッダーは、トークンの種類と使用する署名アルゴリズムを指定するJSONオブジェクトです。
1
2
3
4
|
{
"alg": "HS256",
"typ": "JWT"
}
|
| フィールド |
説明 |
alg |
署名アルゴリズム(HS256、RS256など) |
typ |
トークンタイプ(通常は"JWT") |
このJSONはBase64URLエンコードされ、JWTの最初の部分となります。
Payload(ペイロード)#
ペイロードには、クレーム(claim)と呼ばれるエンティティ(通常はユーザー)に関する情報と追加データが含まれます。
1
2
3
4
5
6
7
|
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622,
"roles": ["ROLE_USER", "ROLE_ADMIN"]
}
|
クレームには3種類あります。
| クレームの種類 |
説明 |
例 |
| 登録済みクレーム |
JWTの仕様で事前定義されたクレーム |
sub, iss, exp, iat, aud |
| パブリッククレーム |
自由に定義可能だが、衝突を避けるため登録が推奨 |
IANAのJWT Claimsレジストリに登録 |
| プライベートクレーム |
当事者間で合意した独自のクレーム |
roles, department |
主要な登録済みクレームは以下のとおりです。
| クレーム |
正式名称 |
説明 |
sub |
Subject |
トークンの主体(通常はユーザーID) |
iss |
Issuer |
トークンの発行者 |
aud |
Audience |
トークンの受信者 |
exp |
Expiration Time |
トークンの有効期限(UNIXタイムスタンプ) |
iat |
Issued At |
トークンの発行日時(UNIXタイムスタンプ) |
nbf |
Not Before |
トークンが有効になる日時 |
jti |
JWT ID |
トークンの一意識別子 |
Signature(署名)#
署名は、トークンの改ざんを検出するために使用されます。HMAC-SHA256アルゴリズムの場合、以下のように計算されます。
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
署名の検証フローを以下に示します。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Client->>Server: JWTを含むリクエスト
Server->>Server: 1. HeaderとPayloadを分離
Server->>Server: 2. 秘密鍵で署名を再計算
Server->>Server: 3. 受信した署名と比較
alt 署名一致
Server-->>Client: リクエスト処理
else 署名不一致
Server-->>Client: 401 Unauthorized
endステートレス認証とステートフル認証の違い#
JWT認証の最大の利点は「ステートレス」であることです。従来のセッションベース認証との違いを理解しましょう。
セッションベース認証(ステートフル)#
従来のWebアプリケーションでは、サーバー側でセッション情報を保持するステートフルな認証が一般的でした。
sequenceDiagram
participant User as ユーザー
participant Server as サーバー
participant Session as セッションストア
User->>Server: ログイン(ID/パスワード)
Server->>Session: セッション情報を保存
Session-->>Server: セッションID
Server-->>User: Set-Cookie: JSESSIONID=xxx
User->>Server: リクエスト(Cookie: JSESSIONID=xxx)
Server->>Session: セッション情報を取得
Session-->>Server: ユーザー情報
Server-->>User: レスポンスJWT認証(ステートレス)#
JWT認証では、サーバーはセッション情報を保持せず、トークン自体に必要な情報が含まれます。
sequenceDiagram
participant User as ユーザー
participant Server as サーバー
User->>Server: ログイン(ID/パスワード)
Server->>Server: JWTを生成
Server-->>User: JWT(Authorization: Bearer xxx)
User->>Server: リクエスト(Authorization: Bearer xxx)
Server->>Server: JWTを検証・デコード
Server-->>User: レスポンス比較表#
| 観点 |
セッションベース認証 |
JWT認証 |
| サーバー状態 |
セッション情報を保持(ステートフル) |
状態を保持しない(ステートレス) |
| スケーラビリティ |
セッション共有が必要(Redis等) |
サーバー間でセッション共有不要 |
| 水平スケーリング |
複雑(スティッキーセッション等) |
容易 |
| トークンサイズ |
小さい(セッションIDのみ) |
大きい(ペイロードを含む) |
| 無効化 |
サーバー側で即座に無効化可能 |
有効期限まで有効(ブラックリスト方式で対応) |
| 適用シーン |
従来型Webアプリケーション |
REST API、マイクロサービス、SPA |
ステートレス認証のメリット#
ステートレス認証には以下のメリットがあります。
- スケーラビリティ: サーバー間でセッション情報を共有する必要がないため、ロードバランサー配下で複数サーバーを容易に運用できます
- マイクロサービス適合性: 各サービスが独立してトークンを検証できるため、サービス間通信が簡素化されます
- クロスドメイン対応: Cookieに依存しないため、異なるドメイン間での認証が容易です
- モバイルアプリ対応: HTTPヘッダーでトークンを送信するため、モバイルアプリとの連携が容易です
jjwtライブラリの導入#
Spring BootプロジェクトでJWTを扱うために、jjwt(Java JWT)ライブラリを導入します。jjwtはJWT、JWS、JWEの作成と検証をサポートする、Javaで最も広く使用されているJWTライブラリです。
Maven依存関係#
Mavenを使用している場合、pom.xmlに以下の依存関係を追加します。
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
|
<dependencies>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- jjwt API -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<!-- jjwt Implementation(runtime) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<!-- jjwt Jackson(runtime) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
|
Gradle依存関係#
Gradleを使用している場合、build.gradleに以下を追加します。
1
2
3
4
5
6
7
8
|
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
}
|
jjwtの依存関係構造#
jjwtは意図的にモジュール分割されています。
| モジュール |
スコープ |
説明 |
jjwt-api |
compile |
公開API。アプリケーションコードから直接使用 |
jjwt-impl |
runtime |
内部実装。直接参照すべきではない |
jjwt-jackson |
runtime |
JSONシリアライズ用Jackson実装 |
jjwt-implをruntimeスコープにすることで、内部実装への依存を避け、APIの安定性を保つことができます。
JWTトークン生成ロジックの実装#
JWTの生成と検証を担当するサービスクラスを実装します。
アプリケーション設定#
まず、application.ymlにJWT関連の設定を追加します。
1
2
3
4
|
jwt:
secret-key: your-256-bit-secret-key-for-hs256-algorithm-must-be-at-least-32-bytes
expiration-time: 3600000 # 1時間(ミリ秒)
refresh-expiration-time: 604800000 # 7日間(ミリ秒)
|
本番環境では、秘密鍵は環境変数やSecrets Managerから取得することを強く推奨します。
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
|
package com.example.security.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secretKey;
private long expirationTime;
private long refreshExpirationTime;
// Getters and Setters
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public long getExpirationTime() {
return expirationTime;
}
public void setExpirationTime(long expirationTime) {
this.expirationTime = expirationTime;
}
public long getRefreshExpirationTime() {
return refreshExpirationTime;
}
public void setRefreshExpirationTime(long refreshExpirationTime) {
this.refreshExpirationTime = refreshExpirationTime;
}
}
|
JWTサービスクラス#
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
|
package com.example.security.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import com.example.security.config.JwtProperties;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtService {
private final JwtProperties jwtProperties;
private final SecretKey secretKey;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
// 秘密鍵をSecretKeyオブジェクトに変換
this.secretKey = Keys.hmacShaKeyFor(
jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)
);
}
/**
* ユーザー情報からアクセストークンを生成する
*/
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
/**
* 追加クレームを含むアクセストークンを生成する
*/
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return buildToken(extraClaims, userDetails, jwtProperties.getExpirationTime());
}
/**
* リフレッシュトークンを生成する
*/
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails, jwtProperties.getRefreshExpirationTime());
}
/**
* JWTトークンを構築する
*/
private String buildToken(
Map<String, Object> extraClaims,
UserDetails userDetails,
long expiration
) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* トークンからユーザー名を抽出する
*/
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
/**
* トークンから有効期限を抽出する
*/
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
/**
* トークンから指定したクレームを抽出する
*/
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
/**
* トークンからすべてのクレームを抽出する
*/
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* トークンが有効かどうかを検証する
*/
public boolean isTokenValid(String token, UserDetails userDetails) {
try {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* トークンが期限切れかどうかを確認する
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
/**
* トークンの検証のみを行う(ユーザー詳細なし)
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
// トークン期限切れ
return false;
} catch (JwtException | IllegalArgumentException e) {
// 不正なトークン
return false;
}
}
}
|
トークン生成の流れ#
トークン生成の処理フローを以下に示します。
flowchart TD
A[ユーザー認証成功] --> B[UserDetailsを取得]
B --> C[追加クレームを準備]
C --> D[Jwts.builder を開始]
D --> E[クレームを設定]
E --> F[subject にユーザー名を設定]
F --> G[issuedAt に現在時刻を設定]
G --> H[expiration に有効期限を設定]
H --> I[signWith で署名]
I --> J[compact でトークン文字列に変換]
J --> K[JWTトークンを返却]JWTトークン検証ロジックの実装#
JWTを検証するフィルターを実装し、Spring Securityのフィルターチェーンに組み込みます。
JWT認証フィルター#
すべてのリクエストで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
|
package com.example.security.filter;
import com.example.security.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
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ヘッダーを取得
final String authHeader = request.getHeader(AUTHORIZATION_HEADER);
// Bearerトークンが存在しない場合はスキップ
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
// "Bearer "プレフィックスを除去してトークンを取得
final String jwt = authHeader.substring(BEARER_PREFIX.length());
try {
// トークンからユーザー名を抽出
final String username = jwtService.extractUsername(jwt);
// ユーザー名が存在し、まだ認証されていない場合
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// UserDetailsServiceからユーザー情報を取得
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// トークンが有効な場合、認証情報を設定
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) {
// JWT解析エラーの場合は認証なしで続行
logger.debug("JWT認証に失敗しました: " + e.getMessage());
}
filterChain.doFilter(request, response);
}
}
|
認証フィルターの処理フロー#
JWT認証フィルターの処理フローを以下に示します。
flowchart TD
A[リクエスト受信] --> B{Authorizationヘッダーあり?}
B -->|なし| C[フィルター通過]
B -->|あり| D{Bearer で始まる?}
D -->|いいえ| C
D -->|はい| E[JWTを抽出]
E --> F[ユーザー名を抽出]
F --> G{抽出成功?}
G -->|失敗| C
G -->|成功| H{既に認証済み?}
H -->|はい| C
H -->|いいえ| I[UserDetailsを取得]
I --> J{トークン有効?}
J -->|無効| C
J -->|有効| K[認証情報をSecurityContextに設定]
K --> C
C --> L[次のフィルターへ]Security設定の構成#
JWT認証フィルターをSpring Securityに組み込むための設定を行います。
SecurityFilterChainの設定#
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
|
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.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
public SecurityConfig(
JwtAuthenticationFilter jwtAuthenticationFilter,
UserDetailsService userDetailsService
) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
@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)
)
// 認証プロバイダーの設定
.authenticationProvider(authenticationProvider())
// JWTフィルターを追加
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config
) throws Exception {
return config.getAuthenticationManager();
}
}
|
設定のポイント#
| 設定項目 |
説明 |
csrf().disable() |
REST APIではCSRFトークンが不要なため無効化 |
SessionCreationPolicy.STATELESS |
サーバー側でセッションを作成しない |
addFilterBefore() |
JWTフィルターをUsernamePasswordAuthenticationFilterの前に追加 |
authenticationProvider() |
ユーザー認証の実行方法を定義 |
認証コントローラーの実装#
ログインとトークン発行を行うコントローラーを実装します。
認証リクエスト/レスポンスDTO#
1
2
3
4
5
6
|
package com.example.security.dto;
public record AuthenticationRequest(
String username,
String password
) {}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
package com.example.security.dto;
public record AuthenticationResponse(
String accessToken,
String refreshToken,
String tokenType,
long expiresIn
) {
public AuthenticationResponse(String accessToken, String refreshToken, long expiresIn) {
this(accessToken, refreshToken, "Bearer", expiresIn);
}
}
|
認証コントローラー#
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
|
package com.example.security.controller;
import com.example.security.config.JwtProperties;
import com.example.security.dto.AuthenticationRequest;
import com.example.security.dto.AuthenticationResponse;
import com.example.security.service.JwtService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
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;
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final JwtProperties jwtProperties;
public AuthenticationController(
AuthenticationManager authenticationManager,
JwtService jwtService,
JwtProperties jwtProperties
) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
this.jwtProperties = jwtProperties;
}
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> authenticate(
@RequestBody AuthenticationRequest request
) {
// 認証を実行
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
// 認証成功後、JWTを生成
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return ResponseEntity.ok(new AuthenticationResponse(
accessToken,
refreshToken,
jwtProperties.getExpirationTime() / 1000 // 秒単位に変換
));
}
@PostMapping("/refresh")
public ResponseEntity<AuthenticationResponse> refreshToken(
@RequestBody RefreshTokenRequest request
) {
// リフレッシュトークンからユーザー名を抽出
String username = jwtService.extractUsername(request.refreshToken());
// トークンを検証(実際のアプリではUserDetailsServiceからユーザーを取得)
if (jwtService.validateToken(request.refreshToken())) {
// 新しいアクセストークンを生成
// 注: 実際のアプリではUserDetailsServiceを使用
String newAccessToken = jwtService.generateToken(
org.springframework.security.core.userdetails.User.builder()
.username(username)
.password("") // パスワードは不要
.authorities("ROLE_USER")
.build()
);
return ResponseEntity.ok(new AuthenticationResponse(
newAccessToken,
request.refreshToken(),
jwtProperties.getExpirationTime() / 1000
));
}
return ResponseEntity.status(401).build();
}
}
record RefreshTokenRequest(String refreshToken) {}
|
認証フロー全体図#
sequenceDiagram
participant Client as クライアント
participant AuthCtrl as 認証コントローラー
participant AuthMgr as AuthenticationManager
participant JwtSvc as JwtService
participant UDS as UserDetailsService
Client->>AuthCtrl: POST /api/auth/login
AuthCtrl->>AuthMgr: authenticate(username, password)
AuthMgr->>UDS: loadUserByUsername(username)
UDS-->>AuthMgr: UserDetails
AuthMgr->>AuthMgr: パスワード検証
AuthMgr-->>AuthCtrl: Authentication
AuthCtrl->>JwtSvc: generateToken(userDetails)
JwtSvc-->>AuthCtrl: accessToken
AuthCtrl->>JwtSvc: generateRefreshToken(userDetails)
JwtSvc-->>AuthCtrl: refreshToken
AuthCtrl-->>Client: AuthenticationResponse動作確認#
実装したJWT認証の動作を確認します。
ログインリクエスト#
1
2
3
|
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "user", "password": "password"}'
|
期待されるレスポンス:
1
2
3
4
5
6
|
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNzA1MDAwMDAwLCJleHAiOjE3MDUwMDM2MDB9.xxxxx",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNzA1MDAwMDAwLCJleHAiOjE3MDU2MDQ4MDB9.yyyyy",
"tokenType": "Bearer",
"expiresIn": 3600
}
|
保護されたリソースへのアクセス#
1
2
|
curl -X GET http://localhost:8080/api/users/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIi..."
|
トークンなしでのアクセス#
1
|
curl -X GET http://localhost:8080/api/users/me
|
期待されるレスポンス: 401 Unauthorized
セキュリティ上の考慮事項#
JWT認証を本番環境で使用する際は、以下のセキュリティ対策を必ず実施してください。
秘密鍵の管理#
| 対策 |
説明 |
| 十分な長さ |
HS256では256ビット(32バイト)以上の秘密鍵を使用 |
| 環境変数 |
秘密鍵はコードにハードコードせず、環境変数から取得 |
| ローテーション |
定期的に秘密鍵をローテーション |
| Secrets Manager |
AWS Secrets ManagerやHashiCorp Vaultの使用を推奨 |
トークンの有効期限#
1
2
3
4
|
# 推奨設定
jwt:
expiration-time: 900000 # アクセストークン: 15分
refresh-expiration-time: 604800000 # リフレッシュトークン: 7日
|
アクセストークンは短い有効期限(15分〜1時間)に設定し、リフレッシュトークンで更新する方式を推奨します。
その他の対策#
| 対策 |
説明 |
| HTTPS |
本番環境では必ずHTTPSを使用 |
| ブラックリスト |
ログアウト時にトークンを無効化するブラックリスト機能 |
| 監査ログ |
認証イベントのログ記録 |
| レート制限 |
ブルートフォース攻撃対策としてレート制限を実装 |
まとめ#
本記事では、Spring Security 6.4でJWT認証を実装する方法を解説しました。主なポイントは以下のとおりです。
- JWTは Header、Payload、Signature の3部構成で、自己完結型のトークン
- ステートレス認証により、スケーラビリティとマイクロサービス適合性が向上
- jjwt 0.13.0を使用したトークンの生成と検証
OncePerRequestFilterを継承したJWT認証フィルターの実装
SessionCreationPolicy.STATELESSによるステートレス設定
次のステップとして、リフレッシュトークンのローテーション、トークンブラックリスト機能、OAuth2との連携などを検討することで、より堅牢な認証システムを構築できます。
参考リンク#