使用 Spring Cloud Gateway 的前端 OAuth2 後端
1. 概述
在本教程中,我們將使用 Spring Cloud Gateway 和spring-addons實作 OAuth2 Backend for Frontend (BFF) 模式。
如果我們檢查任何已知使用 OAuth2 的主要網站(Google、Facebook、Github、LinkedIn 等),我們將找不到帶有 token 的Bearer
headers 。為什麼?
根據安全專家的說法,我們不應該再將單頁應用程式(Angular、React、Vue 等)或行動應用程式配置為「公共」OAuth2 用戶端。最好的替代方案可能是透過在我們信任的伺服器上執行的 BFF 上的會話來授權行動和 Web 應用程式。
提醒一下,JSON Web 令牌 (JWT) 無法失效,而且當終止伺服器上的會話時,我們很難在最終使用者裝置上刪除它。如果我們透過網路發送 JWT,我們所能做的就是等待它過期,在此之前對資源伺服器的存取仍然被授權。但如果令牌永遠不會離開後端,那麼我們可以將其與 BFF 上的使用者會話一起刪除,立即撤銷對資源的存取權限。
好消息是,我們可以透過幾個簡單的步驟將Single Page Applications
(SPA) 連接到 OAuth2 BFF。更好的是,我們根本不需要進行任何修改即可應用於資源伺服器(使用Bearer
存取令牌授權的 REST API)。
在本教程中,我們將使用:
- Spring Cloud Gateway 作為 OAuth2 BFF:帶有
TokenRelay
過濾器的「機密」OAuth2 用戶端 - 配置為無狀態 OAuth2 資源伺服器的 Spring Boot REST API
- 3 個使用 Angular、React (Next.js) 和 Vue (Vite) 編寫的前端
- Keycloak 作為主要 OpenID 提供者 (OP),但配套存儲庫還包含 Spring 配置文件,以便輕鬆開始使用 Auth0 和 Amazon Cognito。
- 至少 SPA 和 BFF 具有相同來源的反向代理
-
spring-addons-starter-oidc
,一個開源 Spring Boot 啟動器,用於進一步簡化 Spring Boot 應用程式中的 OAuth2 配置
2. 前端模式的 OAuth2 後端
前端後端模式是一種在前端和 REST API 之間具有中介軟體的架構。當涉及 OAuth2 時,請求透過以下方式授權:
- 前端和 BFF 之間的會話 cookie 和 CSRF 保護
- BFF 和 REST API 之間(以及後端服務之間)的存取令牌
這樣的 OAuth2 BFF 負責:
- 使用“機密”OAuth2 客戶端驅動授權代碼流
- 在會話中儲存令牌
- 在將請求從前端轉發到資源伺服器之前,將會話 cookie 替換為會話中的存取令牌
OAuth2 BFF 模式比將單一頁面或行動應用程式配置為「公共」OAuth2 用戶端更安全,因為:
- BFF 運行在我們信任的伺服器上,它可以保守調用授權伺服器令牌端點的秘密
- 我們可以設定防火牆規則以僅允許來自後端的請求存取權杖端點
- 令牌保存在伺服器(會話)上。使用會話 cookie 需要針對 CSRF 進行保護,但 cookie 可以使用
HttpOnly
、Secure
和SameSite
進行標記,比將令牌暴露給最終用戶裝置上運行的程式碼更安全。
作為 BFF,我們將使用 S pring
Cloud Gateway:
-
spring-boot-starter-oauth2-client
和oauth2Login()
處理授權程式碼流並在會話中儲存令牌 - 當將請求從前端轉發到資源伺服器時,
TokenRelay=
過濾器將會話 cookie 替換為會話中的存取令牌
3. 架構
到目前為止,我們列出了相當多的服務:前端 (SPA)、REST API、BFF、授權伺服器和反向代理。讓我們看看它是如何建構一個連貫的系統的。
3.1.系統總覽
以下是我們將在主設定檔中使用的服務、連接埠和路徑前綴的表示:
此架構中需要注意的幾點是:
- 從最終用戶設備的角度來看,與後端有一個單一的聯繫點:反向代理
- 我們公開了三個不同的單頁應用程式來演示與每個主要框架(Angular、React 和 Vue)的集成
- 反向代理使用路徑前綴將請求路由到正確的服務
到授權伺服器的唯一連結來自反向代理的原因是:
- 在第 4 節中設定 Keycloak 時,我們將使用指向反向代理的值來設定
hostname-url
屬性,並以/auth
作為路徑前綴。這會影響令牌issuer
聲明的值,也會影響 OpenID 配置中的所有端點 URI(授權、令牌、結束會話等)。 - 反向代理配置為將路徑以
/auth
開頭的所有請求路由到 Keycloak
請注意,在配套專案中,Auth0 和 Amazon Cognito 的設定檔配置不同:反向代理程式上沒有到授權伺服器的路由,且頒發者以及終端節點 URI 在授權伺服器上保留預設值。這導致了這個略有不同的替代架構:
在單一開發機器上工作時,使用路徑前綴來區分 SPA 很好,但是當進入類似生產的環境時,我們不妨將反向代理配置為使用(子)域進行路由,甚至為每個前端使用不同的反向代理。
3.2.反向代理
我們需要 SPA 及其 BFF 具有相同的起源,因為:
- 請求透過前端和 BFF 之間的會話 cookie 進行授權
- Spring 會話 cookie 帶有
SameSite=Lax
標記
為此,我們將使用反向代理作為瀏覽器的單一聯絡點。它將使用路徑前綴將請求路由到正確的服務。
在配套的儲存庫中,我們使用一個非常基本的 Spring Cloud Gateway 實例,只帶有一些路由(沒有安全性,除了到 BFF 的路由上的StripPrefix=1
之外沒有其他過濾器),但還有很多其他選項可以實現相同的目標,有些更適合特定的環境:Docker中的nginx容器,K8s上的ingress等。
3.3.是否將授權伺服器隱藏在反向代理後面
出於安全原因,授權伺服器應始終設定X-Frame-Options
標頭。由於 Keycloak 允許將其設定為SAMEORIGIN
,如果授權伺服器和 SPA 共享相同的來源,則可以在此 SPA 中嵌入的 iframe 中顯示 Keycloak 登入和註冊表單。
從最終用戶的角度來看,留在同一個 Web 應用程式中並以模式顯示授權表單可能是更好的體驗,而不是在 SPA 和授權伺服器之間來回重定向。
另一方面,單一登入 (SSO) 依賴帶有SameOrigin.
因此,要使兩個 SPA 從單一登入中受益,它們不僅應該在同一授權伺服器上對使用者進行身份驗證,還應該使用相同的權限( https://appa.net
和https://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)。因此,僅當使用預設設定檔(使用 Keycloak)時才可以從 iframe 登入。
3.4.執行
首先,使用我們的IDE或https://start.spring.io/ ,我們建立一個名為reverse-proxy
的新Spring Boot項目,並使用Reactive Gateway
作為依賴項。
然後我們將src/main/resources/application.properties
重新命名為[src/main/resources/application.yml](https://github.com/eugenp/tutorials/tree/master/spring-security-modules/spring-security-oauth2-bff/backend/reverse-proxy/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
新增此配置後,我們就可以啟動反向代理了!
4. 授權伺服器
在 GitHub 上的配對專案中,預設設定檔是為 Keycloak 設計的,但由於spring-addons-starter-oidc
,切換到任何其他 OpenID 提供者只需編輯application.yml.
配套專案中提供的文件包含可輕鬆開始使用 Auth0 和 Amazon Cognito 的設定檔。
4.1. Docker 中的 Keycloak
配套儲存庫包含一個 docker compose 檔案。要使用它,我們需要做的就是:
- 編輯
.env
檔以更改KEYCLOAK_ADMIN_PASSWORD
- 從
keycloak
目錄運行“docker compose -f docker-compose.yaml up”
4.2.獨立鑰匙斗篷
我們也可以從Keycloak 網站下載由 Quarkus 提供支援的發行版。
首先,我們需要編輯keycloak.conf
加入如下內容:
hostname-url=http://localhost:7080/auth
hostname-admin-url=http://localhost:7080/auth
http-relative-path=/auth
http-port=8080
那我們應該:
- 檢查反向代理是否正在運行
- 使用
“bin\kc.bat start-dev”
或“bash ./bin/kc.sh start-dev”
啟動Keycloak - 造訪
http://localhost:7080/auth/
設定管理員密碼
4.3.領域和測試用戶
為了在 Keycloak 領域中對實驗室進行沙箱處理,我們將:
- 點擊左上角顯示
master
下拉式選單 - 點選
Create Realm
按鈕 - 輸入
baeldung
作為Realm name
然後,我們將創建一個用戶:
- 點擊左側選單中的
Users
- 點擊
Add user
按鈕 - 填表格
- 點選
Create
按鈕 - 切換到
Credentials
選項卡 - 點選
Set password
按鈕
4.3.具有授權碼的機密客戶端
透過瀏覽[http://localhost:7080/auth/admin/master/console/#/baeldung/clients](http://localhost:7080/auth/admin/master/console/#/baeldung/clients) ,
我們可以建立一個baeldung-confidential
客戶端:
開啟Client authentication
以指定我們想要一個「機密」客戶端,並且僅選擇Standard flow
,因為我們將僅使用授權代碼。
我們至少應該有:
-
http://localhost:7080/bff/login/oauth2/code/baeldung
作為重定向 URI -
http://localhost:7080/*
作為註銷後 URI -
+
作為 Web 來源(允許在重定向和登出後 URI 中配置來源)
當從其他設備(如行動裝置或模擬器)進行調試時,我們應該為我們的開發機器添加更多的網路接口,而不僅僅是localhost
(並在 Keycloak 配置中調整hostname-url
和hostname-admin-url
)。
4.4.與其他 OpenID 供應商合作
每個 OpenID Provider 都有其聲明“機密”OAuth2 客戶端的方式,因此我們應該參閱其文檔以了解詳細信息,但都具有相似的配置參數。
例如,在 Auth0 上,我們將創建一個名為baeldung-confidential
的新Regular Web Application
,其Settings
標籤將期望與上一節的第二個 Keycloak 螢幕截圖中可見的值相同。它也是收集client-id
和client-secret
的地方。最後,我們將建立一個以bff.baeldung.com
作為識別碼的 API,並在Machine To Machine Applications
標籤中啟用baeldung-confidential
。
5. 使用 Spring Cloud Gateway 和spring-addons-starter-oidc
實作 BFF
首先,使用我們的 IDE 或https://start.spring.io/
,我們建立一個名為bff
的新 Spring Boot 項目,並使用Reactive Gateway
和OAuth2 client
作為依賴項。
然後我們將src/main/resources/application.properties
重新命名為src/main/resources/application.yml
。
最後,我們將[spring-addons-starter-oidc](https://mvnrepository.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc)
加入到我們的依賴項:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.5.3</version>
</dependency>
5.1.重複使用的屬性
讓我們從application.yml
中的一些常數開始,這些常數將在其他部分以及需要覆蓋命令列或 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: change-me
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
的值。
5.2.伺服器屬性
現在是常見的伺服器屬性:
server:
port: ${bff-port}
5.3. Spring Cloud 閘道由
由於網關後面只有一台資源伺服器,因此我們只需要一個路由定義:
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
最重要的部分是SaveSession
和TokenRelay=
,它們構成了 OAuth2 BFF 模式實現的基石:第一個部分確保會話持久化(使用oauth2Login()
獲取的令牌),第二個部分用訪問令牌替換會話 cookie在路由請求時在會話中。
StripPrefix=1
過濾器在路由請求時從路徑中刪除/api
前綴。值得注意的是, /bff
前綴在反向代理路由期間已被刪除。因此,從前端發送到/bff/api/me
請求將作為/me
到達資源伺服器上。
5.4.春季安全
我們現在可以使用標準啟動屬性來配置 OAuth2 客戶端安全性:
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
這裡確實沒有什麼特別的,只是向所需提供者進行非常標準的授權代碼註冊。
5.5. spring-addons-starter-oidc
為了完成配置,讓我們使用spring-addons-starter-oidc
調整安全性:
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
,具有提供者特定值。這使我們能夠指定要轉換為 Spring 權限的聲明的 JSON 路徑(每個聲明都有可選的前綴和大小寫轉換)。如果aud
屬性不為空,spring-addons 會為 JWT 解碼器新增受眾驗證器。 -
client
,當security-matchers
不為空時,此部分會觸發使用oauth2Login()
建立SecurityFilterChain
bean。在這裡,透過client-uri
屬性,我們強制使用反向代理 URI 作為所有重定向的基礎(而不是 BFF 內部 URI)。此外,當我們使用 SPA 時,我們要求 BFF 在 Javascript 可存取的 cookie 中公開 CSRF 令牌。最後,為了防止 CORS 錯誤,我們要求 BFF 以 201 狀態(而不是 3xx)回應RP 發起的註銷,這使 SPA 能夠攔截此回應並要求瀏覽器在請求中使用新的回應來處理它。起源。 -
resourceserver
,這會使用oauth2ResourceServer()
請求第二個SecurityFilterChain
bean。此過濾器鏈具有最低優先權的@Order
將處理與客戶端SecurityFilterChain
的安全匹配器不符的所有請求。我們將它用於不需要會話的所有資源:不涉及登入或使用TokenRelay
進行路由的端點。
我們現在可以運行 BFF 應用程序,在命令列或運行配置中仔細提供client-secret
。
5.6. /login-options
端點
BFF 是我們定義登入設定的地方:使用授權代碼進行 Spring OAuth2 用戶端註冊。為了避免每個 SPA 中的設定重複(以及可能的不一致),我們將在 BFF 上託管 REST 端點,公開它支援的使用者登入選項。
為此,我們所要做的就是公開一個@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) {
}
}
5.7.非標準 RP 發起的註銷
RP-Initiated Logout是 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-request-params:
baeldung:
- name: audience
value: ${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
發送授權請求時,附加請求參數的規範。
6.帶有spring-addons-starter-oidc
資源伺服器
我們對該系統的需求非常簡單:使用JWT 存取令牌授權的無狀態REST API,公開單一端點以反映令牌中包含的一些使用者資訊(如果請求未經授權,則顯示具有空值的有效負載) 。
為此,我們將建立一個名為resource-server
的新 Spring Boot 項目,並將 Spring Web
和OAuth2 Resource Server
作為依賴項。
然後我們將src/main/resources/application.properties
重新命名為src/main/resources/application.yml
。
最後,我們將[spring-addons-starter-oidc](https://mvnrepository.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc)
加入到我們的依賴項:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.5.3</version>
</dependency>
6.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
- /actuator/health/readiness
- /actuator/health/liveness
感謝spring-addons-starter-oidc
,這足以宣告一個無狀態資源伺服器:
- 從我們選擇的聲明映射的權限(在具有領域角色的 Keycloak 的情況下,
realm_access.roles
) - 使
/me
可以存取匿名請求
配套儲存庫中的application.yaml
包含其他使用其他角色私有聲明的 OpenID 提供者的設定檔。
6.2. @RestController
讓我們實作一個 REST 端點,從安全上下文中的Authentication
傳回一些資料(如果有):
@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);
}
}
6.3.資源伺服器多租用戶
如果使用我們的 REST API 的前端並不都在同一授權伺服器或領域上授權其使用者(或如果它們提供授權伺服器選擇)怎麼辦?
使用spring-security-starter-oidc
,這非常簡單: com.c4-soft.springaddons.oidc.ops
配置屬性是一個數組,我們可以添加我們信任的任意多個發行者,每個發行者都有其用戶名和權限的對應。由任何這些發行者發行的有效代幣都將被我們的資源伺服器接受,並將角色正確地對應到 Spring 權限。
7. SPA
由於用於將 SPA 連接到 OAuth2 BFF 的框架之間存在一些差異,因此我們將介紹三個主要框架: Angular 、 React和Vue 。
但是,建立 SPA 超出了本文的範圍。此後,我們將僅關注 Web 應用程式如何在 OAuth2 BFF 上登入和登出使用者並查詢背後的 REST API。請參閱配套存儲庫以了解完整的實作。
我們努力使應用程式具有相同的結構:
- 示範如何在身份驗證後恢復目前路由的兩種路由
- 如果
iframe
和default
都可用,Login
元件將提供登入體驗選擇。它還處理 iframe 顯示狀態或重定向到授權伺服器。 -
Logout
元件向 BFF/logout
端點發送 POST 請求,然後重定向到授權伺服器以進行 RP 發起的註銷 -
UserService
透過BFF從資源伺服器取得目前使用者資料。它還包含一些邏輯,用於在 BFF 上的存取權杖到期之前調度此資料的刷新。
然而,由於框架處理狀態的方式非常不同,當前使用者資料的管理方式有所不同:
- 在 Angular 應用程式中,
UserService
是一個使用BehaviorSubject
管理目前使用者的單例 - 在 React 應用程式中,我們在
app/
layout.tsx
中使用createContext
將當前使用者暴露給所有元件,並在需要存取它的任何地方使用useContext
- 在 Vue 應用程式中,
UserService
是一個單一範例(在main.ts
中實例化),使用ref
管理目前用戶
7.1.在配套儲存庫中執行 SPA
首先要做的就是cd
到我們要運行的專案的資料夾中。
然後,我們應該運行“npm install”
來提取所有必需的 npm 套件。
最後,取決於框架:
- Angular:執行
“npm run start”
並開啟http://localhost:7080/angular-ui/ - Vue:執行
“npm run serve”
並開啟http://localhost:7080/vue-ui/ - React (Next.js):執行
“npm run dev”
並開啟http://localhost:7080/react-ui/
我們應該小心,只使用指向反向代理的 URL ,而不是指向SPA 開發伺服器的URL(http://localhost:7080,而不是http://localhost:4201、http://localhost:4202 和http: // /本機:4203)。
7.2.使用者服務
UserService 的職責是:
- 定義使用者表示(內部和 DTO)
- 暴露一個函數透過BFF從資源伺服器取得使用者數據
- 在訪問令牌到期之前安排
refresh()
呼叫(保持會話活動)
7.3.登入
正如我們已經看到的,在可能的情況下,我們提供兩種不同的登入體驗:
- 使用者使用目前瀏覽器標籤重定向到授權伺服器(SPA 暫時「退出」)。這是預設行為並且始終可用。
- 授權伺服器表單顯示在 SPA 內的 iframe 中,這需要 SPA 和授權伺服器具有
SameOrigin
,因此僅當 BFF 和資源伺服器使用預設設定檔(使用 Keycloak)運行時才有效
該邏輯由Login
元件實現,該元件顯示一個下拉清單以選擇登入體驗( iframe
或default
)和一個按鈕。
元件初始化時,會從 BFF 取得登入選項。在本教程中,我們只期望一個選項,因此我們僅選擇回應負載中的第一個條目。
當使用者點擊Login
按鈕時,會發生什麼取決於所選的登入體驗:
- 如果選擇
iframe
,則 iframe 來源設定為登入 URI,並顯示包含 iframe 的模態 div - 否則,
window.location.href
將設定為登入 URI,該 URI 將「退出」SPA 並使用全新的來源設定目前選項卡
當使用者選擇iframe
登入體驗時,我們為 iframe load
事件註冊事件監聽器,以檢查使用者身份驗證是否成功並隱藏模式。每次 iframe 中發生重定向時都會執行此回呼。
最後,我們可以注意到 SPA 如何將post_login_success_uri
請求參數加入到授權代碼流啟動請求中。 [spring-addons-starter-oidc](https://github.com/ch4mpy/spring-addons/edit/master/spring-addons-starter-oidc/README.MD#1-2-3)
將此參數的值保存在會話中,並在將授權代碼交換為令牌後,使用它來建構返回到前端的重定向 URI。
7.4.登出
註銷按鈕和相關邏輯由Logout
組件處理。
預設情況下,Spring /logout
端點需要POST
請求,並且當任何請求修改具有會話的伺服器上的狀態時,它應該包含 CSRF 令牌。 Angular 和 React 透明地處理帶有http-only=false
標記的 CSRF cookie 和請求標頭,但我們必須手動讀取XSRF-TOKEN
cookie 並在 Vue 中為每個POST
、 PUT
、 PATCH
和DELETE
請求設定X-XSRF-TOKEN
標頭。
當涉及 Spring OAuth2 用戶端時, RP-Initiated Logout
發生在兩個請求中:
- 首先,一個
POST
請求被傳送到 Spring OAuth2 用戶端,該客戶端關閉自己的會話 - 第一個請求的回應有一個
Location
標頭,其中包含授權伺服器上的 URI,用於關閉使用者在那裡的其他會話
預設的 Spring 行為是對第一個請求使用 302 狀態,這使得瀏覽器自動追蹤授權伺服器,但保持相同的來源。為了避免 CORS 錯誤,我們將 BFF 配置為使用2xx
欄位中的狀態。這要求 SPA 手動遵循重定向,但使其有機會使用window.location.href
(具有新來源的請求)執行此操作。
最後,我們可以注意到 SPA 如何在註銷請求中使用X-POST-LOGOUT-SUCCESS-URI
標頭來提供註銷後 URI。 spring-addons-starter-oidc
使用此標頭的值在來自授權伺服器的註銷請求的 URI 中插入請求參數。
7.5。客戶端多租戶
在配套專案中,有一個帶有授權代碼的 OAuth2 用戶端註冊。但如果我們有更多呢?例如,如果我們在多個前端共用一個 BFF,其中一些前端具有不同的發行者或範圍,則可能會發生這種情況。
應提示使用者僅在他可以進行身份驗證的 OpenID 提供者之間進行選擇,並且在許多情況下,我們可以過濾登入選項。
以下是一些情況的範例,我們可以大幅減少可能的選擇數量,最好減少到一個,這樣就不會提示使用者進行選擇:
- SPA 配置有要使用的特定選項
- 有幾個反向代理,每個都可以設置諸如標頭之類的東西,並可以選擇使用
- 一些技術信息,例如前端設備的 IP,可以告訴我們用戶應該在此處或那裡獲得授權
在這種情況下,我們有兩個選擇:
- 將篩選條件與請求一起傳送至
/login-options
並在 BFF 控制器中進行過濾 - 過濾前端內的
/login-options
響應
8. 後台退出
如果在 SSO 設定中,在 BFF 上開啟會話的使用者使用另一個 OAuth2 用戶端登出怎麼辦?
在 OIDC 中, Back-Channel Logout規範就是針對這樣的場景制定的:當在授權伺服器上聲明用戶端時,我們可以註冊一個在使用者登出時呼叫的 URL。
由於 BFF 在伺服器上運行,因此它可以公開端點以接收此類註銷事件的通知。從版本6.2
開始, Spring Security 支援 Back-Channel Logout ,並且spring-addons-starter-oidc
公開了一個標誌來啟用它。
一旦會話在 BFF 上以 Back-Channel Logout 結束,從前端到資源伺服器的請求將不再被授權(即使在令牌過期之前)。因此,為了獲得完美的使用者體驗,當在 BFF 上啟動 Back-Channel Logout 時,我們可能也應該添加像WebSockets
這樣的機制來通知前端使用者狀態的變化。
9. 為什麼要使用spring-addons-starter-oidc
?
在這篇文章中,我們修改了spring-boot-starter-oauth2-client
和spring-boot-starter-oauth2-resource-server
的許多預設行為:
- 更改 OAuth2 重定向 URI 以指向反向代理而不是內部 OAuth2 用戶端
- 讓 SPA 控制使用者登入/登出後重定向的位置
- 在瀏覽器中執行的 Javascript 程式碼可存取的 cookie 中公開 CSRF 令牌
- 適應不完全標準的
RP-Initiated Logout
(例如 Auth0 和 Amazon Cognito) - 將可選參數新增至授權請求(Auth0
audience
或其他) - 變更 OAuth2 重定向的 HTTP 狀態,以便 SPA 可以選擇如何遵循
Location
標頭 - 分別使用
oauth2Login()
(具有基於會話的安全性和 CSRF 保護)和oauth2ResourceServer()
(無狀態,具有基於令牌的安全性)註冊兩個不同的SecurityFilterChain
bean,以保護不同的資源組 - 定義匿名可以存取哪些端點
- 在資源伺服器上,接受多個 OpenID 供應商頒發的令牌
- 將受眾驗證器新增至 JWT 解碼器
- 映射任何聲明的權威(並添加前綴或強制使用大寫/小寫)
這通常需要相當多的 Java 程式碼和對 Spring Security 的深入了解。但在這裡,我們僅使用應用程式屬性來完成此操作,並且可以使用 IDE 自動完成的指導!
我們應該參考Github 上的入門自述文件,以獲取完整的功能清單、自動配置調整和預設覆蓋。
10. 結論
在本教程中,我們了解如何使用 Spring Cloud Gateway 和spring-addons實現前端的 OAuth2 後端模式。
我們還看到:
- 為什麼我們應該青睞此解決方案而不是將 SPA 配置為「公共」OAuth2 用戶端
- 引入 BFF 對 SPA 本身影響很小
- 這種模式對資源伺服器沒有任何改變
- 因為我們使用伺服器端 OAuth2 客戶端,所以即使在 SSO 配置中,我們也可以完全控制使用者會話,這要歸功於Back-Channel Logout
最後,我們開始探索spring-addons-starter-oidc
可以如何方便地僅使用屬性來配置通常需要大量 Java 配置的內容。
與往常一樣,所有程式碼實作都可以在 GitHub 上取得。