Spring SecurityとJWTでREST APIを保護する

1. 概要

REST APIは通常セッションを使いません。各リクエストは認証情報を自分で持ち歩く必要があり、JWT(JSON Web Token)がこのための最も一般的な方法です。

この記事では、Spring Security 6とSpring Boot 3を使って、注文管理APIを保護する方法をご説明します。ユーザーがログインするときにJWTを発行し、すべてのリクエストでトークンを認証し、ロールに基づいて権限を付与します。最後の部分は、主な動作の自動テストです。

JWTフィルターを手動で作成することはありません。その代わり、この記事ではSpring Securityに組み込まれているOAuth2 Resource Serverを使用します。この方法はコード量が少なく、より安全です。

2. Maven依存性

pom.xmlには以下の依存性が必要です:

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

starter oauth2-resource-serverは、Nimbus JOSE + JWTライブラリをもたらします。このライブラリはトークンの署名と署名検証を処理します。jjwtや他のJWTライブラリを追加する必要はありません。

3. 秘密キーの設定

シンプルにするために、対称アルゴリズムHS256を使ってトークンに署名します。同じ秘密キーが署名と署名検証の両方に使用されます。

jwt.secret=${JWT_SECRET:doi-chuoi-nay-thanh-mot-khoa-bi-mat-dai-it-nhat-32-byte}
jwt.expiration-minutes=15

式${JWT_SECRET:...}は環境変数JWT_SECRETからキーを読み込みます。この変数が存在しない場合、Springはコロンの後のデフォルト値を使用します。デフォルト値はdev環境のみを対象としています。

注意: HS256には最小256ビット、つまり32バイト以上のキー長が必要です。キーがこれより短い場合、Nimbusはトークンに署名する際にKeyLengthExceptionをスローします。

4. Spring Securityの設定

4.1. JwtEncoder、JwtDecoder、およびユーザー

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Bean
    SecretKey jwtSecretKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        return NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var customer = User.withUsername("alice")
                .password(encoder.encode("alice123")).roles("CUSTOMER").build();
        var admin = User.withUsername("bob")
                .password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
        return new InMemoryUserDetailsManager(customer, admin);
    }

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(uds);
        provider.setPasswordEncoder(encoder);
        return new ProviderManager(provider);
    }
}

各beanの役割:

  • SecretKeyは秘密文字列をHMAC-SHA256キーにラップします。
  • JwtEncoderは、このキーを使ってユーザーがログインするときにトークンに署名します。
  • JwtDecoderは、各リクエストでトークンの署名と有効期限(exp)を確認します。Resource Serverは自動的にこのbeanを使用します。
  • InMemoryUserDetailsManagerは2つのサンプルユーザーを作成します。実装では、これをデータベースから読み込むimplementationに置き換えます。
  • AuthenticationManagerはログインエンドポイントでusernameとpasswordをチェックします。

4.2. SecurityFilterChain

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
    return http.build();
}

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

このbeanは同じSecurityConfigクラスの中にあります。各部分は以下のことを行います:

  • csrf.disable():APIはCookie認証を使用しないため、CSRF攻撃に対して脆弱ではありません。
  • STATELESS:SpringはHttpSessionを作成しません。各リクエストはトークンから再び認証されます。
  • authorizeHttpRequests:ログインエンドポイントは公開されています。/api/admin/**はロールADMINのみを対象としています。他のすべてのエンドポイントはログインを要求します。
  • oauth2ResourceServer(...jwt...):Authorization: Bearer <token>ヘッダーを読み取ってトークンをJwtDecoderで解析するフィルターを有効にします。

デフォルトでは、Springはscopeクレームから権限を読み込み、SCOPE_というプレフィックスを追加します。hasRole("ADMIN")を使いたいので、JwtAuthenticationConverterを再構成してrolesクレームを読み込みます。プレフィックスは削除されます。これはトークン内の値が既にROLE_ADMINの形式だからです。

5. ログイン時のトークン発行

5.1. TokenService

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long expirationMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.expiration-minutes}") long expirationMinutes) {
        this.encoder = encoder;
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("nonla-shop")
                .issuedAt(now)
                .expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                .claim("roles", roles)
                .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long getExpirationSeconds() {
        return expirationMinutes * 60;
    }
}

トークンには次のクレームが含まれています:発行者(iss)、発行時刻(iat)、有効期限(exp)、ユーザー名(sub)、ロールのリスト。

行JwsHeader.with(MacAlgorithm.HS256)は必須です。これがない場合、NimbusJwtEncoderはデフォルトでRS256を選択します。そうなると、適切なキーが見つからず、Failed to select a JWK signing keyエラーがスローされます。

5.2. AuthController

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;

    public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }

    public record LoginRequest(String username, String password) {}
    public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(), request.password()));
        String token = tokenService.generateToken(auth);
        return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
    }
}

authenticate()はDaoAuthenticationProviderを通してusernameとpasswordを確認します。間違っている場合、メソッドはBadCredentialsExceptionをスローします。Spring Securityはこれを HTTP 401 に翻訳します。正しい場合、コントローラーはトークンを作成してクライアントに返します。

curlでテストしてみます:

curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}

6. 保護されたエンドポイント

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

    public record Order(long id, String customer, BigDecimal total) {}

    private final List<Order> orders = List.of(
            new Order(1, "alice", new BigDecimal("250000")),
            new Order(2, "bob", new BigDecimal("990000")));

    @GetMapping("/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orders.stream()
                .filter(o -> o.customer().equals(jwt.getSubject()))
                .toList();
    }

    @GetMapping("/admin/orders")
    public List<Order> allOrders() {
        return orders;
    }
}

@AuthenticationPrincipal Jwt jwtは認証済みトークンをコントローラーにインジェクトします。jwt.getSubject()でユーザー名を取得します。他のクレームを読み込むには、jwt.getClaim("roles")を使用します。

3つの異なるケースで試してみます:

# トークンなし
curl -i localhost:8080/api/orders

# aliceが有効なトークンを持っている
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"

# aliceがadminエンドポイントを呼び出す
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer

[{"id":1,"customer":"alice","total":250000}]

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...

結果は2つの異なるエラーコードを示しています。401は未認証を意味します。トークンが不足、署名が間違っている、またはトークンの期限が切れているのです。403は認証されているが権限が不足していることを意味します。

7. テストの記述

spring-security-testはポストプロセッサーjwt()を提供します。実際のトークンに署名する必要なく、認証済みのJWTをシミュレートします。これを実際のログインを伴うエンドツーエンドテストと組み合わせます。

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void whenNoToken_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"username":"alice","password":"alice123"}
                                """))
                .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(body).get("accessToken").asText();

        MvcResult result = mockMvc.perform(get("/api/orders")
                        .header("Authorization", "Bearer " + token))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString())
                .contains("\"customer\":\"alice\"")
                .doesNotContain("\"customer\":\"bob\"");
    }

    @Test
    void whenWrongPassword_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"username\":\"alice\",\"password\":\"sai\"}"))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/admin/orders")
                        .with(jwt().jwt(j -> j.subject("alice"))
                                .authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(403);
    }
}

必要な静的インポートはMockMvcRequestBuilders.*、SecurityMockMvcRequestPostProcessors.jwt、AssertJのAssertions.assertThatです。

2番目のテストは全体のフローをチェックします。ログインエンドポイントを呼び出し、トークンを取得し、そのトークンを使ってAPIを呼び出します。このテストはエンコーダーとデコーダーの設定ミス、例えばJwsHeaderを忘れたり、キーが短すぎたりするエラーを検出します。

最後のテストはjwt()を使って権限管理の部分を個別にチェックします。.authorities(...)はコンバーターをオーバーライドするため、authority名ROLE_CUSTOMERを正しく渡す必要があることに注意してください。

8. よくあるエラーと使用すべきでない場合

よくあるエラー:

  • キーが短すぎる: 32バイト未満のキーはHS256でKeyLengthExceptionを引き起こします。
  • HS256ヘッダーを忘れる: JwtEncoderはFailed to select a JWK signing keyをレポートします。
  • authorityプレフィックスが間違っている: デフォルトコンバーターを使用すると、権限はSCOPE_...の形式になります。その場合、hasRole("ADMIN")は常に403を返します。クレームとプレフィックスが一致しているか確認してください。
  • 秘密をリポジトリにハードコーディングする: 常に環境変数またはシークレットマネージャーから秘密を読み込みます。
  • ペイロードに機密データを含める: JWTペイロードはBase64URLでエンコードされているだけで、暗号化されていません。トークンを持っている人は誰でもコンテンツを読むことができます。パスワード、カード番号、個人情報などをトークンに含めないでください。

注意: ブラックリストなどの補足メカニズムを追加しない限り、JWTの有効期限前に失効させることはできません。アクセストークンは短命(5~15分)に保ちます。長いセッションが必要な場合は、追加のリフレッシュトークンをサーバーに保存して失効させることができます。

このアプローチを使用すべきでない場合:

  • サーバー側でレンダリングするWebアプリケーション(Thymeleafなど):セッションとCookieの組み合わせがより単純で安全です。
  • ログアウトまたはアカウントロックを即座に有効にする必要がある場合: セッションまたは内容検査(introspection)を伴うopaque tokenがより適切です。
  • マルチサービスシステム: すべてのサービスで1つのHMACキーを共有することは非常にリスキーです。この場合、Spring Authorization ServerやKeyCloak などの専用認可サーバーを使用する必要があります。認可サーバーはRSAまたはECでトークンに署名し、各サービスはspring.security.oauth2.resourceserver.jwt.issuer-uriを設定してJWKS経由で公開キーを取得するだけです。

9. 結論

この記事は、JWTで保護されたステートレスREST APIを構築しました。以下の手順が含まれています:

  • HS256キーでNimbusJwtEncoderとNimbusJwtDecoderを使用します。
  • エンドポイント/api/auth/loginでAuthenticationManagerを通じてトークンを発行します。
  • OAuth2 Resource ServerにBearerヘッダーの認証を自動的に行わせます。
  • クレームrolesをauthorityにマップしてhasRoleを使用できるようにします。
  • MockMvcとAssertJで401、403のケースと成功フローをテストします。

このベースから、InMemoryUserDetailsManagerをデータベースに保存されたユーザーに置き換えたり、リフレッシュトークンを追加したり、システムが複数のサービスに拡張される際に非対称キーに切り替えたりできます。

spring-bootspring-securityjwtrest-apijava