在 Spring Authorization Server 中實作多租用戶
1. 引言
Spring Authorization Server 最初是 Spring 龐大產品組合下的附屬項目,於幾年前啟動。此後,它逐漸發展壯大,並在版本 7 中正式整合到 Spring Security 中。
在本教程中,我們將探討如何在多租戶場景中使用它,該功能可以幫助在單一伺服器部署上為多個不同的客戶提供服務。
2. 快速回顧
我們在先前的教學課程中已經介紹了 Spring Authorization Server(簡稱 SAS)的基本用法,但讓我們先快速回顧一下。
簡而言之,SAS 是一個基於 Spring 的函式庫,它使我們能夠快速實作符合 OpenID Connect/OAuth 2.0 標準的身分提供者。此實作建構於成熟的 Spring Security 框架之上,因此可以輕鬆地使用熟悉的 Spring 相關模式進行嵌入或擴充。
隨著專案遷移到 Spring Security,一些變化使得將該庫整合到用戶應用程式中變得更加容易:
- 與其他 Spring Security 依賴項的版本一致性
- HttpSecurity 的 DSL 集成
- 單一文件站點,提升開發者體驗
此外,Spring Boot 4 還包含新的啟動模組,簡化了 SAS 的使用。現在,我們只需幾分鐘即可透過start.spring.io建立一個功能齊全的 OpenID Connect 伺服器,只需新增所需的依賴項並配置一些屬性即可。
警告:截至本文撰寫之時,SAS 7.x 版本僅限於基於 Servlet 的應用程式。如果我們有一個基於 Web 的響應式 SAS 應用程序,我們可以將其重寫為使用基於 MVC 的 API,或者至少目前可以繼續使用 1.x 版本,直到我們準備好升級為止。
3. Spring Authorization Server 中的多租戶
儘管 SAS 提高了生活質量,但其提供的多租戶支援功能需要編寫一些程式碼。
在標準的 SAS 實作中,我們通常使用屬性或資料庫支援的儲存來註冊一個或多個客戶端應用程序,這些應用程式共享同一組鍵,並且至關重要的是,共享同一個發行者。
這意味著,由於使用相同的金鑰簽名,為一個客戶端建立的 SAS 產生的令牌也可能被另一個客戶端視為有效。根據客戶端的配置,這可能會帶來安全風險:由於並非所有客戶端都實現了受眾驗證,惡意或被入侵的用戶端可能會獲取令牌並用它來存取原本屬於其他客戶端的資料。
利用多租用戶架構,我們可以隔離客戶端,使每個租用戶使用自己的金鑰對來簽署令牌。即使某個租戶的客戶端遭到入侵,其他租戶也無法辨識這些令牌。
SAS 遵循 OpenID Connect 關於多租戶的 建議。依照規範,頒發者 URL 的host:port部分之後可以包含路徑。在 SAS 的實作中,我們將路徑的最後一部分用作租用戶識別碼:
- 發行者 URL:
https://example.com:9443/issuer1issuer1 => 租戶識別碼:issuer1 - 發行者 URL:
https://example.com:9443/issuer2issuer2 => 租戶識別碼:issuer2
4. 項目設定
讓我們建立一個簡單的基於 SAS 的項目,看看多租戶在實踐中是如何運作的。
首先,讓我們將所需的 Spring Boot starter Maven 依賴項新增到我們的專案中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
<version>4.0.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server-test</artifactId>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
這些相依性的最新版本可在 Maven Central 上找到:
-
[spring-boot-starter-security-oauth2-authorization-server](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security-oauth2-authorization-server) -
[spring-boot-starter-security-oauth2-authorization-server-test](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security-oauth2-authorization-server-test)
我們還需要為伺服器啟用多租戶支持,該功能預設為停用。最簡單的啟用方法是將以下屬性新增至application.yaml (或 properties)檔案:
spring:
security:
oauth2:
authorizationserver:
multiple-issuers-allowed: true
5. 了解現有租戶
僅僅啟用多個發行者並不足以完全實現多租戶。如果我們仔細檢查 SAS 的核心元件,就會發現它們實作的介面都無法感知目前租用戶。
例如,我們來看RegisteredClientRepository元件,它有三個方法:
public interface RegisteredClientRepository {
void save(RegisteredClient registeredClient);
RegisteredClient findById(String id);
RegisteredClient findByClientId(String clientId);
}
請注意,它們都沒有接受租戶識別碼之類的東西,而實作過程可以使用這些資訊來確定該方法應該使用哪個租戶。
那麼,我們該如何繼續呢?解決方案在於AuthorizationServerContextFilter ,這是一個始終添加到 SAS 的 SecurityFilterChain 中的內部類別。
這個過濾器有兩個作用:
- 首先,它使用
IssuerResolver輔助類別從目前請求中提取發行者識別碼。 - 其次,它建立了一個
AuthorizationServerContext實例,並使用AuthorizationServerContextHolder將其綁定到目前執行緒。
由於此篩選器會在其他 SAS 篩選器之前調用,因此我們可以隨時在元件內部擷取發行者識別碼:
var issuer = AuthorizationServerContextHolder.getContext().getIssuer()
6. 多租戶感知組件實現策略
在開始實作這些元件之前,我們必須先定義如何為每個租用戶載入資訊。在本教學中,我們將採用基於屬性/YAML 的方法,力求簡單易行。
我們將使用@ConfigurationProperties類別來讀取每個租戶的訊息,並將其儲存在一個映射中,以租戶識別碼作為索引:
@ConfigurationProperties(prefix = "multitenant-auth-server")
public class MultitenantAuthServerProperties {
private Map<String, OAuth2AuthorizationServerProperties> tenants = new HashMap<>();
public Map<String, OAuth2AuthorizationServerProperties> getTenants() {
return tenants;
}
public void setTenants(Map<String, OAuth2AuthorizationServerProperties> tenants) {
this.tenants = tenants;
}
}
租用戶對映中的值使用庫自己的OAuth2AuthorizationServerProperties來儲存租用戶特定的訊息,例如已註冊的用戶端。
這是一個application.yaml檔案的範例,其中定義了兩個租用戶,每個租用戶上都註冊了一個客戶端。請注意,即使客戶端使用相同的client-id值,它們仍會被視為不同的客戶端。
multitenant-auth-server:
tenants:
issuer1:
client:
client1:
require-authorization-consent: false
registration:
client-name: Client 1 - Issuer 1
client-id: client1
scopes:
- openid
- email
- account:read
# ... other properties omitted
issuer2:
client:
client1:
require-authorization-consent: false
registration:
client-name: 'Client 1 - Issuer 2'
client-id: client1
scopes:
- openid
- email
- account:write
# ... other properties omitted
組件的實作策略將採用複合委託方式。在初始化時,每個元件都會收到一個映射表,其中包含按租用戶識別碼索引的同類型實例。在每個實例中,元件將按照以下簡單策略實現所需功能:
- 取得當前發行人
- 將發行者對應到租戶識別符
- 使用租戶識別碼尋找匹配的代理
- 呼叫對應的委託方法
由於步驟 1 到 3 本質上與元件類型或呼叫方法無關,我們將把它們提取到一個基底類別中:
public class AbstractMultitenantComponent<T> {
private Map<String,T> componentsByTenant;
private Supplier<AuthorizationServerContext> authorizationServerContextSupplier;
protected AbstractMultitenantComponent(Map<String,T> componentsByTenant,
Supplier<AuthorizationServerContext> authorizationServerContextSupplier) {
this.componentsByTenant = componentsByTenant;
this.authorizationServerContextSupplier = authorizationServerContextSupplier;
}
protected Optional<T> getComponent() {
var authorizationServerContext = authorizationServerContextSupplier.get();
if (authorizationServerContext == null || authorizationServerContext.getIssuer() == null) {
return Optional.empty();
}
var issuer = authorizationServerContext.getIssuer();
for (var entry : componentsByTenant.entrySet()) {
if (issuer.endsWith(entry.getKey())) {
return Optional.of(entry.getValue());
}
}
return Optional.empty();
}
}
getComponent()方法包含子類別共享的邏輯,用於取得租戶特定的元件實現,以便與目前請求一起使用。請注意,我們選擇使用 ` Supplier<AuthorizationServerContext>而不是直接使用AuthorizationServerContextHolder 。主要原因是簡化單元測試,並將此邏輯與取得AuthorizationServerContext實例的策略隔離。
7. 核心元件實現
現在,我們準備為以下核心 SAS 元件新增多租用戶支援:
-
RegisteredClientRepository -
OAuth2AuthorizationService -
OAuth2AuthorizationConsentService -
JWKSource<SecurityContext>
由於基類提供了相應的功能,因此實作起來非常簡單。例如,以下是MultitenantRegisteredClientRepository的完整原始碼,它是我們為RegisteredClientRepository元件實現的多租戶感知功能:
public class MultitenantRegisteredClientRepository
extends AbstractMultitenantComponent<RegisteredClientRepository>
implements RegisteredClientRepository {
public MultitenantRegisteredClientRepository(Map<String, RegisteredClientRepository> clientRepoByTenant,
Supplier<AuthorizationServerContext> authorizationServerContextSupplier) {
super(clientRepoByTenant,authorizationServerContextSupplier);
}
@Override
public void save(RegisteredClient registeredClient) {
getComponent()
.orElseThrow(UnknownIssuerException::new)
.save(registeredClient);
}
@Override
public @Nullable RegisteredClient findById(String id) {
return getComponent()
.map(repo -> repo.findById(id))
.orElse(null);
}
@Override
public @Nullable RegisteredClient findByClientId(String clientId) {
return getComponent()
.map(repo -> repo.findByClientId(clientId))
.orElse(null);
}
}
其他可在網路上找到的實作類別也遵循相同的模式。
8. 配置
下一個任務是將我們的元件註冊為@Bean實例,以便 SAS 可以使用它們而不是常規的元件。
AuthServerConfiguration是一個@Configuration類,其中包含根據提供的屬性建立委託映射並實例化多租戶感知組件所需的邏輯。
建立OAuth2AuthorizationService bean 的程式碼就是一個很好的例子:
@Bean
OAuth2AuthorizationService multitenantAuthorizationService(Supplier<AuthorizationServerContext> authorizationServerContextSupplier) {
Map<String, OAuth2AuthorizationService> authServiceByTenant = new HashMap<>();
for(var tenantId : multitenantAuthServerProperties.getTenants().keySet()) {
authServiceByTenant.put(tenantId, new InMemoryOAuth2AuthorizationService());
}
return new MultitenantOAuth2AuthorizationService(authServiceByTenant,authorizationServerContextSupplier);
}
在這裡,我們遍歷租用戶標識符,並為每個識別碼建立一個InMemoryOAuth2AuthorizationService實例,並將其放入委託映射中的一個新條目中。
建構好委託映射後,我們將其連同所需的Supplier<AuthorizationServerContext>一起傳遞給多租戶感知組件。
9. 測試
最後,但同樣重要的是,讓我們編寫一些測試來驗證我們現在是否擁有一個支援多租戶的授權伺服器。
首先,讓我們檢查一下我們的發現端點是否能正確處理路徑中包含租戶識別碼的請求:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MultitenantAuthServerApplicationUnitTest {
@LocalServerPort
private int port;
@Test
void whenRequestDiscoveryDocumentForIssuer1_thenSuccess() {
restTestClient.get()
.uri("/issuer1/.well-known/openid-configuration")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.issuer")
.isEqualTo("http://localhost:" + port + "/issuer1");
}
// ... other tests omitted
}
此測試使用臨時本機連接埠啟動應用程式。我們使用RestTestClient向返回 OIDC 發現文件的已知位置發送 GET 請求。由於我們在.well-known部分之前加入了issuer1路徑元素,因此傳回的 JSON 結構中的issuer屬性必須包含該元素。
接下來,讓我們使用有效的客戶端憑證和範圍,模擬向issuer1發出的client_credentials令牌請求:
@Test
void givenClientCredentialsAndValidScope_whenRequestTokenForIssuer1_thenSuccess() {
var response = restTestClient.post()
.uri("/issuer1/oauth2/token")
.header("Authorization", "Basic " + base64Encode("client1:secret1"))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials&scope=account:read")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.access_token")
.exists()
.returnResult()
.getResponseBodyContent();
assertNotNull(response);
}
讓我們確保issuer1客戶端不能使用僅對issuer2有效的作用域:
@Test
void givenClientCredentialsAndinalidScope_whenRequestTokenForIssuer1_thenError() {
restTestClient.post()
.uri("/issuer1/oauth2/token")
.header("Authorization", "Basic " + base64Encode("client1:secret1"))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials&scope=account:write") // Invalid scope for Tenant1
.exchange()
.expectStatus()
.is4xxClientError();
}
很好。正如預期的那樣,我們遇到了 4xx 錯誤,這正是我們想要的。
10. 結論
在本教程中,我們已經了解如何在 Spring Authorization Server 中實作多租用戶。
透過建立多租用戶感知元件,我們增加了對伺服器實例中多個租用戶的支援。
像往常一樣,完整的源代碼可以在 GitHub 上找到。