1. 簡介
在本教程中,我們將演示如何將 Spring Security 的授權決策外部化到 OPA – Open Policy Agent。 Open Policy Agent
2. 序言:外部化授權的論據
在應用程序中,通常需要根據策略做出某些決策的能力。當該策略足夠簡單且不太可能更改時,我們可以直接在代碼中實現該策略,這是最常見的場景。
然而,在其他情況下,我們需要更大的靈活性。訪問控制決策是典型的:隨着應用程序的複雜性增加,訪問特定功能的權限可能不僅取決於你是誰,還取決於請求的其他上下文方面。這些方面可能包括 IP 地址、一天中的時間、登錄身份驗證方法(例如“記住我”或 OTP)等等。
此外,將上下文信息與用户身份結合的規則應該容易更改,最好無需應用程序停機。這一要求自然地導致了一個專門的服務處理策略評估請求的架構。
在此,這種靈活性帶來的權衡是增加的複雜性和為調用外部服務而產生的性能開銷。另一方面,我們可以演化或完全替換授權服務,而不會影響應用程序。此外,我們可以與多個應用程序共享此服務,從而在它們之間實現一致的授權模型。
3. 什麼是 OPA?
Open Policy Agent,簡稱 OPA,是一個使用 Go 語言實現的開源策略評估引擎。Styra 首次開發了這個項目,但現在它已成為 CNCF 畢業的項目。以下是一些該工具的典型用途:
- Envoy 授權過濾器
- Kubernetes 准入控制器
- Terraform 計劃評估
安裝 OPA 非常簡單:請參閲他們的 官方文檔 以獲取最新版本。此外,我們將通過將其添加到操作系統的 PATH 變量來提供它。我們可以使用一個簡單的命令來驗證它是否已正確安裝:
$ opa version
Version: 0.39.0
Build Commit: cc965f6
Build Timestamp: 2022-03-31T12:34:56Z
Build Hostname: 5aba1d393f31
Go Version: go1.18
Platform: windows/amd64
WebAssembly: available
OPA 評估使用 REGO 語言編寫的策略,該語言是一種針對複雜對象結構優化的聲明式語言。客户端應用程序然後根據特定用例使用這些查詢的結果。在我們的用例中,對象結構是一個授權請求,我們將使用策略來查詢結果以授予對特定功能的訪問權限。
重要的是要注意,OPA 的策略是通用的,並且與表達授權決策無關。事實上,我們可以將其用於其他傳統上由規則引擎(如 Drools 等)主導的場景。
4. 編寫策略
以下是一個用 REGO 編寫的簡單授權策略的示例:
package baeldung.auth.account
# 默認情況下未授權
default authorized = false
authorized = true {
count(deny) == 0
count(allow) > 0
}
# 允許訪問 /public
allow["public"] {
regex.match("^/public/.*",input.uri)
}
# 賬户 API 需要已認證的用户
deny["account_api_authenticated"] {
regex.match("^/account/.*",input.uri)
regex.match("ANONYMOUS",input.principal)
}
# 授權訪問賬户
allow["account_api_authorized"] {
regex.match("^/account/.+",input.uri)
parts := split(input.uri,"/")
account := parts[2]
role := concat(":",[ "ROLE_account", "read", account] )
role == input.authorities[i]
}
首先需要注意的是包聲明。OPA 策略使用包來組織規則,並且它們在評估傳入請求時也起着關鍵作用,正如我們稍後將展示。我們可以將策略文件組織到多個目錄中。
接下來,我們定義實際的策略規則:
- 一個 默認 規則,以確保我們始終會為 authorized 變量提供一個值
- 主要的聚合規則,可以被讀取為“authorized 在沒有拒絕訪問的規則和至少有一個允許訪問的規則時為 true”
- 允許和拒絕規則,每個規則都表達了一個條件,如果匹配,將向 allow 或 deny 數組添加一個條目
OPA 策略語言的完整描述超出了本文檔的範圍,但規則本身並不難閲讀。在查看它們時,需要注意以下幾點:
- 形式為 a := b 或 a=b 的語句是簡單的賦值(儘管 它們並不相同)
- 形式為 a = b { … conditions } 或 a { …conditions } 的語句意味着“將 b 賦值給 a 如果 conditions 為真”
- 策略文檔中出現的順序不重要
除此之外,OPA 附帶了一個豐富的內置函數庫,針對深度嵌套的數據結構進行了優化,以及更熟悉的特性,如字符串操作、集合和等等。
5. 評估策略
讓我們使用上一節中定義的策略來評估一個授權請求。 在我們的例子中,我們將使用包含來自傳入請求的一些內容的一個 JSON 結構來構建這個授權請求:
{
"input": {
"principal": "user1",
"authorities": ["ROLE_account:read:0001"],
"uri": "/account/0001",
"headers": {
"WebTestClient-Request-Id": "1",
"Accept": "application/json"
}
}
}
請注意,我們用一個單一的 input 對象包裹了請求屬性。 這個對象在策略評估期間成為 input 變量,我們可以使用 JavaScript 樣式的語法訪問其屬性。
為了測試我們的策略是否按預期工作,讓我們在本地以服務器模式下運行 OPA 並手動提交一些測試請求:
$ opa run -w -s src/test/rego
選項 -s 啓用服務器模式運行,而 -w 啓用自動規則文件重新加載。 src/test/rego 是包含我們示例代碼中策略文件的文件夾。 一旦運行,OPA 將監聽來自本地端口 8181 的 API 請求。 如果需要,可以使用 -a 選項更改默認端口。
現在,我們可以使用 curl 或其他工具發送請求:
$ curl --location --request POST 'http://localhost:8181/v1/data/baeldung/auth/account' \
--header 'Content-Type: application/json' \
--data-raw '{
"input": {
"principal": "user1",
"authorities": [],
"uri": "/account/0001",
"headers": {
"WebTestClient-Request-Id": "1",
"Accept": "application/json"
}
}
}'
請注意路徑部分在 /v1/data 前綴之後:它對應於策略的包名,點被替換為斜槓
響應將是一個 JSON 對象,其中包含對輸入數據進行策略評估所產生的所有結果:
{
"result": {
"allow": [],
"authorized": false,
"deny": []
}
}
result 屬性是一個包含策略引擎產生的結果的對象。 我們可以看到,在這種情況下,authorized 屬性為 false。 此外,allow 和 deny 都是空數組。 這意味着沒有特定的規則匹配輸入。 因此,主授權規則也沒有匹配。
6. Spring Authorization Manager 集成
現在我們已經瞭解了 OPA 的工作方式,可以向前進並將其集成到 Spring Authorization 框架中。 ReactiveAuthorizationManager Bean,它使用 OPA 作為後端:
@Bean
public ReactiveAuthorizationManager<AuthorizationContext> opaAuthManager(WebClient opaWebClient) {
return (auth, context) -> {
return opaWebClient.post()
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.body(toAuthorizationPayload(auth,context), Map.class)
.exchangeToMono(this::toDecision);
};
}
這裏注入的 WebClient 來自另一個 Bean,其中我們預先初始化了其屬性,從 @ConfigurationProperties 類中。
處理流程將職責委託給 toAuthorizationRequest 方法,該方法從當前的 Authentication 和 AuthorizationContext 中收集信息,然後構建授權請求負載。 類似地,toAuthorizationDecision 方法將授權響應映射到 AuthorizationDecision。
我們使用該 Bean 構建 SecurityWebFilterChain:
@Bean
public SecurityWebFilterChain accountAuthorization(ServerHttpSecurity http, @Qualifier("opaWebClient") WebClient opaWebClient) {
return http.httpBasic(Customizer.withDefaults())
.authorizeExchange(exchanges -> exchanges.pathMatchers("/account/*")
.access(opaAuthManager(opaWebClient)))
.build();
}
我們僅將自定義 AuthorizationManager 應用於 /account API。 這種方法的理由是我們很容易擴展此邏輯以支持多個策略文檔,從而使它們更易於維護。 例如,我們可以有一個配置,它使用請求 URI 來選擇適當的規則包,並使用此信息來構建授權請求。
在我們的案例中,/account API 只是一個簡單的控制器/服務對,它返回一個填充了假餘額的 Account 對象。
7. 測試
最後但也是最重要的,我們來構建一個集成測試,將所有內容整合在一起。首先,我們確保“成功路徑”正常工作。這意味着一個已認證的用户能夠訪問其賬户:
@Test
@WithMockUser(username = "user1", roles = { "account:read:0001"} )
void testGivenValidUser_thenSuccess() {
rest.get()
.uri("/account/0001")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.is2xxSuccessful();
}
其次,我們還必須驗證一個已認證的用户只能訪問其賬户:
@Test
@WithMockUser(username = "user1", roles = { "account:read:0002"} )
void testGivenValidUser_thenUnauthorized() {
rest.get()
.uri("/account/0001")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isForbidden();
}
最後,我們還要測試當認證用户沒有權限的情況:
@Test
@WithMockUser(username = "user1", roles = {} )
void testGivenNoAuthorities_thenForbidden() {
rest.get()
.uri("/account/0001")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isForbidden();
}
我們可以從 IDE 或命令行運行這些測試。請注意,無論在何種情況下,我們首先必須啓動指向包含我們的授權策略文件的文件夾的 OPA 服務器。
8. 結論
在本文中,我們展示瞭如何使用 OPA 來外部化 Spring Security 應用程序的授權決策。