1. バックエンド統合の概要
Keycloak は標準の OAuth2/OIDC メカニズムを提供し、JWT 検証をサポートするバックエンド フレームワークとの統合を可能にします。この記事では、Java エコシステムで最も人気のある 2 つのフレームワークを統合します。スプリングブーツ3そしてクォーカス.
| フレームワーク | 図書館 | アプローチ |
|---|---|---|
| スプリングブーツ3 | スプリングブートスターターoauth2リソースサーバー | JWTリソースサーバー |
| クォーカス | quarkus-oidc | OIDC 拡張機能 |
アーキテクチャの概要:
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Client │────▶│ Keycloak │ │ Backend API │
│ (SPA/ │ │ Server │ │ (Spring Boot │
│ Mobile) │ └─────┬─────┘ │ / Quarkus) │
│ │ │ └───────┬──────┘
│ │ ┌─────▼─────┐ │
│ │ │ Access │ ┌───────▼──────┐
│ │────▶│ Token │────▶│ JWT Verify │
│ │ │ (JWT) │ │ + Role Check │
└──────────┘ └───────────┘ └──────────────┘
2. Spring Boot 3 + Keycloak の統合
2.1 Maven の依存関係
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-spring-demo</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Resource Server (JWT validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Test dependencies -->
<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>
<!-- Testcontainers Keycloak -->
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>3.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
重要な注意事項:Keycloak 20+ 以降、Spring Boot 専用アダプター (キークローク-スプリングブート-スターター) 苦しんできた廃止された。現在の標準的な方法は次のとおりです。スプリングブートスターターoauth2リソースサーバーSpring Security による。
2.2 Application.yml の設定
# src/main/resources/application.yml
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
# Keycloak OIDC issuer URI
issuer-uri: http://localhost:8080/realms/my-realm
# JWK Set URI để verify JWT signature
jwk-set-uri: http://localhost:8080/realms/my-realm/protocol/openid-connect/certs
logging:
level:
org.springframework.security: DEBUG
プロパティの説明:
| 財産 | 説明する |
|---|---|
発行者ウリ | KeycloakレルムのURI、検証に使用されますですJWT でのクレーム |
jwk-set-uri | エンドポイントには、JWT 署名を検証するための公開キー (JWKS) が含まれています |
2.3 セキュリティ設定
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Bật @PreAuthorize, @Secured, @RolesAllowed
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Stateless session - không lưu session trên server
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Tắt CSRF cho REST API (stateless)
.csrf(csrf -> csrf.disable())
// Cấu hình authorization rules
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
// Admin endpoints yêu cầu role ADMIN
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// User endpoints yêu cầu role USER hoặc ADMIN
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/users/**").hasRole("ADMIN")
// Tất cả request khác cần authenticated
.anyRequest().authenticated()
)
// Cấu hình OAuth2 Resource Server với JWT
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRoleConverter());
return converter;
}
}
2.4 カスタム JwtAuthenticationConverter
Keycloakは、JWTクレームのロールを特別な構造に保存します。正しい役割を抽出するにはカスタム コンバーターが必要です。
JWT Claims từ Keycloak:
{
"realm_access": {
"roles": ["ADMIN", "USER", "offline_access"]
},
"resource_access": {
"my-client": {
"roles": ["client_admin", "client_user"]
},
"account": {
"roles": ["manage-account"]
}
},
"preferred_username": "john",
"email": "[email protected]",
...
}
package com.example.config;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class KeycloakRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
// Client ID trong Keycloak
private static final String CLIENT_ID = "my-client";
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
// Extract realm roles
Collection<String> realmRoles = extractRealmRoles(jwt);
// Extract client roles
Collection<String> clientRoles = extractClientRoles(jwt, CLIENT_ID);
// Combine và convert thành GrantedAuthority
return Stream.concat(realmRoles.stream(), clientRoles.stream())
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
}
@SuppressWarnings("unchecked")
private Collection<String> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return Collections.emptyList();
}
Object roles = realmAccess.get("roles");
if (roles instanceof Collection) {
return (Collection<String>) roles;
}
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
private Collection<String> extractClientRoles(Jwt jwt, String clientId) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess == null) {
return Collections.emptyList();
}
Object clientAccess = resourceAccess.get(clientId);
if (clientAccess instanceof Map) {
Map<String, Object> clientMap = (Map<String, Object>) clientAccess;
Object roles = clientMap.get("roles");
if (roles instanceof Collection) {
return (Collection<String>) roles;
}
}
return Collections.emptyList();
}
}
役割マッピングの仕組み:
Keycloak JWT claim → Spring Security Authority
─────────────────────────────────────────────────────────────
realm_access.roles["ADMIN"] → ROLE_ADMIN
realm_access.roles["USER"] → ROLE_USER
resource_access.my-client → ROLE_client_admin
.roles["client_admin"]
resource_access.my-client → ROLE_client_user
.roles["client_user"]
2.5 RBAC を備えた REST コントローラー
package com.example.controller;
import org.springframework.http.ResponseEntity;
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.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class DemoController {
// ========== Public Endpoints ==========
@GetMapping("/public/health")
public ResponseEntity<Map<String, String>> health() {
return ResponseEntity.ok(Map.of("status", "UP"));
}
// ========== Authenticated Endpoints ==========
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> getCurrentUser(
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(Map.of(
"username", jwt.getClaimAsString("preferred_username"),
"email", jwt.getClaimAsString("email"),
"roles", jwt.getClaimAsMap("realm_access"),
"token_id", jwt.getId()
));
}
// ========== Role-based Endpoints ==========
@GetMapping("/users")
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> getUsers() {
return ResponseEntity.ok(Map.of(
"message", "User list - accessible by USER and ADMIN roles"
));
}
@PostMapping("/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> createUser(
@RequestBody Map<String, String> user) {
return ResponseEntity.ok(Map.of(
"message", "User created - ADMIN only",
"username", user.getOrDefault("username", "unknown")
));
}
@DeleteMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> deleteUser(@PathVariable String id) {
return ResponseEntity.ok(Map.of(
"message", "User deleted",
"userId", id
));
}
// ========== Admin Endpoints ==========
@GetMapping("/admin/dashboard")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> adminDashboard() {
return ResponseEntity.ok(Map.of(
"message", "Admin Dashboard - ADMIN only"
));
}
// ========== Advanced Authorization ==========
// SpEL expression kiểm tra nhiều điều kiện
@PutMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN') or " +
"(hasRole('USER') and #jwt.getClaimAsString('preferred_username') == #id)")
public ResponseEntity<Map<String, String>> updateUser(
@PathVariable String id,
@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> updates) {
return ResponseEntity.ok(Map.of(
"message", "User updated",
"userId", id,
"updatedBy", jwt.getClaimAsString("preferred_username")
));
}
// Kiểm tra client role
@GetMapping("/reports")
@PreAuthorize("hasAuthority('ROLE_client_admin')")
public ResponseEntity<Map<String, String>> getReports() {
return ResponseEntity.ok(Map.of(
"message", "Reports - requires client_admin role"
));
}
}
2.6 CORS の設定
フロントエンド (React/Angular) がバックエンド API を呼び出すときは、CORS を構成する必要があります。
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000", // React dev
"http://localhost:4200" // Angular dev
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
CORS の追加セキュリティフィルターチェーン:
// Trong SecurityConfig.securityFilterChain()
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ... các config khác
2.7 トークンの有効期限の処理
Spring Security は自動的に検証します経験値請求。請求。トークンの有効期限が切れると、サーバーは HTTP 401 を返します。
package com.example.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, Object> body = Map.of(
"status", 401,
"error", "Unauthorized",
"message", authException.getMessage(),
"path", request.getRequestURI(),
"timestamp", Instant.now().toString()
);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
に登録してくださいセキュリティフィルターチェーン:
// Trong SecurityConfig
@Autowired
private CustomAuthenticationEntryPoint authEntryPoint;
// Trong securityFilterChain()
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
.authenticationEntryPoint(authEntryPoint)
)
3.Quarkus + Keycloakの統合
3.1 Quarkus OIDC 拡張機能
Quarkus が提供するquarkus-oidcKeycloakと統合された拡張機能:
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<!-- Optional: Keycloak Authorization Policy Enforcer -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-keycloak-authorization</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-keycloak-server</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2 アプリケーションのプロパティ
# src/main/resources/application.properties
# ===== OIDC Configuration =====
quarkus.oidc.auth-server-url=http://localhost:8080/realms/my-realm
quarkus.oidc.client-id=my-quarkus-client
quarkus.oidc.credentials.secret=my-client-secret
# Application type: service (Resource Server) hoặc web-app (OIDC login)
quarkus.oidc.application-type=service
# Token verification
quarkus.oidc.token.issuer=http://localhost:8080/realms/my-realm
quarkus.oidc.token.audience=my-quarkus-client
# Role mapping - Keycloak roles source
quarkus.oidc.roles.role-claim-path=realm_access/roles
quarkus.oidc.roles.source=accesstoken
# ===== HTTP Configuration =====
quarkus.http.port=8081
quarkus.http.cors=true
quarkus.http.cors.origins=http://localhost:3000,http://localhost:4200
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
quarkus.http.cors.headers=Authorization,Content-Type
3.3 @RolesAllowed を使用した REST リソース
package com.example.resource;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.Set;
import org.eclipse.microprofile.jwt.JsonWebToken;
@Path("/api")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DemoResource {
@Inject
SecurityIdentity securityIdentity;
@Inject
JsonWebToken jwt;
// ========== Public ==========
@GET
@Path("/public/health")
@PermitAll
public Response health() {
return Response.ok(Map.of("status", "UP")).build();
}
// ========== Authenticated ==========
@GET
@Path("/me")
@Authenticated
public Response getCurrentUser() {
return Response.ok(Map.of(
"username", jwt.getClaim("preferred_username"),
"email", jwt.getClaim("email"),
"roles", securityIdentity.getRoles(),
"token_id", jwt.getTokenID()
)).build();
}
// ========== Role-based ==========
@GET
@Path("/users")
@RolesAllowed({"USER", "ADMIN"})
public Response getUsers() {
return Response.ok(Map.of(
"message", "User list - USER and ADMIN roles"
)).build();
}
@POST
@Path("/users")
@RolesAllowed("ADMIN")
public Response createUser(Map<String, String> user) {
return Response.ok(Map.of(
"message", "User created",
"username", user.getOrDefault("username", "unknown")
)).build();
}
@GET
@Path("/admin/dashboard")
@RolesAllowed("ADMIN")
public Response adminDashboard() {
return Response.ok(Map.of(
"message", "Admin Dashboard - ADMIN only",
"identity", securityIdentity.getPrincipal().getName()
)).build();
}
}
3.4 マルチテナント OIDC 構成
Quarkus は、複数の Keycloak レルムに接続する必要がある SaaS システムのマルチテナント OIDC をサポートしています。
# application.properties - Multi-tenant setup
# Default tenant
quarkus.oidc.auth-server-url=http://localhost:8080/realms/default-realm
quarkus.oidc.client-id=default-client
quarkus.oidc.application-type=service
# Tenant A
quarkus.oidc.tenant-a.auth-server-url=http://localhost:8080/realms/tenant-a
quarkus.oidc.tenant-a.client-id=tenant-a-client
quarkus.oidc.tenant-a.credentials.secret=tenant-a-secret
quarkus.oidc.tenant-a.application-type=service
# Tenant B
quarkus.oidc.tenant-b.auth-server-url=http://localhost:8080/realms/tenant-b
quarkus.oidc.tenant-b.client-id=tenant-b-client
quarkus.oidc.tenant-b.credentials.secret=tenant-b-secret
quarkus.oidc.tenant-b.application-type=service
package com.example.config;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CustomTenantResolver implements TenantResolver {
@Override
public String resolve(RoutingContext context) {
// Resolve tenant từ request path
String path = context.request().path();
if (path.startsWith("/api/tenant-a")) {
return "tenant-a";
}
if (path.startsWith("/api/tenant-b")) {
return "tenant-b";
}
// Hoặc resolve từ header
String tenantHeader = context.request().getHeader("X-Tenant-ID");
if (tenantHeader != null) {
return tenantHeader;
}
// Default tenant
return null;
}
}
3.5 Keycloak認可ポリシー・エンフォーサ
使用quarkus-keycloak-承認Keycloak認可サービスポリシーを適用するには:
# application.properties
quarkus.keycloak.policy-enforcer.enable=true
quarkus.keycloak.policy-enforcer.enforcement-mode=ENFORCING
# Policy paths
quarkus.keycloak.policy-enforcer.paths.users.path=/api/users/*
quarkus.keycloak.policy-enforcer.paths.users.enforcement-mode=ENFORCING
quarkus.keycloak.policy-enforcer.paths.admin.path=/api/admin/*
quarkus.keycloak.policy-enforcer.paths.admin.enforcement-mode=ENFORCING
quarkus.keycloak.policy-enforcer.paths.public.path=/api/public/*
quarkus.keycloak.policy-enforcer.paths.public.enforcement-mode=DISABLED
4. テストコンテナを使用したテスト
4.1 Spring Boot + テストコンテナ Keycloak
使用テストコンテナ-キークローク統合テストで実際の Keycloak を実行するには:
package com.example;
import com.github.dasniko.testcontainers.keycloak.KeycloakContainer;
import org.junit.jupiter.api.BeforeAll;
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.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class KeycloakIntegrationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:25.0")
.withRealmImportFile("test-realm.json");
@Autowired
MockMvc mockMvc;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/test-realm");
registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
() -> keycloak.getAuthServerUrl()
+ "/realms/test-realm/protocol/openid-connect/certs");
}
static String adminToken;
static String userToken;
@BeforeAll
static void obtainTokens() {
// Lấy admin token
adminToken = getAccessToken("admin-user", "admin-pass");
// Lấy user token
userToken = getAccessToken("regular-user", "user-pass");
}
static String getAccessToken(String username, String password) {
// Sử dụng Keycloak Admin Client hoặc HTTP request
// để lấy token từ Keycloak container
String tokenEndpoint = keycloak.getAuthServerUrl()
+ "/realms/test-realm/protocol/openid-connect/token";
// HTTP POST to token endpoint
// grant_type=password&client_id=test-client
// &username=...&password=...
// Return access_token from response
// (Implementation chi tiết sử dụng RestTemplate hoặc WebClient)
return ""; // Placeholder
}
@Test
void publicEndpoint_shouldReturnOk() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
@Test
void protectedEndpoint_withoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpoint_withValidToken_shouldReturnOk() throws Exception {
mockMvc.perform(get("/api/me")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").exists());
}
@Test
void adminEndpoint_withUserToken_shouldReturn403() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken))
.andExpect(status().isForbidden());
}
@Test
void adminEndpoint_withAdminToken_shouldReturnOk() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").exists());
}
}
4.2 レルム JSON のテスト
ファイルの作成src/test/resources/test-realm.jsonテスト用にレルムをインポートするには:
{
"realm": "test-realm",
"enabled": true,
"clients": [
{
"clientId": "test-client",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"]
}
],
"roles": {
"realm": [
{ "name": "ADMIN", "composite": false },
{ "name": "USER", "composite": false }
]
},
"users": [
{
"username": "admin-user",
"enabled": true,
"credentials": [
{ "type": "password", "value": "admin-pass", "temporary": false }
],
"realmRoles": ["ADMIN", "USER"]
},
{
"username": "regular-user",
"enabled": true,
"credentials": [
{ "type": "password", "value": "user-pass", "temporary": false }
],
"realmRoles": ["USER"]
}
]
}
4.3 モック JWT を使用した単体テスト
単体テストには実際の Keycloak は必要ありません。それを使用してください@WithMockUserまたはカスタム JWT:
package com.example;
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 java.util.List;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class MockJwtTest {
@Autowired
MockMvc mockMvc;
@Test
void testWithMockJwt_AdminRole() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.with(jwt()
.jwt(builder -> builder
.claim("preferred_username", "test-admin")
.claim("email", "[email protected]")
.claim("realm_access", Map.of(
"roles", List.of("ADMIN", "USER")
))
)
.authorities(new KeycloakRoleConverter())
))
.andExpect(status().isOk());
}
@Test
void testWithMockJwt_UserRole_AccessDenied() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.with(jwt()
.jwt(builder -> builder
.claim("preferred_username", "test-user")
.claim("realm_access", Map.of(
"roles", List.of("USER")
))
)
.authorities(new KeycloakRoleConverter())
))
.andExpect(status().isForbidden());
}
}
5. ベストプラクティスとトラブルシューティング
5.1 ベストプラクティス
| # | 練習する | 説明する |
|---|---|---|
| 1 | レルムロールの使用 | 優先順位を付けるrealm_access.roles簡単な認証用 |
| 2 | ステートレスセッション | 常に使用するSessionCreationPolicy.STATELESSREST API用 |
| 3 | API の CSRF を無効にする | JWT Bearer トークンを使用する場合、CSRF は必要ありません |
| 4 | トークン検証のキャッシュ | Spring SecurityはJWKセットを自動的にキャッシュするため、リクエストごとにKeycloakを呼び出す必要はありません |
| 5 | クレームベースの認可 | 使用@PreAuthorize複雑なロジックには SpEL を使用 |
| 6 | エラー処理 | カスタム認証エントリポイント統一応答フォーマット用 |
| 7 | テストカバレッジ | 単体テスト (モック JWT) と統合テスト (Testcontainers) を結合する |
5.2 一般的な問題のトラブルシューティング
Lỗi: "An error occurred while attempting to decode the Jwt"
→ Kiểm tra issuer-uri có đúng realm URL không
→ Đảm bảo Keycloak server đang chạy và accessible
Lỗi: "Jwt expired"
→ Token đã hết hạn, client cần refresh token
→ Kiểm tra Access Token Lifespan trong Keycloak Realm Settings
Lỗi: "Access Denied" dù đúng role
→ Kiểm tra role name trong JWT có match với @PreAuthorize không
→ Debug: log SecurityContext.getAuthentication().getAuthorities()
→ Kiểm tra KeycloakRoleConverter có prefix "ROLE_" đúng không
Lỗi: "CORS error" khi gọi từ frontend
→ Kiểm tra CorsConfiguration có include frontend origin không
→ Đảm bảo Authorization header được allow
5.3 カールテストコマンド
# 1. Lấy Access Token từ Keycloak
ACCESS_TOKEN=$(curl -s -X POST \
"http://localhost:8080/realms/my-realm/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=my-client" \
-d "username=admin" \
-d "password=admin" \
| jq -r '.access_token')
echo $ACCESS_TOKEN
# 2. Decode JWT (kiểm tra claims)
echo $ACCESS_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
# 3. Gọi public endpoint
curl -s http://localhost:8081/api/public/health | jq .
# 4. Gọi protected endpoint với token
curl -s http://localhost:8081/api/me \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .
# 5. Gọi admin endpoint
curl -s http://localhost:8081/api/admin/dashboard \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .
# 6. Gọi endpoint không có token (expect 401)
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/api/me
6. まとめ
| 基準 | スプリングブーツ3 | クォーカス |
|---|---|---|
| 図書館 | スプリングブートスターターoauth2リソースサーバー | quarkus-oidc |
| 役割のマッピング | カスタムJwtAuthenticationConverter | 構成役割.役割要求パス |
| 認可 | @PreAuthorize, 役割がある() | @RolesAllowed, @認証済み |
| マルチテナント | カスタム実装 | 内蔵テナントリゾルバー |
| ポリシー執行者 | マニュアル | quarkus-keycloak-承認 |
| テスト | テストコンテナ + モック JWT | quarkus-テスト-keycloak-サーバー |
| 起動時間 | ~2~5秒 | ~0.5-1s (ネイティブ ~0.01s) |
次の記事では、Keycloakをフロントエンドフレームワーク(React、Angular)およびNode.jsバックエンドと統合する方法を学びます。