1. 引言

Spring Cloud Gateway 是一个基于 Spring Boot 的轻量级 API 网关库,我们在之前的文章中已经详细介绍过。本文将重点演示如何在 Spring Cloud Gateway 上快速实现 OAuth 2.0 安全模式

2. OAuth 2.0 快速回顾

OAuth 2.0 是互联网广泛采用的安全标准,用于实现用户和应用的安全资源访问。虽然本文不会深入展开标准细节,但先快速回顾几个关键术语:

  • 资源:只有授权客户端才能访问的任何信息
  • 客户端:消费资源的应用,通常通过 REST API 实现
  • 资源服务器:负责向授权客户端提供资源的服务
  • 资源所有者:拥有资源的实体(人或应用),最终负责授权客户端访问
  • 令牌:客户端获取并在请求中发送给资源服务器用于认证的信息片段
  • **身份提供者(IdP)**:验证用户凭据并向客户端颁发访问令牌
  • 认证流程:客户端获取有效令牌必须执行的步骤序列

有关标准的完整描述,建议参考 Auth0 的官方文档

3. OAuth 2.0 模式

Spring Cloud Gateway 主要用于以下两种角色:

  • OAuth 客户端
  • OAuth 资源服务器

下面详细讨论这两种场景。

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

此场景下,任何未认证的请求都会触发授权码流程。网关获取令牌后,会在转发请求到后端服务时使用该令牌:

OAuth 2.0 授权码流程

典型应用场景是社交网络聚合器:网关为每个支持的社交网络扮演 OAuth 2.0 客户端角色。这样前端(通常是用 Angular/React 等框架构建的 SPA)就能代表用户无缝访问这些网络的数据。更重要的是:用户无需向聚合器暴露自己的凭据

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

网关在此充当守门人角色,强制要求每个请求必须携带有效访问令牌才能转发到后端服务。此外,它还能根据令牌关联的权限范围(scope)检查令牌是否有权访问特定资源:

Spring Gateway 资源服务器

需要注意,这种权限检查主要在粗粒度级别进行。细粒度访问控制(如对象/字段级权限)通常在后端通过领域逻辑实现。此模式中需考虑后端服务如何认证和授权转发请求,主要有两种方式:

  • 令牌传播:API 网关原样转发接收到的令牌
  • 令牌替换:API 网关在发送请求前替换传入令牌

本文只覆盖令牌传播场景,因为这是最常见的模式。令牌替换虽然可行,但需要额外配置和编码,会偏离本文核心目标。

4. 示例项目概述

为演示 Spring Gateway 如何实现上述 OAuth 模式,我们将构建一个暴露 /quotes/{symbol} 接口的示例项目。访问该接口需要身份提供者颁发的有效访问令牌

我们使用嵌入式 Keycloak 身份提供者。只需添加新客户端应用和测试用户即可。

为增加趣味性,后端服务会根据请求关联的用户返回不同报价:拥有 gold 角色的用户享受折扣价,其他用户支付常规价(生活本就不公平,^_^)。

我们将用 Spring Cloud Gateway 代理该服务,仅需修改几行配置,就能在 OAuth 客户端和资源服务器角色间切换。

5. 项目搭建

5.1. Keycloak 身份提供者

本教程使用的嵌入式 Keycloak 是个普通 SpringBoot 应用,可从 GitHub 克隆并用 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_silver_customers)以及两个角色(goldsilver)。

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

$ mvn spring-boot:run
... 省略大量日志
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)登录后进入领域管理界面:

Keycloak Baeldung 领域管理界面

最后添加两个用户:第一个是 golden_customer 组成员 Maxwell Smart;第二个是 John Snow(不加入任何组)。根据配置,golden_customers 组成员会自动获得 gold 角色

5.2. 后端服务

报价后端需要常规 Spring Boot Reactive MVC 依赖,加上资源服务器启动器依赖

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

注意:我们省略了依赖版本号,这是使用 SpringBoot 父 POM 或 BOM 时的推荐做法。

在主应用类添加 @EnableWebFluxSecurity 启用 WebFlux 安全:

@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 那样的标准声明。这需要自定义 ReactiveOpaqueTokenIntrospection Bean 从 Keycloak 返回的自定义字段中提取角色。该 Bean 基本与 Spring官方文档示例一致,仅针对自定义字段做了微调。

还需提供身份提供者的配置属性:

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=0e082231-a70d-48e8-b8a5-fbfb743041b6

最后通过 Maven 运行应用(项目 POM 包含专用 profile):

$ mvn spring-boot:run -Pquotes-application

应用将在 http://localhost:8085/quotes 提供服务。用 curl 验证响应:

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

意料之中:未发送 Authorization 头,收到 401 Unauthorized 响应

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

保护作为资源服务器的 Spring Cloud Gateway 应用 与普通资源服务器无异。因此需添加与后端服务相同的启动器依赖:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</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: 0e082231-a70d-48e8-b8a5-fbfb743041b6

路由配置与我们前一篇 Spring Cloud Gateway 文章相同:

... 其他属性省略
  cloud:
    gateway:
      routes:
      - id: quotes
        uri: http://localhost:8085
        predicates:
        - Path=/quotes/**

注意:除了安全依赖和属性,网关本身无需任何修改。使用专用 profile 启动网关:

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

6.1. 测试资源服务器

现在整合所有组件:确保 Keycloak、报价后端和网关都在运行。

首先需要从 Keycloak 获取访问令牌。最简单的方式是使用密码授权模式(Resource Owner 流程),向 Keycloak 发送 POST 请求,包含用户名/密码、客户端 ID 和密钥:

$ 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": "...省略...",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "...省略...",
    "token_type": "bearer",
    "not-before-policy": 0,
    "session_state": "7fd04839-fab1-46a7-a179-a2705dab8c6b",
    "scope": "profile email"
}

使用返回的访问令牌调用 /quotes 接口:

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

返回 JSON 格式的报价:

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

用 Maxwell Smart 的令牌重复测试:

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

价格更低,说明后端正确识别了用户角色。再用不带 Authorization 头的 curl 验证未认证请求:

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

检查网关日志会发现没有请求转发相关记录,证明响应由网关直接生成。

7. Spring Gateway 作为 OAuth 2.0 客户端

启动类复用资源服务器版本。以此强调所有安全行为都来自库和配置

两个版本的主要差异在于配置属性。这里需要通过 issuer-uri 或独立端点配置身份提供者详情(授权、令牌、内省端点)。

还需定义客户端注册详情,包括请求的范围(scope),这些范围告知 IdP 通过内省机制可提供哪些信息:

... 其他属性省略
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://localhost:8083/auth/realms/baeldung
        registration:
          quotes-client:
            provider: keycloak
            client-id: quotes-client
            client-secret: 0e082231-a70d-48e8-b8a5-fbfb743041b6
            scope:
            - email
            - profile
            - roles

路由配置的关键修改:为需要传播令牌的路由添加 TokenRelay 过滤器

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

若所有路由都需要触发认证流程,可将 TokenRelay 添加到 default-filters

spring:
  cloud:
    gateway:
      default-filters:
      - TokenRelay=
      routes:
... 其他路由省略

7.1. 测试 Spring Gateway 作为 OAuth 2.0 客户端

测试前确保三个组件都在运行。这次用不同 Spring Profile 启动网关,使其扮演 OAuth 2.0 客户端角色。项目 POM 包含专用 profile:

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

网关启动后,浏览器访问 http://localhost:8087/quotes/BAEL。若一切正常,会重定向到 IdP 登录页:

登录页

使用 Maxwell Smart 凭据登录后,获得折扣价报价:

Maxwell 的报价

最后用无痕窗口测试 John Snow 的凭据,获得常规报价:

Snow 的报价

8. 结论

本文探讨了 OAuth 2.0 的几种安全模式,以及如何用 Spring Cloud Gateway 实现它们。所有代码可在 GitHub 获取。


原始标题:Using Spring Cloud Gateway with OAuth 2.0 Patterns