OAuth2とJWT認証
JWTはマイクロサービスのパスポートです。ステートレス, 自己完結, クロスサービス認証を可能にし, セッション共有の課題に終止符を打ちます。
1. 学ぶ内容
- OAuth 2.0認可コードフローとパスワードグラントの比較
- JWTトークン構造:Header / Payload / Signature
spring-boot-starter-oauth2-resource-serverの設定とJWTデコード- カスタムJWT Claimsのユーザーロールへのマッピング
- AliceがOrderFlowにJWT発行とAPIトークン認証を実装
2. アーキテクトの実話
(1) ペインポイント:マイクロサービスでセッションが切れる
AliceがOrderFlowを単一インスタンスから3インスタンスにスケールアウトした後, セッション問題が発生しました。ユーザーがインスタンスAでログインし, リクエストがインスタンスBに負荷分散されると, セッションが存在せずログアウトされてしまいます。BobはRedisでセッションを共有しようとしましたが, 新たな依存関係と複雑さが追加されました。Charlieはサードパーティログイン (Google/GitHub)のサポートを要求しましたが, 従来のセッションベースのソリューションでは全く対応できません。
(2) JWTによる解決策
JWTトークンにはすべての認証情報が含まれるため, サーバーはセッションを保存する必要がありません。
// ログイン時にJWTを発行
String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
// 各リクエストでJWTを検証 (ステートレス, セッション不要)
// Spring SecurityがJWT claimsからロールを自動抽出
(3) 成果
AliceがセッションをJWTに置き換えた後, 3つのインスタンスはセッションを共有する必要がなくなり, ゼロコストの水平スケーリングが可能になりました。トークンはクライアント側に保存され, サーバーはステートレスになり, サードパーティのOAuth 2.0ログインもサポートされるようになりました。
3. OAuth 2.0とJWTの概念
(1) OAuth 2.0認可フロー
sequenceDiagram
participant U as ユーザーエージェント<br/>(ブラウザ)
participant C as クライアント<br/>(OrderFlow SPA)
participant AS as 認可<br/>サーバー
participant RS as リソース<br/>サーバー (API)
U->>C: "GitHubでログイン"をクリック
C->>AS: /authorizeにリダイレクト
AS->>U: ログイン + 同意ページ
U->>AS: 承認
AS->>C: 認可コード付きでリダイレクト
C->>AS: POST /token (認可コード + client_secret)
AS->>C: アクセストークン (JWT)
C->>RS: GET /api/orders (Bearer トークン)
RS->>RS: JWT署名を検証
RS->>C: 200 OK + データ
| 認可モデル | 使用場面 | クライアント型 |
|---|---|---|
| 認可コードフロー | Webアプリケーション / SPA | バックエンドサービス + フロントエンド |
| クライアントクレデンシャル | サービス間呼び出し | マシン・ツー・マシン |
| リソースオーナーパスワードクレデンシャル (ROPC) | 非推奨, テストのみ | ファーストパーティアプリ |
| インプリシットモード | 非推奨 | レガシーSPA |
(2) JWTトークン構造
JWTは3つの部分で構成され, .で区切られます。
Header.Payload.Signature
eyJhbGciOiJS256.eyJzdWIiOiJib2IiLCJyb2xlIjoiQ1VTVU9NRVIifQ.signature
| セクション | 内容 | 例 |
|---|---|---|
| Header | アルゴリズム + トークンタイプ | {"alg":"RS256","typ":"JWT"} |
| Payload | クレーム | {"sub":"bob","role":"CUSTOMER","exp":1705312000} |
| Signature | Header+Payloadの署名 | RS256署名の完全性検証 |
| 標準クレーム | 意味 |
|---|---|
sub |
サブジェクト (ユーザーID) |
iss |
発行者 |
aud |
オーディエンス |
exp |
有効期限 |
iat |
発行日時 |
scope |
権限スコープ |
4. OAuth 2.0 Resource Serverの設定
(1) 依存関係と設定
(1) ▶ サンプル:pom.xmlの依存関係
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
出力:
// 実行成功
(2) ▶ サンプル:SecurityFilterChain JWT設定
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/login").permitAll()
.requestMatchers("/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/orders/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
String role = jwt.getClaimAsString("role");
if (role == null) return List.of();
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
});
return converter;
}
}
出力:
// 実行成功
5. JWTの発行と検証
(1) RSA鍵ペアの設定
(1) ▶ サンプル:RSA鍵ペアの生成
@Configuration
public class JwtConfig {
@Bean
public KeyPair keyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
@Bean
public JwtEncoder jwtEncoder(KeyPair keyPair) {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSource<SecurityContext> jwkSource =
new ImmutableJWKSet<>(new JWKSet(rsaKey));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JwtDecoder jwtDecoder(KeyPair keyPair) {
return JwtDecoders.withPublicKey((RSAPublicKey) keyPair.getPublic());
}
}
出力:
// 実行成功
(2) ▶ サンプル:ログイン時のJWT発行
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final JwtEncoder jwtEncoder;
private final AuthenticationManager authenticationManager;
public AuthController(JwtEncoder jwtEncoder,
AuthenticationManager authenticationManager) {
this.jwtEncoder = jwtEncoder;
this.authenticationManager = authenticationManager;
}
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
Instant now = Instant.now();
Instant expiry = now.plus(1, ChronoUnit.HOURS);
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("orderflow-service")
.subject(auth.getName())
.issuedAt(now)
.expiresAt(expiry)
.claim("role", auth.getAuthorities().stream()
.filter(a -> a.getAuthority().startsWith("ROLE_"))
.map(a -> a.getAuthority().replace("ROLE_", ""))
.findFirst().orElse("CUSTOMER"))
.build();
String token = jwtEncoder
.encode(JwtEncoderParameters.from(claims))
.getTokenValue();
return Map.of("accessToken", token);
}
public record LoginRequest(String username, String password) {}
}
出力:
// 実行成功
6. カスタムJWT Claimsとロールマッピング
(1) ロールマッピング戦略
| クレームフィールド | 値 | マッピング先のAuthority |
|---|---|---|
role: "ADMIN" |
単一ロール文字列 | ROLE_ADMIN |
roles: ["ADMIN","CUSTOMER"] |
ロール配列 | ROLE_ADMIN, ROLE_CUSTOMER |
scope: "read write" |
OAuth2スコープ | SCOPE_read, SCOPE_write |
(1) ▶ サンプル:カスタムJwtAuthenticationConverter
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
// "role" (単一)と"roles" (配列)の両方のクレームをサポート
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
String role = jwt.getClaimAsString("role");
if (role != null) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(
new SimpleGrantedAuthority("ROLE_" + r)));
}
return authorities;
});
return converter;
}
出力:
// 実行成功
(2) ▶ サンプル:コントローラでJWT情報を使用
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@GetMapping("/my")
public List<OrderResponse> getMyOrders(
@AuthenticationPrincipal Jwt jwt) {
String username = jwt.getClaimAsString("sub");
String role = jwt.getClaimAsString("role");
return orderService.getOrdersByUsername(username);
}
}
出力:
// 実行成功
| 方法 | ユーザー情報の取得 | 適用シナリオ |
|---|---|---|
@AuthenticationPrincipal Jwt jwt |
完全なJWT Claims | カスタムクレームが必要な場合 |
@AuthenticationPrincipal UserDetails user |
UserDetails | 標準的なユーザー情報が必要な場合 |
Principal principal |
getName() | ユーザー名のみ |
7. 総合サンプル:OrderFlow JWT認証の完全な実装
// SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/orders/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt ->
Optional.ofNullable(jwt.getClaimAsString("role"))
.map(role -> List.of(new SimpleGrantedAuthority("ROLE_" + role)))
.orElse(List.of()));
return converter;
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
// AuthController.java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final JwtEncoder jwtEncoder;
private final AuthenticationManager authManager;
public AuthController(JwtEncoder jwtEncoder, AuthenticationManager authManager) {
this.jwtEncoder = jwtEncoder;
this.authManager = authManager;
}
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("orderflow").subject(auth.getName())
.issuedAt(now).expiresAt(now.plus(1, ChronoUnit.HOURS))
.claim("role", auth.getAuthorities().stream()
.map(a -> a.getAuthority().replace("ROLE_", ""))
.findFirst().orElse("CUSTOMER"))
.build();
String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return Map.of("accessToken", token);
}
public record LoginRequest(String username, String password) {}
}
# 1. ログインしてJWTを取得
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"bob","password":"pass123"}' | jq -r '.accessToken')
# 2. JWTで保護されたAPIにアクセス
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/orders/my
# 3. 管理者JWTで管理エンドポイントにアクセス
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"admin123"}' | jq -r '.accessToken')
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:8080/api/v1/admin/dashboard
❓ よくある質問
📖 まとめ
- JWTの3つの構成要素:Header (アルゴリズム), Payload (クレーム), Signature (署名)
- OAuth2 Resource ServerはJWT署名を検証し, クレームからロールを抽出して認可を行う
- RSA非対称暗号:秘密鍵で発行, 公開鍵で検証。より安全な鍵管理
JwtAuthenticationConverterでClaimsからAuthorityへのカスタムマッピングロジックを実装- ログインAPIがJWTを発行。その他のAPIはBearer Token認証を使用
- JWTはステートレスでマイクロサービスに適しているが, 自力的に取り消せないという欠点がある
📝 練習問題
-
基本問題 (難易度 ⭐):OrderFlowにJWTログインとAPI認証を実装し, httpBasicを置き換えてください。curlでログインプロセスをテストし, トークンを取得 → トークンを使って保護されたAPIにアクセスしてください。
-
応用問題 (難易度 ⭐⭐):リフレッシュトークンメカニズムを実装してください。アクセストークンの有効期限は15分, リフレッシュトークンの有効期限は7日とし, リフレッシュトークンで新しいアクセストークンを取得できるようにしてください。
-
チャレンジ問題 (難易度 ⭐⭐⭐):Keycloakを外部認可サーバーとして統合し, OrderFlowはリソースサーバーとしてのみKeycloakが発行したJWTを検証してください。サービス間の信頼関係の確立方法について考察してください。



