知識庫 / Spring / Spring Cloud RSS 訂閱

Spring Cloud Gateway 與 OAuth2 後端集成

Spring Cloud,Spring Security
HongKong
5
11:16 AM · Dec 06 ,2025

1. 概述

在本教程中,我們將使用 Spring Cloud Gateway 和 spring-addons 實現前端(BFF)模式中的 OAuth2 後端,以從三個不同的單頁應用程序(Angular、React 和 Vue)中消費無狀態 REST API。

使用調試工具進行檢查時,我們不會在任何使用 OAuth2 的知名網站上找到 Bearer 令牌(Google、Facebook、Github 或 LinkedIn)。原因是什麼呢?

根據安全專家 的觀點,即使使用 PKCE,也不應將在用户設備上運行的應用程序配置為“公共” OAuth2 客户端。推薦的替代方案是使用我們信任的 BFF(後端前端)對移動應用程序和 Web 應用程序進行會話授權

我們將在此瞭解單頁應用程序(SPA)如何通過 OAuth2 BFF 消費 REST API 的便捷性。我們還將學習現有的資源服務器(使用 Bearer 訪問令牌進行授權的無狀態 REST API)不需要進行任何修改。

2. 前端 BFF 模式中的 OAuth2 後端

在深入瞭解實現細節之前,讓我們先探討一下 BFF(後端前端接口)是什麼,它能帶來什麼,以及它所帶來的代價。

2.1. 定義

Backend for Frontend (BFF) 是一個介於前端和 REST API 之間的中間件,可以具有不同的用途。在這裏,我們關注的是 OAuth2 BFF,它 通過使用會話 Cookie(與前端一起)進行請求授權,以及使用 Bearer 令牌(由資源服務器期望的方式)進行授權,從而實現橋接。它的職責包括:

  • 使用“可信” OAuth2 客户端驅動授權碼和刷新令牌流程
  • 維護會話並將其中的令牌存儲在會話中
  • 在將請求從前端轉發到資源服務器之前,用訪問令牌替換會話 Cookie

2.2 公共 OAuth2 客户端的優勢

主要增值在於安全性:

  • 使用 BFF 在我們信任的服務器上運行,授權服務器的令牌端點可以採用密鑰和防火牆規則進行保護,僅允許來自我們後端請求訪問。 這極大地降低了令牌被頒發給惡意客户端的風險。
  • 令牌存儲在服務器端(在會話中),從而防止惡意程序在用户設備上竊取令牌。 使用會話 Cookie 需要防止 CSRF 攻擊,但可以使用 HttpOnlySecureSameSite 標記,在這種情況下,瀏覽器本身會強制執行設備上的 Cookie 保護。 與將 SPA 配置為公共客户端的情況相比,我們必須非常小心地處理這些令牌的存儲:如果惡意程序能夠讀取訪問令牌,後果將對用户產生災難性影響。 在刷新令牌的情況下,身份冒用可能會持續很長時間。

另一個優勢是完全控制用户會話以及立即撤銷訪問權限。 請記住,JSON Web Tokens (JWT) 無法被無效化,並且當我們終止服務器端會話時,很難刪除存儲在用户設備上的令牌。 如果我們將 JWT 訪問令牌通過網絡發送,我們只能等待其過期,直到那時,對資源服務器的訪問仍然會被授權。 但是,如果令牌從未離開後端,那麼我們就可以在 BFF 中與用户會話一起刪除它們,從而立即撤銷對資源的訪問。

2.3 成本

後端服務代理(BFF)是系統中的一個附加層,並且位於關鍵路徑上。在生產環境中,這會帶來額外的資源消耗和一定的延遲。同時,需要進行監控。

此外,BFF背後使用的資源服務器應該無狀態,但BFF本身需要會話管理,這需要採取特定措施以實現可擴展性和容錯性。

可以將 Spring Cloud Gateway 輕鬆打包成原生鏡像,這使得它在幾分之一秒內變得超輕量級且可啓動,但單個實例能夠吸收的流量總是有上限的。 當流量增加時,我們需要在BFF實例之間共享會話。 Spring Session 項目對此將大有裨益。 另一種選擇是使用智能代理將來自同一設備的全部請求路由到同一個BFF實例。

2.4. 選擇實現方案

某些框架在不明確提及或調用 OAuth2 BFF 模式的情況下,也實現了該模式。例如,NextAuth 庫使用服務器組件來實現 OAuth2(在服務器端的 Node 實例中作為保密客户端)。 即使這樣也能從 OAuth2 BFF 模式的安全優勢中受益。

但是,由於 Spring 生態系統的存在,Spring Cloud Gateway 這樣的解決方案在監控、可伸縮性和容錯性方面非常實用:

  • spring-boot-starter-actuator 依賴項提供強大的審計功能。
  • Spring Session 是一種簡單的分佈式會話解決方案。
  • spring-boot-starter-oauth2-clientoauth2Login() 處理授權碼和刷新令牌流程。 它們還將令牌存儲在會話中。
  • TokenRelay= 過濾器在將前端請求轉發到資源服務器時,用訪問令牌替換會話 Cookie。

3. 架構

我們已經列出了相當多的服務:前端(SPA)、REST API、BFF 和授權服務器。下面我們來探討這些如何構成一個連貫的系統。

3.1 系統概述

以下是對我們使用主配置文件時所使用的服務、端口和路徑前綴的表示:

從該模式中可以看出以下兩點:

  • 從終端設備的角度來看,BFF 和 SPA 資產的單點接觸是反向代理。
  • 資源服務器通過 BFF 訪問。

正如稍後會看到的,通過反向代理提供授權服務器是可選的。

在生產環境中,可以使用(子)域名而不是路徑前綴來區分 SPA。

3.2. 快速入門

配套倉庫包含用於構建和啓動上述所有服務的 Docker 鏡像的構建腳本。

為了確保一切順利運行,我們應該確保:

  • JDK 版本在 17 到 21 之間已添加到 PATH 環境變量中。可以通過運行 java –version 來檢查此設置。
  • Docker Desktop 已安裝並正在運行。
  • 最新版本的 LTS Node 已添加到 PATH 環境變量中 (nvmnvm-windows 可以對此有所幫助)。

然後,您可以運行以下 shell 腳本(在 Windows 上,您可能需要使用 Git Bash):

git clone https://github.com/eugenp/tutorials.git
cd tutorials/spring-security-modules/spring-security-oauth2-bff/
sh ./build.sh

在接下來的部分,我們將看到如何用手邊的東西替換每個容器。

4. 使用 Spring Cloud Gateway 和 spring-addons-starter-oidc 實現 BFF

首先,使用我們的 IDE 或 https://start.spring.io/,創建一個名為 bff 的 Spring Boot 項目,並將 Reactive GatewayOAuth2 client 添加為依賴。

然後,我們將 src/main/resources/application.properties 重命名為 src/main/resources/application.yml

最後,我們將 spring-addons-starter-oidc 添加到我們的依賴中:

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.7.0</version>
</dependency>

4.1. 重新使用的屬性

讓我們從 <a href="https://github.com/eugenp/tutorials/tree/master/spring-security-modules/spring-security-oauth2-bff/backend/bff/src/main/resources/application.yml"><em title="application.yml">application.yml</em></a> 中的一些常量開始,這些常量將幫助我們在其他部分以及在命令行或 IDE 啓動配置中覆蓋某些值時。

scheme: http
hostname: localhost
reverse-proxy-port: 7080
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
authorization-server-prefix: /auth
issuer: ${reverse-proxy-uri}${authorization-server-prefix}/realms/baeldung
client-id: baeldung-confidential
client-secret: secret
username-claim-json-path: $.preferred_username
authorities-json-path: $.realm_access.roles
bff-port: 7081
bff-prefix: /bff
resource-server-port: 7084
audience: 

當然,我們必須通過使用環境變量、命令行參數或 IDE 啓動配置等方式來覆蓋 client-secret 的值。

4.2. 服務器屬性

現在我們將討論常見的服務器屬性:

server:
  port: ${bff-port}

4.3. Spring Cloud Gateway 路由

由於我們只有一個資源服務器位於網關後方,因此我們只需要一個路由定義:

spring:
  cloud:
    gateway:
      routes:
      - id: bff
        uri: ${scheme}://${hostname}:${resource-server-port}
        predicates:
        - Path=/api/**
        filters:
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
        - TokenRelay=
        - SaveSession
        - StripPrefix=1

最關鍵的部分是SaveSessionTokenRelay=,它們構成了 OAuth2 BFF 模式實現的基石。SaveSession 確保會話得以持久化,通過 oauth2Login() 檢索令牌,而 TokenRelay= 則在路由請求時,將會話 Cookie 替換為訪問令牌。

StripPrefix=1 過濾器會從請求路徑中移除 /api 前綴。值得注意的是,/bff 前綴已經在反向代理路由過程中被移除。因此,從前端發送到 /bff/api/me 的請求,在資源服務器上會解析為 /me

4.4. Spring Security

我們可以現在開始配置 OAuth2 客户端安全,使用標準的 Boot 屬性:

spring:
  security:
    oauth2:
      client:
        provider:
          baeldung:
            issuer-uri: ${issuer}
        registration:
          baeldung:
            provider: baeldung
            authorization-grant-type: authorization_code
            client-id: ${client-id}
            client-secret: ${client-secret}
            scope: openid,profile,email,offline_access

這裏沒有什麼特別之處,只是一個標準的 OpenID 身份提供者聲明,使用授權碼和刷新令牌進行單次註冊。

4.5. spring-addons-starter-oidc

為了完成配置,讓我們使用 spring-addons-starter-oidc 來調整 Spring Security。

com:
  c4-soft:
    springaddons:
      oidc:
        # Trusted OpenID Providers configuration (with authorities mapping)
        ops:
        - iss: ${issuer}
          authorities:
          - path: ${authorities-json-path}
          aud: ${audience}
        # SecurityFilterChain with oauth2Login() (sessions and CSRF protection enabled)
        client:
          client-uri: ${reverse-proxy-uri}${bff-prefix}
          security-matchers:
          - /api/**
          - /login/**
          - /oauth2/**
          - /logout
          permit-all:
          - /api/**
          - /login/**
          - /oauth2/**
          csrf: cookie-accessible-from-js
          oauth2-redirections:
            rp-initiated-logout: ACCEPTED
        # SecurityFilterChain with oauth2ResourceServer() (sessions and CSRF protection disabled)
        resourceserver:
          permit-all:
          - /login-options
          - /error
          - /actuator/health/readiness
          - /actuator/health/liveness

讓我們瞭解三個主要部分:

  • ops,帶有 OpenID Provider(s) 值的配置:這允許我們指定將轉換為 Spring 權限的聲明的 JSON 路徑(可包含可選的前綴和大小寫轉換)。如果 aud 屬性不為空,spring-addons 會為 JWT 解碼器添加一個受眾驗證器。
  • client:當 security-matchers 不為空時,此部分會觸發創建帶有 oauth2Login()SecurityFilterChain 豆。在此處,使用 client-uri 屬性,我們強制使用反向代理 URI 作為所有重定向的基礎(而不是 BFF 的內部 URI)。 此外,由於我們使用 SPA,我們要求 BFF 在 JavaScript 中可訪問的 Cookie 中暴露 CSRF 令牌。 最後,為了防止 CORS 錯誤,我們要求 BFF 以 201 狀態(而不是 3xx)響應 RP-Initiated Logout,從而使 SPA 能夠攔截此響應並要求瀏覽器以具有新源的新請求進行處理。
  • resourceserver:這請求一個第二個帶有 oauth2ResourceServer()SecurityFilterChain 豆。 此過濾器鏈具有最低優先級,將處理客户端 SecurityFilterChain 中未匹配的請求。 我們將其用於不希望使用會話的所有資源:不涉及登錄或使用 TokenRelay 進行路由的端點。

4.6. /login-options 端點

BFF 是我們定義登錄配置的地方:Spring OAuth2 客户端註冊(們)以及授權碼。為了避免在每個 SPA(中)出現配置重複以及可能的不一致性,我們將在一個 BFF 上託管一個 REST 端點,該端點公開它支持的登錄選項,供用户使用。

為此,我們只需要公開一個 <em @RestController,該端點返回一個從配置屬性構建的負載:

@RestController
public class LoginOptionsController {
    private final List<LoginOptionDto> loginOptions;

    public LoginOptionsController(OAuth2ClientProperties clientProps, SpringAddonsOidcProperties addonsProperties) {
        final var clientAuthority = addonsProperties.getClient()
          .getClientUri()
          .getAuthority();
        this.loginOptions = clientProps.getRegistration()
          .entrySet()
          .stream()
          .filter(e -> "authorization_code".equals(e.getValue().getAuthorizationGrantType()))
          .map(e -> {
              final var label = e.getValue().getProvider();
              final var loginUri = "%s/oauth2/authorization/%s".formatted(addonsProperties.getClient().getClientUri(), e.getKey());
              final var providerId = clientProps.getRegistration()
                .get(e.getKey())
                .getProvider();
              final var providerIssuerAuthority = URI.create(clientProps.getProvider()
                .get(providerId)
                .getIssuerUri())
                .getAuthority();
              return new LoginOptionDto(label, loginUri, Objects.equals(clientAuthority, providerIssuerAuthority));
          })
          .toList();
    }

    @GetMapping(path = "/login-options", produces = MediaType.APPLICATION_JSON_VALUE)
    public Mono<List<LoginOptionDto>> getLoginOptions() throws URISyntaxException {
        return Mono.just(this.loginOptions);
    }

    public static record LoginOptionDto(@NotEmpty String label, @NotEmpty String loginUri, boolean isSameAuthority) {
    }

}

我們現在可以停止 baeldung-bff.bff 容器並運行 BFF 應用程序,在命令行或運行配置中仔細提供:

  • hostnamehostname 命令或 HOSTNAME 環境變量的值,轉換為小寫
  • client-secretbaeldung-confidential 客户端中聲明的秘密值(除非明確更改,否則為 “secret”

4.7. 非標準 RP-發起註銷

RP-發起註銷是OpenID標準的一部分,但一些提供商並未嚴格遵循該標準。例如,Auth0和Amazon Cognito,它們在OpenID配置中未提供end_session端點,而是使用自定義的查詢參數進行註銷。

spring-addons-starter-oidc 支持這些“接近”符合標準的註銷端點。伴侶項目中的BFF配置包含具有所需配置的配置文件:

---
spring:
  config:
    activate:
      on-profile: cognito
issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl
client-id: 12olioff63qklfe9nio746es9f
client-secret: change-me
username-claim-json-path: username
authorities-json-path: $.cognito:groups
com:
  c4-soft:
    springaddons:
      oidc:
        client:
          oauth2-logout:
            baeldung:
              uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout
              client-id-request-param: client_id
              post-logout-uri-request-param: logout_uri

---
spring:
  config:
    activate:
      on-profile: auth0
issuer: https://dev-ch4mpy.eu.auth0.com/
client-id: yWgZDRJLAksXta8BoudYfkF5kus2zv2Q
client-secret: change-me
username-claim-json-path: $['https://c4-soft.com/user']['name']
authorities-json-path: $['https://c4-soft.com/user']['roles']
audience: bff.baeldung.com
com:
  c4-soft:
    springaddons:
      oidc:
        client:
          authorization-params:
            baeldung:
              audience: ${audience}
          oauth2-logout:
            baeldung:
              uri: ${issuer}v2/logout
              client-id-request-param: client_id
              post-logout-uri-request-param: returnTo

在上述片段中,baeldung 指的是 Spring Boot 屬性中的客户端註冊。如果我們使用了不同的鍵在 spring.security.oauth2.client.registration 中,我們也必須在這裏使用它。

除了必需的屬性覆蓋之外,我們還可以注意到在第二個配置文件中,關於向 Auth0 發送授權請求時,附加請求參數 audience 的規範。

5. 使用 spring-addons-starter-oidc 的資源服務器

我們的需求很簡單:一個無狀態的 REST API,使用 JWT 訪問令牌進行授權,並暴露一個端點來反映令牌(或包含空值的負載)中包含的一些用户信息。

為此,我們將創建一個名為 resource-server 的新 Spring Boot 項目,並使用 Spring WebOAuth2 資源服務器 作為依賴項。

然後,我們將 src/main/resources/application.properties 重命名為 src/main/resources/application.yml

最後,我們將 spring-addons-starter-oidc添加到我們的依賴項中:

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.7.0</version>
</dependency>

5.1. 配置

以下是資源服務器所需的屬性:

scheme: http
hostname: localhost
reverse-proxy-port: 7080
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
authorization-server-prefix: /auth
issuer: ${reverse-proxy-uri}${authorization-server-prefix}/realms/baeldung
username-claim-json-path: $.preferred_username
authorities-json-path: $.realm_access.roles
resource-server-port: 7084
audience: 

server:
  port: ${resource-server-port}

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${issuer}
          username-claim: ${username-claim-json-path}
          authorities:
          - path: ${authorities-json-path}
          aud: ${audience}
        resourceserver:
          permit-all:
          - /me

感謝 spring-addons-starter-oidc,這足以聲明一個無狀態資源服務器,包括:

  • 從我們選擇的聲明中映射權限(例如,在 Keycloak 中使用 realm_access.roles 映射 Realm 角色)
  • 使 /me 對匿名請求可訪問

配套倉庫中的 application.yaml 包含其他 OpenID 提供程序使用的其他私有聲明的配置文件,這些配置文件用於角色。

5.2. <em @RestController

讓我們實現一個返回一些數據,並從 <em @RestController 中獲取安全上下文中(如果存在)的認證數據 REST 端點:

@RestController
public class MeController {

    @GetMapping("/me")
    public UserInfoDto getMe(Authentication auth) {
        if (auth instanceof JwtAuthenticationToken jwtAuth) {
            final var email = (String) jwtAuth.getTokenAttributes()
                .getOrDefault(StandardClaimNames.EMAIL, "");
            final var roles = auth.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .toList();
            final var exp = Optional.ofNullable(jwtAuth.getTokenAttributes()
                .get(JwtClaimNames.EXP)).map(expClaim -> {
                    if(expClaim instanceof Long lexp) {
                        return lexp;
                    }
                    if(expClaim instanceof Instant iexp) {
                        return iexp.getEpochSecond();
                    }
                    if(expClaim instanceof Date dexp) {
                        return dexp.toInstant().getEpochSecond();
                    }
                    return Long.MAX_VALUE;
                }).orElse(Long.MAX_VALUE);
            return new UserInfoDto(auth.getName(), email, roles, exp);
        }
        return UserInfoDto.ANONYMOUS;
    }

    /**
     * @param username a unique identifier for the resource owner in the token (sub claim by default)
     * @param email OpenID email claim
     * @param roles Spring authorities resolved for the authentication in the security context
     * @param exp seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time when the access token expires
     */
    public static record UserInfoDto(String username, String email, List<String> roles, Long exp) {
        public static final UserInfoDto ANONYMOUS = new UserInfoDto("", "", List.of(), Long.MAX_VALUE);
    }
}

正如我們為 BFF 所做的那樣,現在我們也可以停止 baeldung-bff.resource-server 容器,通過命令行或配置運行中提供 hostname

5.3. 資源服務器多租户

如果消費我們 REST API 的前端應用不都使用同一個授權服務器或領域進行用户授權,或者它們提供了授權服務器的選擇,情況會怎樣?

使用 spring-security-starter-oidc,這非常簡單:com.c4-soft.springaddons.oidc.ops 配置屬性是一個數組,我們可以添加我們信任的所有頒發者(issuers),每個頒發者都具有用户名和權限的映射。 任何由這些頒發者頒發的有效令牌都會被我們的資源服務器接受,並且角色會正確地映射到 Spring 權限。

6. 單頁面應用 (SPAs)

由於使用連接 SPAs 到 OAuth2 BFF 的框架存在差異,我們將重點介紹以下三個主要框架:AngularReact,和 Vue

但是,創建 SPAs 超出了本文的範圍。 之後,我們將僅關注在 OAuth2 BFF 上登錄和註銷用户,以及查詢其背後 REST API 所需的內容。 請參閲配套倉庫以獲取完整實現。

為了使應用程序具有相同的結構,已做了一些努力:

  • 兩個路由用於演示當前路由在身份驗證後如何恢復。
  • 一個 Login 組件提供在 iframe 和默認選項均可用時選擇登錄體驗的選擇。 它還處理 iframe 顯示狀態或重定向到授權服務器。
  • 一個 Logout 組件向 BFF 的 /logout 端點發送 POST 請求,然後重定向到授權服務器以進行 RP-Initiated Logout。
  • 一個 UserService 通過 BFF 從資源服務器檢索當前用户數據。 它還包含一些邏輯,用於在 BFF 上的訪問令牌到期之前安排刷新該數據。

但是,由於框架處理狀態的方式非常不同,因此當前用户數據管理存在差異:

  • 在 Angular 應用程序中,UserService 是一個單例,使用 BehaviorSubject 管理當前用户。
  • 在 React 應用程序中,我們在 app/layout.tsx 中使用 createContext 來將當前用户暴露給所有組件,並在需要訪問它時使用 useContext
  • 在 Vue 應用程序中,UserService 是一個單例(在 main.ts 中實例化),使用 ref 管理當前用户。

6.1. 在 Companion 倉庫中運行 SPAs

首先,需要使用 cd 命令進入我們想要運行的項目文件夾。

然後,應該運行 "npm install" 命令來拉取所有必需的 npm 包。

最後,在停止了相應的 Docker 容器之後,根據框架,執行以下操作:

  • Angular:運行 `"npm run start"` 並打開 http://{hostname}:7080/angular-ui/
  • Vue:運行 `"npm run dev"` 並打開 http://{hostname}:7080/vue-ui/
  • React (Next.js):運行 `"npm run dev"` 並打開 http://{hostname}:7080/react-ui/

請務必僅使用指向反向代理的 URL,而不是 SPAs 的開發服務器 (http://{hostname}:7080,而不是 http://{hostname}:4201, http://{hostname}:4202http://{hostname}:4203)。

6.2. 用户服務

用户服務的職責是:

  • 定義用户表示(內部和 DTO)。
  • 通過 BFF 向資源服務器檢索用户數據。
  • 在訪問令牌過期前立即安排一次 refresh() 調用(保持會話存活)。

6.3. 登錄

正如我們已經看到的那樣,在可能的情況下,我們提供兩種不同的登錄體驗:

  • 用户將通過當前瀏覽器標籤頁重定向到授權服務器(SPA 臨時“退出”)。 這是一個默認行為,並且始終可用。
  • 授權服務器表單在 SPA 內部的 iframe 中顯示,這需要 SPA 和授權服務器都具有 SameOrigin ,因此它僅在 BFF 和資源服務器使用默認配置文件(與 Keycloak 配合使用時)時才有效。

邏輯由 Login 組件實現,該組件顯示一個下拉菜單以選擇登錄體驗(iframe默認)和一個按鈕。

登錄選項在組件初始化時從 BFF 中獲取。 在本教程中,我們預計只有一個選項,因此我們僅選擇響應負載中的第一條條目。

當用户單擊 Login 按鈕時,取決於所選的登錄體驗:

  • 如果選擇了 iframe,則將 iframe 的源設置為登錄 URI,並顯示包含 iframe 的模態 div。
  • 否則,將 window.location.href 設置為登錄 URI,這會“退出” SPA 並使用全新的起源設置當前標籤頁。

當用户選擇 iframe 登錄體驗時,我們為 iframe 的 load 事件註冊事件監聽器,以檢查用户身份驗證是否成功,並隱藏模態。 此回調在 iframe 中發生任何重定向時運行。

最後,請注意 SPA 將 post_login_success_uri 請求參數添加到授權碼流程初始化請求中。 spring-addons-starter-oidc 會將此參數的值存儲在會話中,並在授權碼交換為令牌後,使用它來構建返回到前端的重定向 URI。

6.4. 登出

登出按鈕和相關的邏輯由 <em >Logout</em > 組件處理。

默認情況下,Spring 的 <em >/logout</em > 端點期望一個 <em >POST</em > 請求,並且,由於任何修改服務器會話狀態的請求,它應該包含 CSRF 令牌。 Angular 和 React 透明地處理帶有 <em >http-only=false</em > 標誌的 CSRF 餅乾和請求頭。 但是,`我們必須手動讀取 XSRF-TOKEN 餅乾併為 Vue 中的每個 POST, PUT, PATCH, 和 DELETE 請求設置 X-XSRF-TOKEN 請求頭。 我們還應該始終參考我們選擇的前端框架的文檔,因為可能存在一些微妙的障礙。 例如,Angular 僅會為我們設置 X-XSRF-TOKEN 請求頭,但僅適用於沒有權威性的 URL(我們應該查詢 /bff/api/me 而不是 http://localhost:7080/bff/api/me,即使窗口位置是 http://localhost:7080/angular-ui/)。

當涉及 Spring OAuth2 客户端時,<a href="https://openid.net/specs/openid-connect-rpinitiated-1_0.html"><em >RP-Initiated Logout</em > 在兩個請求中發生:

  • 首先,一個 POST 請求被髮送到 Spring OAuth2 客户端,從而關閉其自身的會話。
  • 1 號請求的響應包含一個 Location 請求頭,其中包含授權服務器上關閉用户在該服務器上進行的其他會話的 URI。

默認的 Spring 行為是使用 302 狀態碼對 1 號請求,這使得瀏覽器自動跟隨到授權服務器,保持相同的源頭。 為了避免 CORS 錯誤,我們配置了 BFF 使用 2xx 字段中的狀態碼。 這要求 SPA 手動跟隨重定向,但它也提供了使用 window.location.href (具有新源頭) 來執行此操作的機會。

最後,我們可以注意到 SPA 使用 X-POST-LOGOUT-SUCCESS-URI 請求頭提供的登出 URI。 spring-addons-starter-oidc 使用此請求頭的有效負載,在授權服務器的登出請求 URI 中插入一個請求參數。

6.5. 客户端多租户

在配套項目中,存在一個單一的 OAuth2 客户端註冊,並使用授權碼。但是,如果我們需要更多?例如,如果我們在多個前端之間共享 BFF,其中一些前端具有不同的頒發者或範圍,這種情況可能會發生。

用户應該只被提示選擇他可以認證的 OpenID 提供程序,並且在許多情況下,我們可以過濾登錄選項。

以下是一些可以大大減少可能選擇數量的情況,理想情況下將其減少到 1 個,這樣用户就不需要進行選擇:

  • SPA 已配置為使用特定選項。
  • 存在多個反向代理,每個代理可以設置類似於帶有選項的標頭。
  • 像前端設備的 IP 等技術信息可以告訴我們用户應該在這裏或那裏進行授權。

在這種情況下,我們有以下兩個選擇:

  • 將過濾標準包含在請求中到 `/login-options`,並在 BFF 控制器中進行過濾。
  • 在前端中過濾 `/login-options` 響應。

7. 後台通道註銷

如果在一個 BFF 上有一個已打開的會話,用户使用另一個 OAuth2 客户端註銷,將會發生什麼?

在 OIDC 中,後台通道註銷 規範是為了處理此類場景而設計的:在授權服務器聲明客户端時,我們可以註冊一個 URL,用於在用户註銷時調用。

由於 BFF 運行在服務器上,它可以暴露一個端點,用於接收註銷事件通知。自版本 6.2 以來,Spring Security 支持後台通道註銷,並且 spring-addons-starter-oidc 提供了啓用該功能的標誌。

在後台通道註銷後,BFF 上會話結束時,前端到資源服務器(們)的請求將不再被授權(即使在令牌未過期之前)。因此,為了獲得完美的用户體驗,在 BFF 上啓用後台通道註銷時,我們可能也應該添加諸如 WebSockets 之類的機制,以便通知前端用户狀態的變更。

8. 反向代理

為了 SPA(單頁面應用程序)及其 BFF(背端前端)保持相同的源頭,因為:

  • 請求通過前端和 BFF 之間的會話 Cookie 進行授權。
  • Spring Session Cookie 設置為 SameSite=Lax

為此,我們將反向代理作為瀏覽器唯一的聯繫點。但實現這種反向代理有多種解決方案,我們的選擇將取決於上下文:

  • 在 Docker 中,我們使用 Nginx 容器。
  • 在 Kubernetes 上,我們可能配置 Ingress。
  • 在我們的 IDE 中工作時,我們可能更喜歡 Spring Cloud Gateway 實例。如果運行的服務數量很重要,我們甚至可以使用 Gateway 實例上的額外路由,作為 BFF 使用,而不是像本文中那樣使用專用實例。

8.1. 是否隱藏授權服務器在反向代理後面

為了安全原因,授權服務器應始終設置 X-Frame-Options 頭部。由於 Keycloak 允許將其設置為 SAMEORIGIN”,如果授權服務器和 SPA 共享相同的源(origin),則可以將 Keycloak 登錄和註冊表單嵌入在 SPA 中,並顯示在 iframe 中。

從用户角度來看,與授權表單在模態框中顯示,保持在同一 Web 應用程序中,而不是在 SPA 和授權服務器之間來回重定向,可能是一個更好的體驗。

另一方面,單點登錄 (SSO) 依賴於帶有 SameOrigin 標誌的 Cookie。因此,為了使兩個 SPA 能夠受益於單點登錄,它們不僅應該在同一授權服務器上進行身份驗證,還應該使用相同的權威服務器(例如,https://appa.nethttps://appy.net 都使用 https://sso.net 進行身份驗證)。

匹配這兩個條件的解決方案是,所有 SPA 和授權服務器都使用相同的源,使用諸如以下 URI:

  • https://domain.net/appa
  • https://domain.net/appy
  • https://domain.net/auth

這是我們與 Keycloak 協作時將使用的選項,但 SPA 和授權服務器之間共享相同的源並非 BFF 模式所需,僅需共享 SPA 和 BFF 之間的相同源。

配套倉庫中的項目已預先配置為使用 Amazon Cognito 和 Auth0,並使用其源(不會在運行時動態重寫 URL)。因此,在 iframe 中登錄僅在默認配置文件(使用 Keycloak)可用時才可用。

8.2. 使用 Spring Cloud Gateway 實現

首先,使用我們的 IDE 或 https://start.spring.io/,創建一個名為 reverse-proxy 的 Spring Boot 項目,並將 Reactive Gateway 添加為依賴。

然後,將 src/main/resources/application.properties 重命名為 src/main/resources/application.yml.

接下來,我們需要定義 Spring Cloud Gateway 的路由屬性:

# Custom properties to ease configuration overrides
# on command-line or IDE launch configurations
scheme: http
hostname: localhost
reverse-proxy-port: 7080
angular-port: 4201
angular-prefix: /angular-ui
angular-uri: http://${hostname}:${angular-port}${angular-prefix}
vue-port: 4202
vue-prefix: /vue-ui
vue-uri: http://${hostname}:${vue-port}${vue-prefix}
react-port: 4203
react-prefix: /react-ui
react-uri: http://${hostname}:${react-port}${react-prefix}
authorization-server-port: 8080
authorization-server-prefix: /auth
authorization-server-uri: ${scheme}://${hostname}:${authorization-server-port}${authorization-server-prefix}
bff-port: 7081
bff-prefix: /bff
bff-uri: ${scheme}://${hostname}:${bff-port}

server:
  port: ${reverse-proxy-port}

spring:
  cloud:
    gateway:
      default-filters:
      - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
      routes:
      # SPAs assets
      - id: angular-ui
        uri: ${angular-uri}
        predicates:
        - Path=${angular-prefix}/**
      - id: vue-ui
        uri: ${vue-uri}
        predicates:
        - Path=${vue-prefix}/**
      - id: react-ui
        uri: ${react-uri}
        predicates:
        - Path=${react-prefix}/**
      
      # Authorization-server
      - id: authorization-server
        uri: ${authorization-server-uri}
        predicates:
        - Path=${authorization-server-prefix}/**
      
      # BFF
      - id: bff
        uri: ${bff-uri}
        predicates:
        - Path=${bff-prefix}/**
        filters:
        - StripPrefix=1

現在我們可以啓動反向代理(在停止 Docker 容器並提供 hostname 作為命令行參數或在運行配置中)。

9. 授權服務器

在 GitHub 上的配套項目,默認配置文件針對 Keycloak,但由於使用了 spring-addons-starter-oidc,切換到任何其他 OpenID 提供者只需編輯 application.yml 文件。配套項目提供的文件包含配置文件,以幫助我們輕鬆上手使用 Auth0 和 Amazon Cognito。

無論我們選擇哪個 OpenID 提供者,我們都應該:

  • 聲明一個機密客户端
  • 確定用於作為用户角色來源的私有聲明
  • 更新 BFF 和資源服務器屬性

10. 使用 spring-addons-starter-oidc 的理由?

在本文中,我們修改了 spring-boot-starter-oauth2-clientspring-boot-starter-oauth2-resource-server 的許多默認行為:

  • 將 OAuth2 重定向 URI 指向反向代理,而不是內部 OAuth2 客户端。
  • 賦予 SPA 控制用户在登錄/註銷後重定向到的位置。
  • 在 JavaScript 代碼可訪問的 Cookie 中暴露 CSRF 令牌。
  • 適應非完全標準的 RP-Initiated Logout (例如 Auth0 和 Amazon Cognito)。
  • 向授權請求添加可選參數 (例如 Auth0 的 audience)。
  • 將 OAuth2 重定向的 HTTP 狀態更改為 SPA 可以選擇遵循 Location 標頭的方式。
  • 註冊兩個不同的 SecurityFilterChain Bean,分別使用 oauth2Login() (帶有基於會話的安全性及 CSRF 保護) 和 oauth2ResourceServer() (無狀態,帶有基於令牌的安全性) 來安全不同的資源組。
  • 定義哪些端點對匿名用户開放。
  • 在資源服務器上,接受來自多個 OpenID 供應商頒發的令牌。
  • 向 JWT 解碼器添加一個 audience 驗證器。
  • 將權限映射來自任何聲明 (以及添加前綴或強制大小寫)。

這通常需要大量的 Java 代碼以及對 Spring Security 的深入瞭解。但在這裏,我們僅使用應用程序屬性就完成了它,並可以利用 IDE 自補全的指導!

我們應該參考 GitHub 上的 starter README 以獲取完整的功能列表、自動配置調整以及默認值覆蓋信息。

11. 結論

在本教程中,我們學習瞭如何使用 Spring Cloud Gateway 和 spring-addons 實現 OAuth2 後端對前端模式。

我們還了解到:

  • 採用這種解決方案比將 SPA 配置為“公共”的 OAuth2 客户端更有優勢。
  • 引入 BFF 對 SPA 本身的影響微乎其微。
  • 這種模式對資源服務器沒有任何改變。
  • 由於我們使用服務器端 OAuth2 客户端,即使在單點登錄 (SSO) 配置中,我們也能完全控制用户會話,這要歸功於 背通道註銷

最後,我們開始探索 spring-addons-starter-oidc 的便捷性,只需通過屬性配置,即可完成通常需要大量 Java 配置的任務。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.