51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

使用 Keycloak 为 Spring Cloud Gateway 和 Spring Boot 微服务启用 OAuth2

本文将带你了解如何使用 Keycloak 为 Spring Cloud Gateway 和 Spring Boot 微服务启用 OAuth2。本文是 上一篇文章 的扩展,并分析了 Spring Security 项目中提供的一些最新功能。

我们的架构由两个 Spring Boot 微服务组成、一个基于 Spring Cloud Gateway 的 API 网关和一个 Keycloak 授权服务器。Spring Cloud Gateway 在此充当 OAuth2 客户端和 OAuth2 资源服务器。对于任何传入请求,它都会在将流量转发到下游服务之前验证 Access Token。对于任何未经验证的请求,它都会使用 Keycloak 初始化一个授权码授权的流程。我们的方案需要包括内部微服务之间的通信。它们都隐藏在 API 网关后面。caller 应用调用 callme 应用暴露的端点。通信中使用的 HTTP 客户端必须使用网关发送的 Access Token。

微服务的认证流程

源码 {#源码}

本文中的代码托管在 Github,你可以克隆这个 Repository,进入到 oauth 目录,其中包含了两个 Spring Boot 微服务:callmecaller。当然,还有构建在 Spring Cloud Gateway 之上的 gateway 应用。之后,只需按照说明操作即可。

运行并配置 Keycloak {#运行并配置-keycloak}

我们以 Docker 容器的形式运行 Keycloak。默认情况下,Keycloak 在 8080 端口上公开 API 和 Web 控制台。还需要通过环境变量设置管理员用户名和密码。下面是运行 Keycloak 容器的命令:

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

容器启动后,访问 http://localhost:8080/admin 地址下的管理控制台。创建一个新 realm。该 realm 的名称是 demo。与其手动创建所需内容,不如导入包含整个 realm 配置的 JSON 资源文件。你可以在 GitHub 仓库中找到这样的资源文件:oauth/gateway/src/test/resources/realm-export.json。不过,接下来,我将使用 Keycloak 面板逐步创建对象。如果你从 JSON 资源文件导入了配置,可以直接跳到下一章节。

keycloak JSON 配置

然后,在 demo realm 中添加一个 OpenID Connect 客户端。客户端的名称是 spring-with-test-scope。启用客户端身份认证,并在 "Valid redirect URIs" 字段中输入正确的地址(为测试目的,可以使用通配符)。

demo realm 中的 OpenID Connect 客户端

我们需要保存客户端的名称及其 secret。这两项设置必须在应用端设置。

户端的名称及其 secret

然后,创建一个名称为 TEST 的新 Client Scope。

创建名称为 TEST 的新 Client Scope

接着,将 TEST 添加到 spring-with-test-scope Client Scope 中。

TEST scope

还需要创建一个用户来验证 Keycloak。用户名为 spring。为了设置密码,需要切换到 "Credentials" 选项卡,设置密码为 Spring_123

创建用户

完成配置后,可以将其导出为 JSON 文件(与创建新 realm 时使用的文件相同)。这样的文件对以后使用 Testcontainers 构建自动化测试很有用。

导出 keycloak 配置

但是,Keycloak 不会将真实用户导出到文件中。因此,需要在导出文件的 users 部分添加以下 JSON。

{
  "username": "spring",
  "email": "piotr.minkowski@gmail.com",
  "firstName": "Piotr",
  "lastName": "Minkowski",
  "enabled": true,
  "credentials": [
    {
      "type": "password",
      "value": "Spring_123"
    }
  ],
  "realmRoles": [
    "default-roles-demo",
    "USER"
  ]
}

创建 Spring Cloud Gateway 网关应用 {#创建-spring-cloud-gateway-网关应用}

如前所述,gateway 应用充当 OAuth2 客户端和 OAuth2 资源服务器,包含的依赖如下:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<!-- 自动解码 JWT Token -->
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- 网关 -->
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<!-- 使用 Testcontainers 在 JUnit 测试期间运行 Keycloak 容器 -->
<dependency>
  <groupId>com.github.dasniko</groupId>
  <artifactId>testcontainers-keycloak</artifactId>
  <version>3.2.0</version>
 <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>1.19.6</version>
  <scope>test</scope>
</dependency>

从 Spring Security 配置开始。首先,需要用 @EnableWebFluxSecurityConfiguration Bean 进行注解。这是因为 Spring Cloud Gateway 使用的是 Spring Web 模块的响应式版本。oauth2Login() 方法负责将未经验证的请求重定向到 Keycloak 登录页面。oauth2ResourceServer() 方法会在将流量转发到下游服务之前验 Access Token。

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.authorizeExchange(auth -> auth.anyExchange().authenticated())
                .oauth2Login(withDefaults())
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.csrf(ServerHttpSecurity.CsrfSpec::disable);
        return http.build();
    }

}

除此以外,还需要设置几个 spring.security.oauth2 前缀开头的配置。Spring OAuth2 资源服务器模块使用 Keycloak JWK 端点来验证传入的 JWT Token。在 Spring OAuth2 客户端部分,我们需要提供 Keycloak issuer realm 的地址。

当然,还需要提供 Keycloak 客户端凭证,选择授权类型和 scope。

spring.security.oauth2:
  resourceserver:
    jwt:
      jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs
  client:
    provider:
      keycloak:
        issuer-uri: http://localhost:8080/realms/demo
    registration:
      spring-with-test-scope:
        provider: keycloak
        client-id: spring-with-test-scope
        client-secret: IWLSnakHG8aNTWNaWuSj0a11UY4lzxd9
        authorization-grant-type: authorization_code
        scope: openid

网关本身只公开一个 HTTP 端点。它使用 OAuth2AuthorizedClient Bean 返回当前的 JWT Access Token。

@SpringBootApplication
@RestController
public class GatewayApplication {

   private static final Logger LOGGER = LoggerFactory
      .getLogger(GatewayApplication.class);

   public static void main(String[] args) {
      SpringApplication.run(GatewayApplication.class, args);
   }

   @GetMapping(value = "/token")
   public Mono<String> getHome(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) {
      return Mono.just(authorizedClient.getAccessToken().getTokenValue());
   }

}

如上就是该部分关于 OAuth2 配置的全部内容。还需要在 Spring application.yml 文件中配置网关的路由。Spring Cloud Gateway 可使用 TokenRelay GatewayFilter 将 OAuth2 Access Token 转发到其代理的下游服务。可以将其设置为所有传入请求的默认 Filter。网关会将流量转发给 callmecaller 服务。本例中,没有使用服务发现功能。默认情况下,callme 应用监听 8040 端口,而 caller 应用监听 8020 端口。

spring:
  application:
    name: gateway
  cloud:
    gateway:
      default-filters:
        - TokenRelay=
      routes:
        - id: callme-service
          uri: http://localhost:8040
          predicates:
            - Path=/callme/**
        - id: caller-service
          uri: http://localhost:8020
          predicates:
            - Path=/caller/**

使用 OAuth2 资源服务器验证微服务中的 Token {#使用-oauth2-资源服务器验证微服务中的-token}

callmecaller 的依赖非常相似。它们都使用 Spring Web 模块公开 HTTP 端点。由于 caller 应用使用 WebClient Bean,因此还需要加入 Spring WebFlux 依赖。同样,还需要加入 Spring OAuth2 Resource Server 模块和 spring-security-oauth2-jose 依赖项,以解码 JWT Token。

<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.security</groupId>
  <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-webflux</artifactId>
</dependency>

下面是应用 Security 的配置。这次需要使用 @EnableWebSecurity 注解,因为我们有一个 Spring Web 模块。oauth2ResourceServer() 方法通过 Keyclock JWK 端点验证 Access Token。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

下面是 Spring application.yml 文件中 Keycloak 的 OAuth2 资源服务器配置:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs

来看看 REST Controller 类的实现。它只有一个 ping 方法。该方法只能由具有 TEST scope 的客户端访问。它会返回一个从 Authentication Bean 获取的已分配 scope 列表。

@RestController
@RequestMapping("/callme")
public class CallmeController {

    @PreAuthorize("hasAuthority('SCOPE_TEST')")
    @GetMapping("/ping")
    public String ping() {
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        return "Scopes: " + authentication.getAuthorities();
    }
}

外部客户端可以通过 API 网关直接调用该方法。不过,caller 应用也可以在自己的 ping 端点实现中调用该端点。

@RestController
@RequestMapping("/caller")
public class CallerController {

    private WebClient webClient;

    public CallerController(WebClient webClient) {
        this.webClient = webClient;
    }

    @PreAuthorize("hasAuthority('SCOPE_TEST')")
    @GetMapping("/ping")
    public String ping() {
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();

        String scopes = webClient
                .get()
                .uri("http://localhost:8040/callme/ping")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        return "Callme scopes: " + scopes;
    }
}

如果 WebClient 调用第二个微服务暴露的端点,它还必须传播 Bearer Token。我们可以使用 ServletBearerExchangeFilterFunction 轻松实现这一功能,如下所示。有了这个 Function,Spring Security 将查找当前的 Authentication 并提取 AbstractOAuth2Token 凭证。然后,它会自动在 Authorization Header 中传播该 Token。

@SpringBootApplication
public class CallerApplication {

    public static void main(String[] args) {
        SpringApplication.run(CallerApplication.class, args);
    }

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
                .filter(new ServletBearerExchangeFilterFunction())
                .build();
    }
    
}

运行应并进行测试 {#运行应并进行测试}

使用相同的 Maven 命令运行所有三个 Spring Boot 应用。从 gateway 应用开始:

$ cd oauth/gateway
$ mvn spring-boot:run

运行第一个应用后,可以通过日志检查一切是否正常。下面是 gateway 应用生成的日志。如你所见,它监听 8060 端口。

gateway 应用日志

之后,运行 caller 应用。

$ cd oauth/caller
$ mvn spring-boot:run

它监听 8020 端口。

caller 应用日志

当然,应用的启动顺序并不重要。最后,来运行 callme 应用。

$ cd oauth/callme
$ mvn spring-boot:run

现在,通过网关调用 caller 应用端点。在这种情况下,我们需要访问 http://localhost:8060/caller/ping URL。网关应用会将我们重定向到 Keycloak 登录页面。我们需要使用 springSpring_123 登录。

keycloak 登录页面

登录后,一切都会自动发生。Spring Cloud Gateway 从 Keycloak 获取 Access Token,然后将其发送到下游服务。caller 应用收到请求后,会使用 WebClient 实例调用 callme 应用。结果如下

callme 应用的响应

我们可以使用 gateway 应用提供的端点 GET /token 轻松获取 Access Token。

Access Token

现在,可以使用 curl 命令执行与之前类似的调用。我们需要复制 Token 字符串,并将其作为 Bearer Token 放入 Authorization Header 中。

$ curl http://localhost:8060/callme/ping \
    -H "Authorization: Bearer <TOKEN>" -v

结果如下:

curl 使用 Token 发起请求

接下来,我们用 JUnit 和 Testcontainers 以完全自动化的方式来进行类似的操作。

Spring OAuth2 和 Keycloak Testcontainers {#spring-oauth2-和-keycloak-testcontainers}

再次切换到 gateway 模块。执行运行 API gateway 应用的测试,将其连接到 Keycloak 实例,并将授权流量路由到目标端点。下面是 src/test/java 目录中模拟 callme 应用端点的 @RestController

pl.piomin.samples.security.gateway.CallmeController

@RestController
@RequestMapping("/callme")
public class CallmeController {

    @PreAuthorize("hasAuthority('SCOPE_TEST')")
    @GetMapping("/ping")
    public String ping() {
        return "Hello!";
    }
}

下面是运行测试所需的配置。我们在 8060 端口上启动 gateway 应用,并使用 WebTestClient 实例来调用它。为了自动配置 Keycloak,导入存储在 realm-export.json 中的 demo realm 配置。由于 Testcontainers 使用随机端口号,我们需要覆盖一些 Spring OAuth2 配置设置。我们还将覆盖 Spring Cloud Gateway 路由,将流量转发到 callme 应用 Controller 的测试实现,而不是真实服务。

现在,就可以开始测试了。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class GatewayApplicationTests {

   static String accessToken;

   @Autowired
   WebTestClient webTestClient;

   @Container
   static KeycloakContainer keycloak = new KeycloakContainer()
            .withRealmImportFile("realm-export.json")
            .withExposedPorts(8080);

   @DynamicPropertySource
   static void registerResourceServerIssuerProperty(DynamicPropertyRegistry registry) {
      registry.add("spring.security.oauth2.client.provider.keycloak.issuer-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/demo");
      registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/demo/protocol/openid-connect/certs");
      registry.add("spring.cloud.gateway.routes[0].uri",
                () -> "http://localhost:8060");
      registry.add("spring.cloud.gateway.routes[0].id", () -> "callme-service");
      registry.add("spring.cloud.gateway.routes[0].predicates[0]", () -> "Path=/callme/**");
   }

   // 测试 。。。

}

这是第一个测试。由于它不包含任何 Token,因此会被应重定向到 Keycloak 的授权机制中。

@Test
@Order(1)
void shouldBeRedirectedToLoginPage() {
   webTestClient.get().uri("/callme/ping")
             .exchange()
             .expectStatus().is3xxRedirection();
}

在第二个测试中,使用 WebClient 实例与 Keycloak 容器交互。需要使用 spring 用户和 spring-with-test-scope 客户端对 Kecloak 进行身份认证。Keycloak 将生成并返回 Access Token。为下一次测试保存其值。

@Test
@Order(2)
void shouldObtainAccessToken() throws URISyntaxException {
   URI authorizationURI = new URIBuilder(keycloak.getAuthServerUrl() + "/realms/demo/protocol/openid-connect/token").build();
   WebClient webclient = WebClient.builder().build();
   MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
   formData.put("grant_type", Collections.singletonList("password"));
   formData.put("client_id", Collections.singletonList("spring-with-test-scope"));
   formData.put("username", Collections.singletonList("spring"));
   formData.put("password", Collections.singletonList("Spring_123"));

   String result = webclient.post()
                .uri(authorizationURI)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(String.class)
                .block();
   JacksonJsonParser jsonParser = new JacksonJsonParser();
   accessToken = jsonParser.parseMap(result)
                .get("access_token")
                .toString();
   assertNotNull(accessToken);
}

最后,运行与第一步类似的测试。不过,这次我们在 Authorization Header 中提供了一个 Access Token。预期响应是 200 OK 状态码和 "Hello!" 响应体,由 CallmeController Bean 的测试实例返回。

@Test
@Order(3)
void shouldReturnToken() {
   webTestClient.get().uri("/callme/ping")
                .header("Authorization", "Bearer " + accessToken)
                .exchange()
                .expectStatus().is2xxSuccessful()
                .expectBody(String.class).isEqualTo("Hello!");
}

在本地运行所有测试,结果如下。

测试结果,全部通过

如你所见,全部通过。

最后 {#最后}

相比于 上一篇文章,本文更关注自动化和服务间通信,而不仅仅是 Spring Cloud Gateway 中的 OAuth2 支持。我们考虑了网关同时充当 OAuth2 客户端和资源服务器的情况。最后,使用 Testcontainers 验证了 Spring Cloud Gateway 和 Keycloak 的应用场景。


Ref:https://piotrminkowski.com/2024/03/01/microservices-with-spring-cloud-gateway-oauth2-and-keycloak/

赞(1)
未经允许不得转载:工具盒子 » 使用 Keycloak 为 Spring Cloud Gateway 和 Spring Boot 微服务启用 OAuth2