启用 Spring Cloud Gateway 的 OAuth2 支持,并将其与 Keycloak 集成

Spring Cloud Gateway 的 OAuth2 支持是微服务安全流程的关键部分。当然,使用 API 网关模式的主要原因是将服务隐藏起来,不对外部客户端可见。然而,在隐藏服务时,并没有对其进行安全保护。本文将带你了解如何使用 Spring Security 和 Keycloak 设置 Spring Cloud Gateway 的 OAuth2 功能。

源码

本文中的源码托管在 Github 上,你可以克隆 sample-spring-security-microservices 仓库,然后按照说明进行安装和部署即可。

在 Spring Cloud Gateway 中启用 OAuth2

要为 Spring Cloud Gateway 应用启用 OAuth2 支持,除了启用网关功能所必需的 spring-cloud-starter-gateway 依赖外,还需要添加 spring-boot-starter-oauth2-client 以启用 Spring Security 对 OAuth 2.0 授权框架和 OpenID Connect Core 1.0 的客户端支持,以及 spring-cloud-starter-security 来激活 TokenRelay Filter。

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-security</artifactId>
</dependency>

接下来为 OAuth2 客户端提供配置设置。因为要与 Keycloak 集成,所以应将 registrationId 的名称(spring.security.oauth2.client.provider.[registrationId])设置为 keycloak。然后,需要设置 tokenauthorizationuserinfo 端点的 uri。另一方面,可以为单个 issuer 端点设置一个值。该部分最后一个重要属性是 user-name-attribute。Keycloak 会将用户登录信息返回到 preferred_username 属性中。

我们要定义两个不同的授权客户端。其中第一个 spring-cloud-gateway 包含测试方法所允许的 scope,而第二个 spring-cloud-gateway-2 则不包含。

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            token-uri: http://localhost:8080/auth/realms/master/protocol/openid-connect/token
            authorization-uri: http://localhost:8080/auth/realms/master/protocol/openid-connect/auth
            userinfo-uri: http://localhost:8080/auth/realms/master/protocol/openid-connect/userinfo
            user-name-attribute: preferred_username
        registration:
          keycloak-with-test-scope:
            provider: keycloak
            client-id: spring-with-test-scope
            client-secret: c6480137-1526-4c3e-aed3-295aabcb7609
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
          keycloak-without-test-scope:
            provider: keycloak
            client-id: spring-without-test-scope
            client-secret: f6fc369d-49ce-4132-8282-5b5d413eba23
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"

最后一步,需要配置 Spring Security。由于 Spring Cloud Gateway 是在 Spring WebFlux 的基础上构建的,因此需要在 @Configuration Bean 上添加 @EnableWebFluxSecurity 驱动注解。在 springSecurityFilterChain 方法中,启用所有对于 exchange 的授权。还把 OAuth2 设置为默认登录方法,最后禁用 CSRF。

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

   @Bean
   public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
      http.authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
         .oauth2Login(withDefaults());
      http.csrf().disable();
      return http.build();
   }

}

运行并配置 Keycloak

我们在 Docker 容器上运行 Keycloak。默认情况下,Keycloak 在 8080 端口上公开 API 和 Web 控制台。不过,该端口号必须与 Spring Cloud Gateway 应用端口不同,因此用 8888 来覆盖它。还需要为管理控制台设置用户名和密码。

$ docker run -d --name keycloak -p 8888:8080 \
   -e KEYCLOAK_USER=spring \
   -e KEYCLOAK_PASSWORD=spring123 \
   jboss/keycloak

然后,需要创建两个名称与网关配置中定义的名称相同的客户端。这两个客户端的 “Access Type” 部分都必须包含 confidential,并设置有效的重定向 URI。在设置重定向地址时,可以使用简单的通配符,如下图所示。

Keycloak 控制台

客户端 spring-with-test-scope 分配了 TEST scope。与此相反,第二个客户端 spring-without-test-scope 未分配 TEST scope。

Keycloak 设置客户端 Scope

在 Spring Cloud Gateway 中启用 OAuth2 资源

现在,可以开始实现下游应用。要运行它,需要在源代码中切换到 callme 目录。首先,需要包含一些 Maven 依赖。spring-boot-starter-web starter 为 Spring Boot 应用提供了 Web 支持。通过 spring-boot-starter-security,可以为微服务启用 Spring Security。spring-security-oauth2-resource-server 包含 Spring Security 对 OAuth 2.0 资源服务器的支持。它还用于通过 OAuth 2.0 Bearer Token 保护 API。最后,spring-security-oauth2-jose 模块包含 Spring Security 对 JOSE(Javascript Object Signing and Encryption)框架的支持。JOSE 框架提供了一种安全地在各方之间传输 claim 的方法。它支持 JWT 和 JWS(JSON Web Signature)。

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

下一步,需要配置与授权服务器的连接。资源服务器使用 spring.security.oauth2.resourceserver.jwt.issuer-uri 属性发现授权服务器公钥(Public Key),然后验证传入的 JWT Token。

spring:
  application:
    name: callme
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/auth/realms/master

还要提供 Spring Security 配置。首先,需要用 @EnableWebSecurity@Configuration Bean 进行注解。然后,为 Controller 方法启用基于注解的安全配置。它允许使用 @PreAuthorize@PostAuthorize 进行简单的基于角色的访问孔控制。要启用 Method Security 功能,需要使用 @EnableGlobalMethodSecurity 注解。最后,只需配置 Spring Security 以授权所有传入请求并验证 JWT Token。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

   protected void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
   }
}

最后,来看看 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();
   }
}

Spring Cloud Gateway 配置路由

进行测试前的最后一步是在 Spring Cloud Gateway 应用上配置路由。由于下游服务(callme)运行于 8040 端口,我们需要将 uri 设置为 http://127.0.0.1:8040。为了将 Access Token 转发到 callme 服务,必须启用全局过滤器 TokenRelay。为确保一切按预期运行,要删除包含 session ID 的 Cookie。session ID 是在执行 OAuth2Login 后在网关上生成的。

spring:
  application:
    name: gateway
  cloud:
    gateway:
      default-filters:
        - TokenRelay
      routes:
        - id: callme-service
          uri: http://127.0.0.1:8040
          predicates:
            - Path=/callme/**
          filters:
            - RemoveRequestHeader=Cookie

最后,来看看网关 main 类。在这里添加了两个有用的端点。第一个端点 GET / 返回 HTTP session ID。第二个端点 GET /token 返回当前的 JWT Access Token。在 Spring Cloud Gateway OAuth2 登录成功后,你可以看到 index 方法返回的结果。

@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());
   }

   @GetMapping("/")
   public Mono<String> index(WebSession session) {
      return Mono.just(session.getId());
   }

}

Spring Cloud Gateway OAuth2 测试

我们在网关上调用 POST /login 端点(1)。在接收到登录请求后,Spring Cloud Gateway 尝试从授权服务器获取 Access Token(2)。然后,Keycloak 返回 JWT Access Token。因此,Spring Cloud Gateway 调用 userinfo 端点(3)。在收到响应后,它创建一个 Web Session 和 Authentication Bean。最后,网关将 Session ID 返回给外部客户端(4)。外部客户端使用带有 Session ID 的 Cookie 来进行请求授权。它调用 callme 应用的 GET ping(5)。网关应用将请求转发给下游服务(6)。但是,它会移除 Cookie 并替换为 JWT Access Token。callme 应用验证传入的 Token(7)。最后,如果客户端被允许调用端点,它返回 200 OK 响应(8)。否则,返回 403 Forbidden

流程图如下:

Spring Cloud Gateway OAuth2 测试流程图

我们可以在 Web 浏览器中进行测试。首先,调用 login 端点。我们需要两个客户端:keycloak-with-test-scopekeycloak-without-test-scope。我们将使用keycloak-with-test-scope 客户端。

keycloak 客户端

然后,网关会将我们重定向到 Keycloak 登录页面。我们可以使用创建 Keycloak 容器时提供的凭证进行登录。

Keycloak 登录页面

登录成功后,网关将执行 OAuth2 授权流程。最后,它会将我们重定向到主页。主页只是 Controller 中的一个方法 index,它返回当前 Session ID。

Session ID

我们还可以使用网关上实现的另一个端点 - GET /token,它返回当前的 JWT Access Token。

$ curl http://localhost:8080/token -H "Cookie: SESSION=9bf852f1-6e00-42f8-a9a2-3cbdced33993"
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0RWpwdkVtQ1ZDZ1VDUm41Y2NJeXRiank0RnR0RXpBRXVrMURoZDRTT0RFIn0.eyJleHAiOjE2MDIyMzM5MTksImlhdCI6MTYwMjIz
MzAxOSwiYXV0aF90aW1lIjoxNjAyMjMzMDE5LCJqdGkiOiIyYWQzYjczNy1mZTdhLTQ3NGUtODhhYy01MGZjYzEzOTlhYTQiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMv
bWFzdGVyIiwiYXVkIjpbIm1hc3Rlci1yZWFsbSIsImFjY291bnQiXSwic3ViIjoiOWVhMDAyYmQtOTQ4Ni00Njk0LWFhYzUtN2IyY2QwNzc2MTZiIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoic3ByaW5n
LWNsb3VkLWdhdGV3YXkiLCJzZXNzaW9uX3N0YXRlIjoiMDRhNzQ4YzUtOTA1My00ZmZmLWJjYzctNWY5MThjMzYwZGE4IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjcmVhdGUt
cmVhbG0iLCJST0xFX1RFTExFUiIsIm9mZmxpbmVfYWNjZXNzIiwiYWRtaW4iLCJURUxMRVIiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7Im1hc3Rlci1yZWFsbSI6eyJy
b2xlcyI6WyJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsInZpZXctcmVhbG0iLCJtYW5hZ2UtaWRlbnRpdHktcHJvdmlkZXJzIiwiaW1wZXJzb25hdGlvbiIsImNyZWF0ZS1jbGllbnQiLCJtYW5hZ2Ut
dXNlcnMiLCJxdWVyeS1yZWFsbXMiLCJ2aWV3LWF1dGhvcml6YXRpb24iLCJxdWVyeS1jbGllbnRzIiwicXVlcnktdXNlcnMiLCJtYW5hZ2UtZXZlbnRzIiwibWFuYWdlLXJlYWxtIiwidmlldy1ldmVu
dHMiLCJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwibWFuYWdlLWF1dGhvcml6YXRpb24iLCJtYW5hZ2UtY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5h
Z2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJwcm9maWxlIGVtYWlsIFRFU1QiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJl
ZF91c2VybmFtZSI6InBpb21pbiJ9.X8XfIHiAiR1YMjiJza75aC294qLwi83RrUU2phorM7FP4phq3h-lx80Zu5xqTrMqMC1-RbHBnX-oUTbs4ViS3DziZlDvoRajdkrh6UTiK5oWgoRW-4qsH5L4X1W
bRfoBZgyHFRSnhaCO4CLgjCyEgeLUR5A-JWY-OMYQIOAxxHB2GwE3MNFfLWeqpmS1AWU8fL0giFFXFDfa1_XZEKgnqe1S75Ps_z8B1sfNfvNpz8daJ8omzXrt6I6TSa0FE3iiZ7Qx18mtkbx-iPuFqDD
RT6DGU-Hing9LnGuOt3Yas-WYdN7PKBigvIZv0LyvRFcilRJQBjOdVfEddL3OQ0rmEg

你可以在 https://jwt.io 网站上解码 JWT Token 来进行验证。

JWT Token 解码

最后,调用 callme 应用暴露的端点。在请求头中设置 Session Cookie。端点将返回分配给当前用户的 scope 列表。只有 scope 为 TEST 的用户才能调用该方法。

$ curl http://localhost:8080/callme/ping -H "Cookie: SESSION=9bf852f1-6e00-42f8-a9a2-3cbdced33993"
Scopes: [SCOPE_profile, SCOPE_email, SCOPE_TEST]

总结

本文介绍了如何启用 Spring Cloud Gateway 的 OAuth2 支持,并将其与Keycloak集成。还实现了 OAuth2 登录、令牌中继和 OAuth2 资源服务器等机制。令牌中继机制完全从 Spring Cloud Security 迁移到 Spring Cloud Gateway。


Ref:https://piotrminkowski.com/2020/10/09/spring-cloud-gateway-oauth2-with-keycloak/