404 Not Found

404 Not Found


nginx

OAuth2とJWT認証

JWTはマイクロサービスのパスポートです。ステートレス, 自己完結, クロスサービス認証を可能にし, セッション共有の課題に終止符を打ちます。

1. 学ぶ内容


2. アーキテクトの実話

(1) ペインポイント:マイクロサービスでセッションが切れる

AliceがOrderFlowを単一インスタンスから3インスタンスにスケールアウトした後, セッション問題が発生しました。ユーザーがインスタンスAでログインし, リクエストがインスタンスBに負荷分散されると, セッションが存在せずログアウトされてしまいます。BobはRedisでセッションを共有しようとしましたが, 新たな依存関係と複雑さが追加されました。Charlieはサードパーティログイン (Google/GitHub)のサポートを要求しましたが, 従来のセッションベースのソリューションでは全く対応できません。

(2) JWTによる解決策

JWTトークンにはすべての認証情報が含まれるため, サーバーはセッションを保存する必要がありません。

JAVA
// ログイン時に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認可フロー

100%
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つの部分で構成され, .で区切られます。

TEXT
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の依存関係

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

出力:

TEXT
// 実行成功

(2) ▶ サンプル:SecurityFilterChain JWT設定

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/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;
    }
}

出力:

TEXT
// 実行成功

5. JWTの発行と検証

(1) RSA鍵ペアの設定

(1) ▶ サンプル:RSA鍵ペアの生成

JAVA
@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());
    }
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:ログイン時のJWT発行

JAVA
@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) {}
}

出力:

TEXT
// 実行成功

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

JAVA
@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;
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:コントローラでJWT情報を使用

JAVA
@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);
    }
}

出力:

TEXT
// 実行成功
方法 ユーザー情報の取得 適用シナリオ
@AuthenticationPrincipal Jwt jwt 完全なJWT Claims カスタムクレームが必要な場合
@AuthenticationPrincipal UserDetails user UserDetails 標準的なユーザー情報が必要な場合
Principal principal getName() ユーザー名のみ

7. 総合サンプル:OrderFlow JWT認証の完全な実装

JAVA
// 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) {}
}
💻 テスト手順:

BASH
# 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

❓ よくある質問

Q JWTとセッションの決定的な違いは何ですか?
A セッションはステートフル (サーバーに保存), JWTはステートレス (自己完結トークン)です。JWTはマイクロサービスや水平スケーリングに適しています。セッションはモノリスアプリケーションに適しています。JWTは自力的に取り消せません (ブラックリストを実装しない限り)。セッションは即座に破棄できます。
Q JWTには対称暗号と非対称暗号のどちらを使うべきですか?
A 本番環境では非対称暗号 (RSA/ECDSA)を推奨します。Resource Serverは公開鍵の検証のみで済み, 秘密鍵は認可サーバーのみが保持します。対称暗号 (HMAC)は鍵漏洩のリスクが高いです。
Q JWTが期限切れになったらどうすればよいですか?
A 一般的なアプローチ:1)リフレッシュトークンメカニズム。短期のアクセストークンと長期のリフレッシュトークンを使用;2)クライアントが期限切れを検出して自動的にログインページにリダイレクト;3)合理的な有効期限 (1〜4時間)を設定。
Q 発行済みのJWTを取り消すにはどうすればよいですか?
A JWTは自力的に取り消せません。一般的なアプローチ:1)短い有効期限 + リフレッシュトークン;2)トークンブラックリスト (Redisに保存);3)署名鍵を変更してすべての古いトークンを無効化。
Q OAuth 2.0 Resource Serverと認可サーバーの関係は何ですか?
A 認可サーバーはトークンの発行を担当し, Resource Serverはトークンの検証とリソースの保護を担当します。このレッスンでは, OrderFlowがResource Serverであり, 同時に認可サーバーの簡易実装も兼ねています。本番環境ではKeycloakなどの専門ソリューションの使用を推奨します。
Q JWTクレームにどれくらいの情報を含めるべきですか?
A 最小限の原則に従ってください。ユーザー識別子 (sub)とロールのみを含め, 機密情報 (パスワードや電話番号など)は含めないでください。JWTはBase64エンコードされているため誰でもデコードでき, 完全性は署名のみで保証されます。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):OrderFlowにJWTログインとAPI認証を実装し, httpBasicを置き換えてください。curlでログインプロセスをテストし, トークンを取得 → トークンを使って保護されたAPIにアクセスしてください。

  2. 応用問題 (難易度 ⭐⭐):リフレッシュトークンメカニズムを実装してください。アクセストークンの有効期限は15分, リフレッシュトークンの有効期限は7日とし, リフレッシュトークンで新しいアクセストークンを取得できるようにしてください。

  3. チャレンジ問題 (難易度 ⭐⭐⭐):Keycloakを外部認可サーバーとして統合し, OrderFlowはリソースサーバーとしてのみKeycloakが発行したJWTを検証してください。サービス間の信頼関係の確立方法について考察してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%