Spring授權伺服器中的動態用戶端註冊
一、簡介
Spring 授權伺服器附帶了一系列合理的預設值,讓我們幾乎無需設定即可使用它。這使得它成為在測試場景中與客戶端應用程式一起使用以及當我們想要完全控制用戶的登入體驗時的絕佳選擇。
一項功能雖然可用,但預設情況下並未啟用:動態用戶端註冊。
在本教程中,我們將展示如何從客戶端應用程式啟用和使用它。
2. 為什麼要使用動態註冊?
當基於 OAuth2 的應用程式用戶端(或用 OIDC 的話來說,依賴方 (RP))啟動身分驗證流程時,它會將其自己的用戶端識別碼傳送給身分提供者。
通常,該標識符使用帶外進程發送給客戶端,然後將其新增至配置中並在需要時使用。
例如,當使用流行的身份提供者解決方案(例如 Azure 的 EntraID 或 Auth0)時,我們可以使用管理控制台或 API 來設定新用戶端。在此過程中,我們需要告知應用程式名稱、授權回調 URL、支援的範圍等。
一旦我們提供了所需的信息,我們最終將獲得一個新的客戶端標識符,對於所謂的「秘密」客戶端,我們將獲得一個客戶端密鑰。然後,我們將它們添加到應用程式的配置中,然後準備部署它。
現在,當我們擁有一小組應用程式時,或者當我們始終使用單一身分提供者時,此過程可以正常運作。然而,對於更複雜的場景,註冊過程需要是動態的,這就是OpenID Connect 動態用戶端註冊規範發揮作用的地方。
對於現實世界的案例,英國的開放銀行標準就是一個很好的例子,該標準使用動態客戶端註冊作為其核心協議之一。
3. 動態註冊如何運作?
OpenID Connect 標準使用用戶端用來註冊自身的單一註冊 URL。這是透過具有 JSON 物件的POST請求來完成的,該物件具有執行註冊所需的客戶端元資料。
重要的是,存取註冊端點需要身份驗證,通常是承載令牌。當然,這引出了一個問題:想要成為該客戶的客戶如何獲得此操作的代幣?
不幸的是,答案尚不清楚。一方面,規範表示端點是受保護的資源,因此需要某種形式的身份驗證。另一方面,它也提到了開放註冊端點的可能性。
對於 Spring 授權伺服器,註冊需要具有client.create範圍的不記名代幣。為了建立此令牌,我們使用常規 OAuth2 的令牌端點和基本憑證。
這是成功註冊的結果序列:
一旦客戶端成功完成註冊,它就可以使用傳回的客戶端 ID 和金鑰來執行任何標準授權流程。
4. 實現動態註冊
現在我們了解了所需的步驟,讓我們使用兩個 Spring Boot 應用程式來建立一個測試場景。一個將託管 Spring 授權伺服器,另一個將是使用 Spring Security Outh2 登入啟動模組的簡單 WebMVC 應用程式。
後者將使用動態註冊端點在啟動時取得客戶端標識符和秘密,而不是使用客戶端的常規靜態配置。
讓我們從伺服器開始。
5. 授權伺服器實現
我們首先新增所需的 Maven 依賴項:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
<version>1.3.1</version>
</dependency>
最新版本可在Maven Central上找到。
對於常規的 Spring 授權伺服器應用程序,這種依賴就足夠了。但出於安全考慮,預設情況下不啟用動態註冊。此外,截至撰寫本文時,還無法僅使用配置屬性來啟用它。
這意味著我們最後必須添加一些程式碼。
5.1.啟用動態註冊
OAuth2AuthorizationServerConfigurer是設定授權伺服器各方面的門戶,包括註冊端點。此配置應作為建立SecurityFilterChain bean 的一部分來完成:
@Configuration
@EnableConfigurationProperties(SecurityConfig.RegistrationProperties.class)
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(oidc -> {
oidc.clientRegistrationEndpoint(Customizer.withDefaults());
});
http.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));
return http.build();
}
// ... other beans omitted
}
在這裡,我們使用伺服器的配置器oidc()方法來存取OidcConfigurer實例。此子配置器具有允許我們控制與 OpenID Connect 標準相關的端點的方法。為了啟用註冊端點,我們使用預設配置的clientRegististrationEndpoint()方法。這將使用不記名令牌授權在/connect/register路徑上啟用註冊。進一步的配置選項包括:
- 定義自訂身份驗證
- 自訂處理收到的註冊數據
- 發送給客戶端的回應的自訂處理
現在,由於我們提供了一個自訂的SecurityFilterChain ,Spring Boot 的自動配置將會後退,讓我們負責在配置中添加一些額外的位元。
特別是,我們需要添加邏輯來設定表單登入驗證:
@Bean
@Order(2)
SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(r -> r.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.build();
}
5.2.註冊客戶端配置
如上所述,註冊機製本身需要客戶端發送不記名令牌。 Spring 授權伺服器透過要求用戶端使用用戶端憑證流來產生此令牌來解決這個先有雞還是先有蛋的問題。
此令牌請求所需的範圍是client.create ,且用戶端必須使用伺服器支援的身份驗證方案之一。在這裡,我們將使用基本憑證,但是,在現實場景中,我們可以使用其他方法。
從授權伺服器的角度來看,該註冊客戶端只是另一個客戶端。因此,我們將使用RegisteredClient Fluent API 來建立它:
@Bean
public RegisteredClientRepository registeredClientRepository(RegistrationProperties props) {
RegisteredClient registrarClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId(props.getRegistrarClientId())
.clientSecret(props.getRegistrarClientSecret())
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.clientSettings(ClientSettings.builder()
.requireProofKey(false)
.requireAuthorizationConsent(false)
.build())
.scope("client.create")
.scope("client.read")
.build();
RegisteredClientRepository delegate = new InMemoryRegisteredClientRepository(registrarClient);
return new CustomRegisteredClientRepository(delegate);
}
我們使用@ConfigurationProperties類別來允許使用 Spring 的標準Environment機製配置客戶端 ID 和秘密屬性。
此引導註冊將是啟動時創建的唯一註冊。在返回之前,我們會將其新增至我們的自訂RegisteredClientRepository 。
5.3.自訂RegisteredClientRepository
Spring Authorization Server 使用配置的RegisteredClientRepository實作來儲存伺服器中所有註冊的客戶端。它開箱即用,帶有內存和基於 JDBC 的實現,涵蓋了基本用例。
然而,這些實作不提供在保存註冊之前自訂註冊的任何功能。在我們的例子中,我們希望修改預設的ClientProperties設置,以便在授權使用者時不需要同意或 PKCE。
我們的實作將大多數方法委託給建置時傳遞的實際儲存庫。重要的例外是save()方法:
@Override
public void save(RegisteredClient registeredClient) {
Set<String> scopes = ( registeredClient.getScopes() == null || registeredClient.getScopes().isEmpty())?
Set.of("openid","email","profile"):
registeredClient.getScopes();
// Disable PKCE & Consent
RegisteredClient modifiedClient = RegisteredClient.from(registeredClient)
.scopes(s -> s.addAll(scopes))
.clientSettings(ClientSettings
.withSettings(registeredClient.getClientSettings().getSettings())
.requireAuthorizationConsent(false)
.requireProofKey(false)
.build())
.build();
delegate.save(modifiedClient);
}
在這裡,我們根據收到的 RegisteredClient 創建一個新的RegisteredClient ,並根據需要更改ClientSettings 。然後,這個新的註冊將被傳遞到後端,並在需要時儲存在那裡。
伺服器實作到此結束。現在,讓我們轉到客戶端
6. 動態註冊客戶端實現
我們的客戶端也將是一個標準的 Spring Web MVC 應用程序,具有顯示當前使用者資訊的單一頁面。 Spring Security,或者更具體地說,它的 OAuth2 Login 模組,將處理所有安全方面的問題。
讓我們從所需的 Maven 依賴項開始:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>3.3.2</version>
</dependency>
這些相依性的最新版本可在 Maven Central 上找到:
-
[spring-boot-starter-web](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web) -
[spring-boot-starter-thymeleaf](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf) -
[spring-boot-starter-oauth2-client](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-oauth2-client)
6.1.安全性設定
預設情況下,SpringBoot 的自動配置機制使用可用的PropertySources中的資訊來收集建立一個或多個ClientRegistration實例所需的數據,然後將這些實例儲存在基於記憶體的ClientRegistrationRepository中。
例如,給定這個application.yaml :
spring:
security:
oauth2:
client:
provider:
spring-auth-server:
issuer-uri: http://localhost:8080
registration:
test-client:
provider: spring-auth-server
client-name: test-client
client-id: xxxxx
client-secret: yyyy
authorization-grant-type:
- authorization_code
- refresh_token
- client_credentials
scope:
- openid
- email
- profile
Spring 將建立一個名為test-client ClientRegistration並將其傳遞到儲存庫。
稍後,當需要啟動身份驗證流程時,OAuth2 引擎會查詢此儲存庫並透過其註冊識別碼(在我們的範例中為test-client恢復註冊。
這裡的關鍵點是授權伺服器應該已經知道此時回傳的ClientRegistration 。這意味著為了支援動態客戶端,我們必須實作一個替代儲存庫並將其公開為@Bean 。
透過這樣做,Spring Boot 的自動配置將自動使用它而不是預設的。
6.2.動態用戶端註冊儲存庫
如預期的那樣,我們的實作必須實作ClientRegistration接口,該介面僅包含一個方法: findByRegistrationId() 。這就提出了一個問題: OAuth2引擎如何知道哪些註冊可用?畢竟,它可以在預設登入頁面上列出它們。
事實證明,Spring Security 希望儲存庫也實作Iterable<ClientRegistration>以便它可以列舉可用的客戶端:
public class DynamicClientRegistrationRepository implements ClientRegistrationRepository, Iterable<ClientRegistration> {
private final RegistrationDetails registrationDetails;
private final Map<String, ClientRegistration> staticClients;
private final RegistrationRestTemplate registrationClient;
private final Map<String, ClientRegistration> registrations = new HashMap<>();
// ... implementation omitted
}
我們的課程需要一些輸入才能工作:
-
RegistrationDetails記錄,其中包含執行動態註冊所需的所有參數 - 將動態註冊的客戶
Map - 用於存取授權伺服器的
RestTemplate
請注意,對於此範例,我們假設所有客戶端都將在同一授權伺服器上註冊。
另一個重要的設計決策是定義動態註冊何時發生。在這裡,我們將採用一種簡單的方法並公開一個公共doRegistrations()方法,該方法將註冊所有已知客戶端並保存返回的客戶端標識符和機密以供以後使用:
public void doRegistrations() {
staticClients.forEach((key, value) -> findByRegistrationId(key));
}
此實作為傳遞給建構函數的每個靜態客戶端呼叫findByRegistrationId() 。此方法檢查給定標識符是否存在有效註冊,如果缺少,則觸發實際的註冊程序。
6.3.動態註冊
doRegistration()函數是實際操作發生的地方:
private ClientRegistration doRegistration(String registrationId) {
String token = createRegistrationToken();
var staticRegistration = staticClients.get(registrationId);
var body = Map.of(
"client_name", staticRegistration.getClientName(),
"grant_types", List.of(staticRegistration.getAuthorizationGrantType()),
"scope", String.join(" ", staticRegistration.getScopes()),
"redirect_uris", List.of(resolveCallbackUri(staticRegistration)));
var headers = new HttpHeaders();
headers.setBearerAuth(token);
headers.setContentType(MediaType.APPLICATION_JSON);
var request = new RequestEntity<>(
body,
headers,
HttpMethod.POST,
registrationDetails.registrationEndpoint());
var response = registrationClient.exchange(request, ObjectNode.class);
// ... error handling omitted
return createClientRegistration(staticRegistration, response.getBody());
}
首先,我們必須取得呼叫註冊端點所需的註冊令牌。請注意,我們必須為每次註冊嘗試取得一個新令牌,因為如 Spring Authorization 的伺服器文件中所述,我們只能使用此令牌一次。
接下來,我們使用靜態註冊物件中的資料建構註冊有效負載,新增所需的authorization和content-type標頭,並將請求傳送到註冊端點。
最後,我們使用回應資料建立最終的ClientRegistration ,它將保存在儲存庫的快取中並返回到 OAuth2 引擎。
6.4.註冊動態儲存庫@Bean
要完成我們的客戶端,最後一個必要步驟是將DynamicClientRegistrationRepository公開為@Bean 。讓我們為此創建一個@Configuration類別:
@Bean
ClientRegistrationRepository dynamicClientRegistrationRepository( DynamicClientRegistrationRepository.RegistrationRestTemplate restTemplate) {
var registrationDetails = new DynamicClientRegistrationRepository.RegistrationDetails(
registrationProperties.getRegistrationEndpoint(),
registrationProperties.getRegistrationUsername(),
registrationProperties.getRegistrationPassword(),
registrationProperties.getRegistrationScopes(),
registrationProperties.getGrantTypes(),
registrationProperties.getRedirectUris(),
registrationProperties.getTokenEndpoint());
Map<String,ClientRegistration> staticClients = (new OAuth2ClientPropertiesMapper(clientProperties)).asClientRegistrations();
var repo = new DynamicClientRegistrationRepository(registrationDetails, staticClients, restTemplate);
repo.doRegistrations();
return repo;
}
@Bean註解的dynamicClientRegistrationRepository()方法透過先從可用屬性填入RegistrationDetails記錄來建立儲存庫。
其次,它利用 SpringBoot 自動配置模組中可用的OAuth2ClientPropertiesMapper類別來建立staticClient映射。這種方法使我們能夠以最小的努力快速地從靜態客戶端切換到動態客戶端並返回,因為兩者的配置結構是相同的。
7. 測試
最後,讓我們進行一些整合測試。首先,我們啟動伺服器應用程序,將其配置為偵聽連接埠 8080:
[ server ] $ mvn spring-boot:run
... lots of messages omitted
[ main] cbssaAuthorizationServerApplication : Started AuthorizationServerApplication in 2.222 seconds (process running for 2.454)
[ main] osbaApplicationAvailabilityBean : Application availability state LivenessState changed to CORRECT
[ main] osbaApplicationAvailabilityBean : Application availability state ReadinessState changed to ACCEPTING_TRAFFIC
接下來,是時候在另一個 shell 中啟動客戶端了:
[client] $ mvn spring-boot:run
// ... lots of messages omitted
[ restartedMain] osbdaOptionalLiveReloadServer : LiveReload server is running on port 35729
[ restartedMain] osbwembedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8090 (http) with context path ''
[ restartedMain] dcDynamicRegistrationClientApplication : Started DynamicRegistrationClientApplication in 2.063 seconds (process running for 2.425)
這兩個應用程式都以偵錯屬性集運行,因此它們會產生大量日誌訊息。特別是,我們可以看到對授權伺服器的/connect/register端點的呼叫:
[nio-8080-exec-3] ossecurity.web.FilterChainProxy : Securing POST /connect/register
// ... lots of messages omitted
[nio-8080-exec-3] ClientRegistrationAuthenticationProvider : Retrieved authorization with initial access token
[nio-8080-exec-3] ClientRegistrationAuthenticationProvider : Validated client registration request parameters
[nio-8080-exec-3] ssarCustomRegisteredClientRepository : Saving registered client: id=30OTlhO1Fb7UF110YdXULEDbFva4Uc8hPBGMfi60Wik, name=test-client
在客戶端,我們可以看到一則訊息,其中包含註冊識別碼( test-client )和對應的client_id:
[ restartedMain] sdccOAuth2DynamicClientConfiguration : Creating a dynamic client registration repository
[ restartedMain] .csDynamicClientRegistrationRepository : findByRegistrationId: test-client
[ restartedMain] .csDynamicClientRegistrationRepository : doRegistration: registrationId=test-client
[ restartedMain] .csDynamicClientRegistrationRepository : creating ClientRegistration: registrationId=test-client, client_id=30OTlhO1Fb7UF110YdXULEDbFva4Uc8hPBGMfi60Wik
如果我們開啟瀏覽器並將其指向http://localhost:8090 ,我們將被重新導向到登入頁面。請注意,網址列中的 URL 變更為http://localhost:8080 ,這表示該頁面來自授權伺服器。
測試憑證是user1/password 。一旦我們將它們放入表格並發送,我們將返回到客戶的主頁。由於我們現在已經通過身份驗證,因此我們將看到一個頁面,其中包含從授權令牌中提取的一些詳細資訊。
八、結論
在本教程中,我們展示瞭如何啟用 Spring 授權伺服器的動態註冊功能並從基於 Spring Security 的客戶端應用程式使用它。
與往常一樣,所有程式碼都可以在 GitHub 上取得。