マイクロサービスアーキテクチャやSPA(Single Page Application)バックエンドにおいて、OAuth2で保護されたREST APIを構築することは一般的なパターンです。本記事では、Spring Security 6.4のOAuth2リソースサーバー機能を使用して、JWTベースのAPI保護を実装する方法を解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Security |
6.4.x |
| ビルドツール |
Maven または Gradle |
| IDE |
VS Code または IntelliJ IDEA |
事前に以下の知識があると理解がスムーズです。
- Spring Securityの基本概念(認証・認可の違い)
- OAuth2とJWTの基礎知識
- REST APIの実装経験
OAuth2リソースサーバーとは#
OAuth2において、リソースサーバーはクライアントがアクセスしたいリソース(API)をホストするサーバーです。リソースサーバーは、認可サーバーから発行されたアクセストークンを検証し、適切な認可を行います。
OAuth2アーキテクチャにおける役割#
OAuth2エコシステムは、以下のコンポーネントで構成されます。
flowchart LR
subgraph "OAuth2 アーキテクチャ"
A[リソースオーナー<br/>ユーザー] --> B[クライアント<br/>SPA/モバイルアプリ]
B --> C[認可サーバー<br/>Keycloak/Auth0]
C --> B
B --> D[リソースサーバー<br/>REST API]
D --> C
end
B -->|"1. 認可リクエスト"| C
C -->|"2. アクセストークン発行"| B
B -->|"3. APIリクエスト + Bearer Token"| D
D -->|"4. トークン検証"| C
D -->|"5. リソース返却"| B
| コンポーネント |
役割 |
| リソースオーナー |
リソースの所有者(通常はエンドユーザー) |
| クライアント |
リソースにアクセスするアプリケーション |
| 認可サーバー |
トークンを発行し、認証を管理するサーバー |
| リソースサーバー |
保護されたリソース(API)を提供するサーバー |
JWTベースのリソースサーバーの特徴#
JWTを使用したリソースサーバーには以下の特徴があります。
| 特徴 |
説明 |
| ステートレス検証 |
トークン自体に情報が含まれるため、認可サーバーへの問い合わせが不要 |
| 署名検証 |
公開鍵でトークンの真正性を確認 |
| クレーム活用 |
トークン内のスコープや権限情報を直接利用 |
| スケーラビリティ |
セッション管理が不要なため、水平スケーリングが容易 |
プロジェクトのセットアップ#
依存関係の追加#
OAuth2リソースサーバー機能を使用するには、以下の依存関係を追加します。
Mavenの場合(pom.xml):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
<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>
</dependencies>
|
Gradleの場合(build.gradle):
1
2
3
4
5
|
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}
|
spring-boot-starter-oauth2-resource-serverは内部でspring-security-oauth2-resource-serverとspring-security-oauth2-jose(JWT処理用)を含んでいます。
プロジェクト構成#
本記事で作成するプロジェクトの構成は以下のとおりです。
src/main/java/com/example/resourceserver/
├── ResourceServerApplication.java
├── config/
│ └── SecurityConfig.java
├── controller/
│ └── MessageController.java
└── dto/
└── MessageResponse.java
基本的なリソースサーバー設定#
最小構成での設定#
最もシンプルな構成では、認可サーバーのissuer-uriを指定するだけでリソースサーバーが機能します。
application.yml:
1
2
3
4
5
6
|
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-auth-server.example.com/realms/your-realm
|
この設定により、Spring Securityは以下の処理を自動で行います。
- 起動時に認可サーバーの
.well-known/openid-configurationエンドポイントを照会
- JWKSet URIを取得し、公開鍵をダウンロード
- 受信したJWTの署名を検証
iss、exp、nbfクレームを検証
SecurityFilterChainの明示的な設定#
より細かい制御が必要な場合は、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
|
package com.example.resourceserver.config;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF保護はステートレスAPIでは無効化
.csrf(csrf -> csrf.disable())
// セッション管理をステートレスに設定
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 認可ルールの設定
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
// OAuth2リソースサーバーの有効化
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
);
return http.build();
}
}
|
この設定のポイントは以下のとおりです。
| 設定項目 |
説明 |
csrf.disable() |
REST APIではCSRF保護は不要(トークンベース認証のため) |
SessionCreationPolicy.STATELESS |
セッションを作成しないステートレス設定 |
oauth2ResourceServer().jwt() |
JWTベースのリソースサーバーとして機能 |
外部認可サーバーとの連携#
Keycloakとの連携#
Keycloakは、オープンソースのIdentity and Access Management(IAM)ソリューションです。
application.yml(Keycloak用):
1
2
3
4
5
6
7
8
|
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/my-realm
# JWK Set URIを明示的に指定する場合
jwk-set-uri: http://localhost:8080/realms/my-realm/protocol/openid-connect/certs
|
Keycloakから発行されるJWTのペイロード例は以下のとおりです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
"exp": 1736672400,
"iat": 1736668800,
"jti": "abc123",
"iss": "http://localhost:8080/realms/my-realm",
"sub": "user-id-123",
"typ": "Bearer",
"azp": "my-client",
"scope": "openid profile email",
"realm_access": {
"roles": ["user", "admin"]
},
"resource_access": {
"my-client": {
"roles": ["client-user"]
}
}
}
|
Auth0との連携#
Auth0は、クラウドベースの認証・認可プラットフォームです。
application.yml(Auth0用):
1
2
3
4
5
6
7
|
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-tenant.auth0.com/
audiences: https://your-api-identifier
|
Auth0ではaudiencesプロパティでAPIのオーディエンスを指定することが推奨されます。
Amazon Cognito との連携#
AWS Cognitoを認可サーバーとして使用する場合の設定例です。
application.yml(Cognito用):
1
2
3
4
5
6
7
|
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX
jwk-set-uri: https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX/.well-known/jwks.json
|
JWTデコーダーのカスタマイズ#
カスタムJwtDecoderの作成#
デフォルトの設定では対応できない要件がある場合、JwtDecoderをカスタマイズできます。
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
|
package com.example.resourceserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import java.time.Duration;
import java.util.List;
@Configuration
public class JwtDecoderConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Value("${spring.security.oauth2.resourceserver.jwt.audiences}")
private String audience;
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);
// バリデーターの組み合わせ
OAuth2TokenValidator<Jwt> withIssuer = new JwtIssuerValidator(issuerUri);
OAuth2TokenValidator<Jwt> withTimestamp = new JwtTimestampValidator(Duration.ofSeconds(60));
OAuth2TokenValidator<Jwt> withAudience = audienceValidator();
OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
withIssuer,
withTimestamp,
withAudience
);
jwtDecoder.setJwtValidator(combinedValidator);
return jwtDecoder;
}
private OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD,
aud -> aud != null && aud.contains(audience)
);
}
}
|
クロックスキューの調整#
分散システムでは、サーバー間の時刻のずれ(クロックスキュー)に対応する必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Bean
public JwtDecoder jwtDecoderWithClockSkew() {
NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);
// クロックスキューを120秒に設定
OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(120)),
new JwtIssuerValidator(issuerUri)
);
jwtDecoder.setJwtValidator(withClockSkew);
return jwtDecoder;
}
|
タイムアウトの設定#
認可サーバーとの通信タイムアウトをカスタマイズする場合は、RestOperationsを設定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.web.client.RestOperations;
@Bean
public JwtDecoder jwtDecoderWithTimeout(RestTemplateBuilder builder) {
RestOperations rest = builder
.setConnectTimeout(Duration.ofSeconds(30))
.setReadTimeout(Duration.ofSeconds(30))
.build();
return NimbusJwtDecoder.withIssuerLocation(issuerUri)
.restOperations(rest)
.build();
}
|
スコープベースのアクセス制御#
基本的なスコープによる認可#
OAuth2のスコープを使用してAPIエンドポイントへのアクセスを制御します。JWTのscopeクレームは自動的にSCOPE_プレフィックス付きの権限に変換されます。
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
|
package com.example.resourceserver.config;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
// パブリックエンドポイント
.requestMatchers("/api/public/**").permitAll()
// スコープベースのアクセス制御
.requestMatchers("/api/messages/**").access(hasScope("messages:read"))
.requestMatchers("/api/users/**").access(hasScope("users:read"))
// 管理者エンドポイント
.requestMatchers("/api/admin/**").access(hasScope("admin"))
// その他は認証必須
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
);
return http.build();
}
}
|
メソッドレベルセキュリティ#
@PreAuthorizeアノテーションを使用して、メソッドレベルでスコープを検証できます。
1
2
3
4
5
6
7
8
9
|
package com.example.resourceserver.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
|
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
|
package com.example.resourceserver.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import com.example.resourceserver.dto.MessageResponse;
import java.util.List;
@RestController
@RequestMapping("/api/messages")
public class MessageController {
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_messages:read')")
public List<MessageResponse> getMessages(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
// メッセージ取得ロジック
return List.of(
new MessageResponse("1", "Hello, " + userId, "system"),
new MessageResponse("2", "Welcome to the API", "system")
);
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_messages:write')")
public MessageResponse createMessage(
@RequestBody MessageRequest request,
@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
// メッセージ作成ロジック
return new MessageResponse("3", request.content(), userId);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('SCOPE_messages:delete') or hasAuthority('SCOPE_admin')")
public void deleteMessage(@PathVariable String id) {
// メッセージ削除ロジック
}
public record MessageRequest(String content) {}
}
|
カスタム権限マッピング#
Keycloakのロールをマッピング#
Keycloakでは、ロール情報がrealm_access.rolesやresource_access.<client>.rolesに格納されます。これを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
60
61
|
package com.example.resourceserver.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Configuration
public class JwtAuthConverterConfig {
@Bean
public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter());
return converter;
}
static class KeycloakGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
List<GrantedAuthority> authorities = new ArrayList<>();
// realm_accessからロールを抽出
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null) {
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles != null) {
authorities.addAll(
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList())
);
}
}
// scopeからも権限を抽出
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
authorities.addAll(
List.of(scope.split(" ")).stream()
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.collect(Collectors.toList())
);
}
return authorities;
}
}
}
|
SecurityFilterChainへの適用#
カスタムコンバーターを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
|
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter;
public SecurityConfig(Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter) {
this.jwtAuthenticationConverter = jwtAuthenticationConverter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/messages/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter)
)
);
return http.build();
}
}
|
エラーハンドリング#
認証エラーのカスタマイズ#
トークンが無効な場合やアクセスが拒否された場合のレスポンスをカスタマイズします。
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
|
package com.example.resourceserver.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
@Configuration
public class AuthenticationErrorConfig {
@Bean
public AuthenticationEntryPoint authenticationEntryPoint(ObjectMapper objectMapper) {
return (request, response, authException) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, Object> errorResponse = Map.of(
"timestamp", LocalDateTime.now().toString(),
"status", HttpStatus.UNAUTHORIZED.value(),
"error", "Unauthorized",
"message", getErrorMessage(authException),
"path", request.getRequestURI()
);
objectMapper.writeValue(response.getOutputStream(), errorResponse);
};
}
private String getErrorMessage(AuthenticationException exception) {
String message = exception.getMessage();
if (message.contains("expired")) {
return "アクセストークンの有効期限が切れています";
} else if (message.contains("invalid")) {
return "無効なアクセストークンです";
} else if (message.contains("malformed")) {
return "トークンの形式が不正です";
}
return "認証に失敗しました";
}
}
|
SecurityFilterChainへの適用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AuthenticationEntryPoint authenticationEntryPoint) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
.authenticationEntryPoint(authenticationEntryPoint)
);
return http.build();
}
|
実装例:完全なリソースサーバー#
DTOクラス#
1
2
3
4
5
6
7
|
package com.example.resourceserver.dto;
public record MessageResponse(
String id,
String content,
String authorId
) {}
|
コントローラー#
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
|
package com.example.resourceserver.controller;
import com.example.resourceserver.dto.MessageResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class MessageController {
@GetMapping("/public/health")
public Map<String, String> health() {
return Map.of("status", "UP");
}
@GetMapping("/messages")
@PreAuthorize("hasAuthority('SCOPE_messages:read')")
public List<MessageResponse> getMessages(@AuthenticationPrincipal Jwt jwt) {
return List.of(
new MessageResponse("1", "Hello, " + jwt.getSubject(), "system"),
new MessageResponse("2", "Welcome to the protected API", "system")
);
}
@GetMapping("/profile")
public Map<String, Object> getProfile(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"sub", jwt.getSubject(),
"email", jwt.getClaimAsString("email"),
"name", jwt.getClaimAsString("name"),
"scopes", jwt.getClaimAsString("scope")
);
}
}
|
統合テスト#
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
|
package com.example.resourceserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class MessageControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void パブリックエンドポイントはトークンなしでアクセス可能() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
@Test
void 適切なスコープがあればメッセージを取得できる() throws Exception {
mockMvc.perform(get("/api/messages")
.with(SecurityMockMvcRequestPostProcessors.jwt()
.jwt(jwt -> jwt
.subject("test-user")
.claim("scope", "messages:read")
)
))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].authorId").value("system"));
}
@Test
void トークンがない場合は401エラー() throws Exception {
mockMvc.perform(get("/api/messages"))
.andExpect(status().isUnauthorized());
}
@Test
void スコープが不足している場合は403エラー() throws Exception {
mockMvc.perform(get("/api/messages")
.with(SecurityMockMvcRequestPostProcessors.jwt()
.jwt(jwt -> jwt
.subject("test-user")
.claim("scope", "profile:read")
)
))
.andExpect(status().isForbidden());
}
}
|
JWT認証フローのシーケンス#
リソースサーバーにおけるJWT認証の処理フローを以下に示します。
sequenceDiagram
participant Client as クライアント
participant Filter as BearerTokenAuthenticationFilter
participant Provider as JwtAuthenticationProvider
participant Decoder as JwtDecoder
participant Converter as JwtAuthenticationConverter
participant Context as SecurityContext
Client->>Filter: APIリクエスト<br/>Authorization: Bearer <token>
Filter->>Filter: Bearerトークン抽出
Filter->>Provider: BearerTokenAuthenticationToken
Provider->>Decoder: トークン検証依頼
Decoder->>Decoder: 署名検証
Decoder->>Decoder: クレーム検証(iss, exp, aud)
Decoder-->>Provider: Jwtオブジェクト
Provider->>Converter: 権限変換依頼
Converter->>Converter: scope→GrantedAuthority変換
Converter-->>Provider: Collection<GrantedAuthority>
Provider-->>Filter: JwtAuthenticationToken
Filter->>Context: 認証情報を設定
Context-->>Client: APIレスポンス本番運用での考慮事項#
JWKキャッシュの設定#
本番環境では、JWKの取得をキャッシュすることでパフォーマンスを向上させます。
1
2
3
4
5
6
|
@Bean
public JwtDecoder jwtDecoder(CacheManager cacheManager) {
return NimbusJwtDecoder.withIssuerLocation(issuerUri)
.cache(cacheManager.getCache("jwks-cache"))
.build();
}
|
application.yml:
1
2
3
4
5
|
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=100,expireAfterWrite=5m
|
CORS設定#
SPAからのアクセスを許可する場合は、CORS設定が必要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://your-spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
configuration.setExposedHeaders(List.of("X-Request-Id"));
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 他の設定...
return http.build();
}
|
監査ログの実装#
APIアクセスの監査ログを出力するフィルターを追加します。
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
|
@Component
public class AuditLoggingFilter extends OncePerRequestFilter {
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userId = auth != null ? auth.getName() : "anonymous";
auditLogger.info("API_ACCESS user={} method={} path={} status={} duration={}ms",
userId,
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
System.currentTimeMillis() - startTime
);
}
}
}
|
まとめ#
本記事では、Spring Security 6.4を使用してOAuth2リソースサーバーを構築する方法を解説しました。
| 項目 |
内容 |
| 基本設定 |
issuer-uriの指定だけで動作する最小構成 |
| 外部IdP連携 |
Keycloak、Auth0、Cognitoとの連携方法 |
| JWTカスタマイズ |
バリデーター、タイムアウト、キャッシュの設定 |
| アクセス制御 |
スコープベース、ロールベースの認可設定 |
| 権限マッピング |
カスタムJwtAuthenticationConverterの実装 |
| エラーハンドリング |
認証エラーのカスタムレスポンス |
| テスト |
MockMvcを使用した統合テスト |
OAuth2リソースサーバーを適切に構成することで、外部の認可サーバーと連携したセキュアなAPIを効率的に構築できます。マイクロサービスアーキテクチャにおいては、各サービスをリソースサーバーとして構成し、中央の認可サーバーでトークンを発行する構成が一般的です。
参考リンク#