Chuyển đến nội dung chính

レッスン 20: Spring Boot と Quarkus の統合

Spring Security OAuth2 Resource Server (spring-boot-starter-oauth2-resource-server) を使用して Keycloak を Spring Boot 3 と統合し、JWT 検証、カスタム JwtAuthenticationConverter、realm_access/resource_access クレームからのロール マッピング、Spring Security メソッドレベルの認可 (@PreAuthorize) を構成し、Quarkus と quarkus-oidc 拡張機能、マルチテナント構成および Testcontainers を使用したテストを統合します。

🔒 DevSecOps — レッスン 20 レッスン 20: Spring Boot と Quarkus の統合

基本から上級までの Keycloak

パート 6: 実際のアプリケーションの統合

xdev.asia

1. バックエンド統合の概要

Keycloak は標準の OAuth2/OIDC メカニズムを提供し、JWT 検証をサポートするバックエンド フレームワークとの統合を可能にします。この記事では、Java エコシステムで最も人気のある 2 つのフレームワークを統合します。スプリングブーツ3そしてクォーカス.

フレームワーク図書館アプローチ
スプリングブーツ3スプリングブートスターターoauth2リソースサーバーJWTリソースサーバー
クォーカスquarkus-oidcOIDC 拡張機能

アーキテクチャの概要:

┌──────────┐     ┌───────────┐     ┌──────────────┐
│  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用
3API の 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-承認
テストテストコンテナ + モック JWTquarkus-テスト-keycloak-サーバー
起動時間~2~5秒~0.5-1s (ネイティブ ~0.01s)

次の記事では、Keycloakをフロントエンドフレームワーク(React、Angular)およびNode.jsバックエンドと統合する方法を学びます。