Spring Cloud Gateway 和 Oauth2

1、概览

Spring Cloud Gateway 是一个响应式的轻量级网关,是 Spring Cloud 体系中一个比较重要的组件。本文将带你了解如何在其基础上快速实现 OAuth 2.0 认证、授权。

2、OAuth 2.0 快速回顾

OAuth 2.0 标准是一个成熟的标准,在互联网上广泛使用,是用户和应用安全访问资源的一种安全机制。

其中涉及的关键术语如下:

  • Resource(资源):只有经过授权的客户端才能检索的任何类型的信息。
  • Client(客户端):消费资源的应用,通常通过 REST API 消费资源。
  • Resource Server(资源服务器):负责向授权客户端提供资源的服务。
  • Resource Owner(资源所有者):实体(人或应用),拥有资源,并最终负责向客户端授予对该资源的访问权限。
  • Token(令牌):客户端获取的一段信息,并作为请求的一部分发送给资源服务器以进行身份验证。
  • Identity Provider(身份提供商,即 IDP):验证用户凭证并向客户端发放 Access Token。
  • Authentication Flow(认证模式/流程):客户端获得有效 Token 必须经过的一系列步骤。

你可以通过 Auth0 的相关文档了解更多详细内容。

3、OAuth 2.0 模式

Spring Cloud Gateway 主要用于以下用途之一:

  • OAuth Client(客户端)
  • OAuth Resource Server(资源服务器)

我们来逐个了解。

3.1、Spring Cloud Gateway 作为 OAuth 2.0 客户端

在这种情况下,任何未经身份认证的传入请求都将触发授权码流程。一旦网关获取到 Token,它将在向后端服务发起请求时使用该 Token。

OAuth 2.0 授权码模式

在实际应用中,一个很好的例子是聚合了 “社交应用” 的应用:对于每个支持的社交应用,网关将充当 OAuth 2.0 客户端。

因此,前端(通常是使用 Angular、React 或类似 UI 框架构建的 SPA 应用)可以代表终端户无缝访问这些应用上的数据。更重要的是:用户无需暴露自己的凭证。

3.2、Spring Cloud Gateway 作为 OAuth 2.0 资源服务器

在这里,网关充 “门卫” 的角色,在将每个请求发送到后端服务之前,都要确保该请求具有有效的 Access Token。此外,网关还能根据相关 Scope 检查 Token 是否具有访问特定资源的适当权限:

Spring Cloud Gateway 作为资源服务器

需要注意的是,这种权限检查主要是在粗粒度层面上进行的。细粒度访问控制(如对象/字段级权限)通常是在后端使用业务实现的。这种模式需要考虑的一个问题是,后端服务如何认证和授权任何转发请求。主要有两种情况:

  • Token 传播:API 网关将收到的 Token 原封不动地转发到后端。
  • Token 替换:在发送请求前,API Gateway 会用另一个 Token 替换收到的 Token。

本文只介绍 “Token 传播” 的示例,这是最常见的情况。

4、示例项目概览

构建一个 “报价” 的示例项目,展示如何使用 Spring Cloud Gateway 与上述的 OAuth 模式。

该项目暴露一个端点:/quotes/{symbol}。访问该端点需要一个由配置的 “Identity Provider” 签发的有效 Access Token。

在本例中,使用嵌入式 Keycloak Identity Provider,添加一个新的客户端应用和几个用户进行测试。

后台服务会根据与请求相关联的用户返回不同的报价。拥有黄金角色(Gold Role)的用户会得到较低的价格,而其他人则会得到正常价格。

使用 Spring Cloud Gateway 来作为这个服务的前端,并且只需更改几行配置,就能够将其角色从 OAuth 客户端切换为资源服务器。

5、项目设置

5.1、Keycloak IdP

本文中使用的嵌入式 Keycloak 只是一个普通的 Spring Boot 应用。

GitHub Clone 并使用 Maven 构建它:

$ git clone https://github.com/Baeldung/spring-security-oauth
$ cd oauth-rest/oauth-authorization/server
$ mvn install

注:本项目目前使用 Java 13+ 为构建版本,但在 Java 11 下也能正常构建和运行。只需在 Maven 命令中添加 -Djava.version=11 即可。

接下来,把 src/main/resources/baeldung-domain.json 替换为 这个版本。修改后的版本具有与原始版本相同的配置,并增加了一个客户端应用(quotes-client)、两个用户组(golden_customerssilver_customers)以及两个角色(goldensilver)。

现在,使用 spring-boot:run maven 插件启动服务器:

$ mvn spring-boot:run
... many, many log messages omitted
2022-01-16 10:23:20.318
  INFO 8108 --- [           main] c.baeldung.auth.AuthorizationServerApp   : Started AuthorizationServerApp in 23.815 seconds (JVM running for 24.488)
2022-01-16 10:23:20.334
  INFO 8108 --- [           main] c.baeldung.auth.AuthorizationServerApp   : Embedded Keycloak started: http://localhost:8083/auth to use keycloak

服务器启动后,使用浏览器访问 http://localhost:8083/auth/admin/master/console/#/realms/baeldung 。使用管理员凭证(bael-admin / pass)登录后,将看到 Realm 的管理界面:

Keycloak Realm 管理界面

加几个用户来完成 IdP 设置。第一个用户是 Maxwell Smart,他是 golden_customers 组的成员。第二个用户是 John Snow,不属于任何组。

使用提供的配置,golden_customers 组的成员将自动获得 gold 角色。

5.2、后端服务

后台需要常规的 Spring Boot Reactive MVC 依赖,外加 Resource Server Starter 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    <version>2.6.2</version>
</dependency>

当使用 Spring Boot 的 parent POM 或 dependency management 部分中的相应 BOM 时 可以省略依赖的版本号。这也是推荐的做法。

在 main 类,使用 @EnableWebFluxSecurity 启用 Web Flux Security:

@SpringBootApplication
@EnableWebFluxSecurity
public class QuotesApplication {    
    public static void main(String[] args) {
        SpringApplication.run(QuotesApplication.class);
    }
}

端点使用 BearerAuthenticationToken 来检查当前用户是否拥有 gold 角色:

@RestController
public class QuoteApi {
    private static final GrantedAuthority GOLD_CUSTOMER = new SimpleGrantedAuthority("gold");

    @GetMapping("/quotes/{symbol}")
    public Mono<Quote> getQuote(@PathVariable("symbol") String symbol,
      BearerTokenAuthentication auth ) {
        
        Quote q = new Quote();
        q.setSymbol(symbol);        
        if ( auth.getAuthorities().contains(GOLD_CUSTOMER)) {
            q.setPrice(10.0);
        }
        else {
            q.setPrice(12.0);
        }
        return Mono.just(q);
    }
}

现在,Spring 如何获取用户角色的呢?毕竟,这不是像 scopesemail 那样的标准 Claim。实际上,这里没有什么魔法:我们必须提供一个自定义的 ReactiveOpaqueTokenIntrospection,从 Keycloak 返回的自定义字段中提取这些角色。这个可在线获取的 Bean 基本上与 Spring 关于 这个主题的文档 中所示的相同,只是针对我们的自定义字段进行了一些细微的更改。

还必须提供访问 Identity Provider 所需的配置属性:

spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token/introspect
spring.security.oauth2.resourceserver.opaquetoken.client-id=quotes-client
spring.security.oauth2.resourceserver.opaquetoken.client-secret=<CLIENT SECRET>

最后,可以将其导入 IDE 或从 Maven 中运行。为此,项目的 POM 中包含一个 Profile:

$ mvn spring-boot:run -Pquotes-application

现在,应用在 http://localhost:8085/quotes 上为请求提供服务。可以使用 curl 检查它是否正常响应:

$ curl -v http://localhost:8085/quotes/BAEL

不出所料,收到了 “401 Unauthorized” 响应,因为没有发送 Authorization Header。

6、Spring Cloud Gateway 作为 OAuth 2.0 资源服务器

作为资源服务器的 Spring Cloud Gateway 应用的安全配置与普通资源服务并无不同。

因此,添加与后端服务相同的 Starter 依赖关系:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    <version>3.1.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    <version>2.6.2</version>
</dependency>

在启动类中添加 @EnableWebFluxSecurity

@SpringBootApplication
@EnableWebFluxSecurity
public class ResourceServerGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(ResourceServerGatewayApplication.class,args);
    }
}

与安全相关的配置属性与后台使用的相同:

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token/introspect
          client-id: quotes-client
          client-secret: <code class="language-css"><CLIENT SECRET>

接下来,只需添加路由声明即可:

  # ... 其他配置忽略
  cloud:
    gateway:
      routes:
      - id: quotes
        uri: http://localhost:8085
        predicates:
        - Path=/quotes/**

关于路由的详细用法,可以参阅 中文文档

注意,除了 Security 依赖和 properties 外,我们没有更改网关本身的任何内容。

使用 spring-boot:run 运行网关程序,并指定带有所需设置的特定 Profile:

$ mvn spring-boot:run -Pgateway-as-resource-server

6.1、测试资源服务器

首先,必须确保 Keycloak、后台服务和网关都在运行。

接下来,需要从 Keycloak 获取 Access Token。在这种情况下,最直接的方法就是使用 “密码授权模式”(又称 “资源所有者”)。这意味着向 Keycloak 发送 POST 请求,传递其中一个用户的用户名/密码,以及客户端 ID 和客户端应用的 Secret:

$ curl -L -X POST \
  'http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'client_id=quotes-client' \
  --data-urlencode 'client_secret=0e082231-a70d-48e8-b8a5-fbfb743041b6' \
  --data-urlencode 'grant_type=password' \
  --data-urlencode 'scope=email roles profile' \
  --data-urlencode 'username=john.snow' \
  --data-urlencode 'password=1234'

响应将是一个 JSON 对象,其中包含 Access Token 和其他值:

{
    "access_token": "...omitted",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "...omitted",
    "token_type": "bearer",
    "not-before-policy": 0,
    "session_state": "7fd04839-fab1-46a7-a179-a2705dab8c6b",
    "scope": "profile email"
}

现在,可以使用返回的 Access Token 访问 /quotes API:

$ curl --location --request GET 'http://localhost:8086/quotes/BAEL' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer xxxx...'

会响应一个 JSON 格式的 Quote

{
  "symbol":"BAEL",
  "price":12.0
}

重复这个过程,这次使用 Maxwell Smart 的 Access Token:

{
  "symbol":"BAEL",
  "price":10.0
}

如你所见,这次返回的 price 较低,这意味着后台能够正确识别相关用户。

还可以使用不带 Authorization Header 的 curl 请求,检查未经身份认证的请求是否会转发到后台:

$ curl  http://localhost:8086/quotes/BAEL

检查网关日志,可以发现没有与请求转发相关的信息。这表明响应是在网关生成的。

7. Spring Cloud Gateway 作为 OAuth 2.0 客户端

使用与资源服务器相同的启动类。

事实上,比较这两个版本,唯一明显的不同之处在于配置属性。在这里,需要使用 issuer-uri 属性或各种端点(authorization、token 和 introspection)的单独设置来配置 Identity Provider 的详细信息。

还需要定义应用客户端 registration 的详细信息,其中包括请求的 scope。这些 scope 会告知 IdP 哪些信息项将通过 introspection 机制提供:

  # 其他属性省略
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://localhost:8083/auth/realms/baeldung
        registration:
          quotes-client:
            provider: keycloak
            client-id: quotes-client
            client-secret: <CLIENT SECRET>
            scope:
            - email
            - profile
            - roles

最后,路由定义部分有一个重要变化。必须在任何需要传播 Access Token 的路由中添加 TokenRelay Filter:

spring:
  cloud:
    gateway:
      routes:
      - id: quotes
        uri: http://localhost:8085
        predicates:
        - Path=/quotes/**
        filters:
        - TokenRelay=

或者,如果我们希望所有路由都启动授权模式,可以在默认过滤器(default-filters)部分添加 TokenRelay Filter:

spring:
  cloud:
    gateway:
      default-filters:
      - TokenRelay=
      routes:
  # 省略其他的路由定义

7.1、测试作为 OAuth 2.0 客户端的 Spring Cloud Gateway

在测试设置中,需要确保项目的三个部分都在运行。不过,这次使用不同的 Spring Profile 来运行网关,该 Profile 包含使网关成为 OAuth 2.0 客户端所需的属性。

示例项目的 POM 中包含一个 Profile,可以使用该 Profile 启动项目:

$ mvn spring-boot:run -Pgateway-as-oauth-client

网关运行后,使用浏览器访问 http://localhost:8087/quotes/BAEL 进行测试。如果一切正常,你将被重定向到 IdP 的登录页面:

Keycloak 登录页

由于使用了 Maxwell Smart 的凭证,再次得到了一个更低的 price

服务响应的数据,price 更低

测试结束时,使用匿名/隐身浏览器窗口,并使用 John Snow 的凭证测试该端点。

这次得到的是正常的 price

服务响应的数据,price 正常

8、总结

本文介绍了 OAuth 2.0 的一些认证、授权模式以及如何使用 Spring Cloud Gateway 实现这些模式。


参考:https://www.baeldung.com/spring-cloud-gateway-oauth2