Share feedback
Answers are generated based on the documentation.


title: 使用 Keycloak 与 Testcontainers 保护 Spring Boot 微服务 linkTitle: Keycloak 与 Spring Boot description: 了解如何使用 Keycloak 保护 Spring Boot 微服务,并使用 Testcontainers 的 Keycloak 模块对其进行测试。 keywords: testcontainers, java, spring boot, testing, keycloak, security, oauth2, jwt summary: | 了解如何使用 Spring Boot 创建 OAuth 2.0 资源服务器,使用 Keycloak 保护 API 端点,并使用 Testcontainers 的 Keycloak 模块测试应用。 aliases:

  • /guides/testcontainers-java-keycloak-spring-boot/create-project/
  • /guides/testcontainers-java-keycloak-spring-boot/run-tests/
  • /guides/testcontainers-java-keycloak-spring-boot/write-tests/ params: tags: [testing] time: 30 minutes

在本指南中,你将学习如何:

  • 使用 Spring Boot 创建一个 OAuth 2.0 资源服务器
  • 使用 Keycloak 保护 API 端点
  • 使用 Testcontainers 的 Keycloak 模块测试 API
  • 使用 Testcontainers 的 Keycloak 模块在本地运行应用

先决条件

  • Java 17+
  • Maven 或 Gradle
  • 一个受 Testcontainers 支持的 Docker 环境
Note

如果你刚接触 Testcontainers,请访问 Testcontainers 概览 了解 Testcontainers 及其使用优势。

创建 Spring Boot 项目

搭建项目

Spring Initializr 上创建一个 Spring Boot 项目, 添加 Spring WebValidationJDBC APIPostgreSQL DriverSpring SecurityOAuth2 Resource Server 以及 Testcontainers 这几个 starter。

或者,克隆 指南仓库

生成应用后,添加 testcontainers-keycloak 社区模块和 REST Assured 作为测试依赖。

pom.xml 中的关键依赖如下:

<properties>
    <java.version>17</java.version>
    <testcontainers.version>2.0.4</testcontainers.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </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>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.github.dasniko</groupId>
        <artifactId>testcontainers-keycloak</artifactId>
        <version>3.4.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

创建领域模型

创建一个表示领域对象的 Product record:

package com.testcontainers.products.domain;

import jakarta.validation.constraints.NotEmpty;

public record Product(Long id, @NotEmpty String title, String description) {}

创建仓储

使用 Spring 的 JdbcClient 实现 ProductRepository,与 PostgreSQL 数据库交互:

package com.testcontainers.products.domain;

import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

@Repository
public class ProductRepository {

  private final JdbcClient jdbcClient;

  public ProductRepository(JdbcClient jdbcClient) {
    this.jdbcClient = jdbcClient;
  }

  public List<Product> getAll() {
    return jdbcClient.sql("SELECT * FROM products").query(Product.class).list();
  }

  public Product create(Product product) {
    String sql =
      "INSERT INTO products(title, description) VALUES (:title,:description) RETURNING id";
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcClient
      .sql(sql)
      .param("title", product.title())
      .param("description", product.description())
      .update(keyHolder);
    Long id = keyHolder.getKeyAs(Long.class);
    return new Product(id, product.title(), product.description());
  }
}

添加 schema 创建脚本

创建 src/main/resources/schema.sql 来初始化 products 表:

CREATE TABLE products (
    id bigserial primary key,
    title varchar not null,
    description text
);

src/main/resources/application.properties 中启用 schema 初始化:

spring.sql.init.mode=always

对于生产应用,请改用 Flyway 或 Liquibase 之类的数据库迁移工具。

实现 API 端点

创建 ProductController,包含获取所有产品以及创建产品的端点:

package com.testcontainers.products.api;

import com.testcontainers.products.domain.Product;
import com.testcontainers.products.domain.ProductRepository;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/products")
class ProductController {

  private final ProductRepository productRepository;

  ProductController(ProductRepository productRepository) {
    this.productRepository = productRepository;
  }

  @GetMapping
  List<Product> getAll() {
    return productRepository.getAll();
  }

  @PostMapping
  @ResponseStatus(HttpStatus.CREATED)
  Product createProduct(@RequestBody @Valid Product product) {
    return productRepository.create(product);
  }
}

配置 OAuth 2.0 安全

创建一个 SecurityConfig 类,使用基于 JWT 令牌的认证来保护 API 端点:

package com.testcontainers.products.config;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(c ->
        c
          .requestMatchers(HttpMethod.GET, "/api/products")
          .permitAll()
          .requestMatchers(HttpMethod.POST, "/api/products")
          .authenticated()
          .anyRequest()
          .authenticated()
      )
      .sessionManagement(c ->
        c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      )
      .cors(CorsConfigurer::disable)
      .csrf(CsrfConfigurer::disable)
      .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
    return http.build();
  }
}

该配置:

  • 允许 GET /api/products 免认证访问。
  • 要求 POST /api/products 及所有其他端点通过认证。
  • 使用基于 JWT 令牌的认证配置 OAuth 2.0 资源服务器。
  • 由于这是一个无状态 API,因此禁用了 CORS 和 CSRF。

将 JWT 签发者 URI 添加到 application.properties

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9090/realms/keycloaktcdemo

导出 Keycloak realm 配置

在编写测试之前,先导出一份 Keycloak realm 配置,以便测试环境能自动导入它。先启动一个临时的 Keycloak 实例:

$ docker run -p 9090:8080 \
    -e KEYCLOAK_ADMIN=admin \
    -e KEYCLOAK_ADMIN_PASSWORD=admin \
    quay.io/keycloak/keycloak:25 start-dev

打开 http://localhost:9090,使用 admin/admin 登录 Admin Console。然后配置 realm:

  1. 在左上角,选择 realm 下拉菜单,创建一个名为 keycloaktcdemo 的 realm。
  2. keycloaktcdemo realm 下,创建一个客户端,配置如下:
    • Client IDproduct-service
    • Client AuthenticationOn
    • Authentication flow:仅勾选 Service accounts roles
  3. Client details 页面,切换到 Credentials 标签页,复制 Client secret 的值。

导出 realm 配置:

$ docker ps
# 复制 keycloak 容器 id

$ docker exec -it <container-id> /bin/bash

$ /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm keycloaktcdemo

$ exit

$ docker cp <container-id>:/opt/keycloak/data/import/keycloaktcdemo-realm.json keycloaktcdemo-realm.json

将导出的 keycloaktcdemo-realm.json 文件复制到 src/test/resources 目录下。

使用 Testcontainers 编写测试

要测试受保护的 API 端点,你需要一个运行中的 Keycloak 实例、一个 PostgreSQL 数据库,以及启动的 Spring 上下文。Testcontainers 会在 Docker 容器中启动这两个服务,并通过动态属性注册将它们连接到 Spring。

配置测试容器

Spring Boot 的 Testcontainers 支持允许你将容器声明为 bean。对于 Keycloak,@ServiceConnection 并不可用,但你可以使用 DynamicPropertyRegistry 来动态设置 JWT 签发者 URI。

src/test/java 下创建 ContainersConfig.java

package com.testcontainers.products;

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.testcontainers.postgresql.PostgreSQLContainer;

@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {

  static String POSTGRES_IMAGE = "postgres:16-alpine";
  static String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:25.0";
  static String realmImportFile = "/keycloaktcdemo-realm.json";
  static String realmName = "keycloaktcdemo";

  @Bean
  @ServiceConnection
  PostgreSQLContainer postgres() {
    return new PostgreSQLContainer(POSTGRES_IMAGE);
  }

  @Bean
  KeycloakContainer keycloak(DynamicPropertyRegistry registry) {
    var keycloak = new KeycloakContainer(KEYCLOAK_IMAGE)
      .withRealmImportFile(realmImportFile);
    registry.add(
      "spring.security.oauth2.resourceserver.jwt.issuer-uri",
      () -> keycloak.getAuthServerUrl() + "/realms/" + realmName
    );
    return keycloak;
  }
}

该配置:

  • 声明了一个带 @ServiceConnectionPostgreSQLContainer bean,它会启动一个 PostgreSQL 容器并自动注册数据源属性。
  • 声明了一个使用 quay.io/keycloak/keycloak:25.0 镜像的 KeycloakContainer bean, 导入 realm 配置文件,并根据 Keycloak 容器的认证服务器 URL 动态注册 JWT 签发者 URI。

编写测试

创建 ProductControllerTests.java

package com.testcontainers.products.api;

import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static java.util.Collections.singletonList;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.testcontainers.products.ContainersConfig;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(ContainersConfig.class)
class ProductControllerTests {

  static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
  static final String CLIENT_ID = "product-service";
  static final String CLIENT_SECRET = "jTJJqdzeCSt3DmypfHZa42vX8U9rQKZ9";

  @LocalServerPort
  private int port;

  @Autowired
  OAuth2ResourceServerProperties oAuth2ResourceServerProperties;

  @BeforeEach
  void setup() {
    RestAssured.port = port;
  }

  @Test
  void shouldGetProductsWithoutAuthToken() {
    when().get("/api/products").then().statusCode(200);
  }

  @Test
  void shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() {
    given()
      .contentType("application/json")
      .body(
        """
            {
                "title": "New Product",
                "description": "Brand New Product"
            }
        """
      )
      .when()
      .post("/api/products")
      .then()
      .statusCode(401);
  }

  @Test
  void shouldCreateProductWithAuthToken() {
    String token = getToken();

    given()
      .header("Authorization", "Bearer " + token)
      .contentType("application/json")
      .body(
        """
            {
                "title": "New Product",
                "description": "Brand New Product"
            }
        """
      )
      .when()
      .post("/api/products")
      .then()
      .statusCode(201);
  }

  private String getToken() {
    RestTemplate restTemplate = new RestTemplate();
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
    map.put("grant_type", singletonList(GRANT_TYPE_CLIENT_CREDENTIALS));
    map.put("client_id", singletonList(CLIENT_ID));
    map.put("client_secret", singletonList(CLIENT_SECRET));

    String authServerUrl =
      oAuth2ResourceServerProperties.getJwt().getIssuerUri() +
      "/protocol/openid-connect/token";

    var request = new HttpEntity<>(map, httpHeaders);
    KeyCloakToken token = restTemplate.postForObject(
      authServerUrl,
      request,
      KeyCloakToken.class
    );

    assert token != null;
    return token.accessToken();
  }

  record KeyCloakToken(@JsonProperty("access_token") String accessToken) {}
}

这些测试覆盖的内容如下:

  • shouldGetProductsWithoutAuthToken() 在不带 Authorization 头的情况下调用 GET /api/products。由于该端点被配置为允许免认证访问,响应状态码为 200。
  • shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() 在不带 Authorization 头的情况下调用受保护的 POST /api/products 端点,并断言响应状态码为 401 (未授权)。
  • shouldCreateProductWithAuthToken() 首先使用 Client Credentials 流程获取一个 access_token。然后在调用 POST /api/products 时,将该令牌作为 Bearer 令牌 放入 Authorization 头中,并断言响应状态码为 201(已创建)。

getToken() 辅助方法使用在导出的 realm 中配置的客户端 ID 和客户端密钥,从 Keycloak 令牌端点请求访问令牌。

将 Testcontainers 用于本地开发

Spring Boot 的 Testcontainers 支持也可用于本地开发。在 src/test/java 下创建 TestApplication.java

package com.testcontainers.products;

import org.springframework.boot.SpringApplication;

public class TestApplication {

  public static void main(String[] args) {
    SpringApplication
      .from(Application::main)
      .with(ContainersConfig.class)
      .run(args);
  }
}

从 IDE 中运行 TestApplication.java,而不是主类 Application.java。它会启动 ContainersConfig 中定义的容器,并将应用配置为使用动态注册的属性,因此你无需 手动安装或配置 PostgreSQL 和 Keycloak。

运行测试与后续步骤

运行测试

$ ./mvnw test

或者使用 Gradle:

$ ./gradlew test

你应该会看到 Keycloak 和 PostgreSQL 的 Docker 容器启动,并导入 realm 配置,测试 全部通过。测试结束后,容器会自动停止并被移除。

小结

Testcontainers 的 Keycloak 模块让你能够基于真实的 Keycloak 服务器开发并测试应用, 而不是使用 mock。针对一个与你的生产环境相仿的真实 OAuth 2.0 提供方进行测试,能让你 对安全配置和基于令牌的认证流程更有信心。

想进一步了解 Testcontainers,请访问 Testcontainers 概览

延伸阅读