1. 引言
Spring Authorization Server 提供了多種合理的默認配置,允許我們幾乎無需配置即可使用。這使得它成為在測試場景中使用客户端應用程序以及我們希望完全控制用户登錄體驗的理想選擇。
儘管該功能可用,但並非默認啓用:動態客户端註冊。
在本教程中,我們將演示如何啓用並從客户端應用程序中使用它。
2. 使用動態註冊的原因?
當基於 OAuth2 的應用客户端(或在 OIDC 術語中稱為“信任方” - RP)啓動身份驗證流程時,它會將自身的客户端標識符發送給身份提供商。
這個標識符通常通過脱機方式頒發給客户端,然後將其添加到配置中,並在需要時使用。
例如,在使用流行的身份提供商解決方案,如 Azure 的 EntraID 或 Auth0 時,我們可以使用管理控制枱或 API 為新客户端提供上游。 在此過程中,我們需要提供應用名稱、授權回調 URL、支持的範圍等信息。
一旦我們提供了所需的信息,我們就會獲得一個新的客户端標識符,以及對於所謂的“秘密”客户端,還有一個客户端密鑰。 然後我們將這些添加到應用程序的配置中,我們就可以部署它。
現在,當我們在少量應用程序或始終使用單個身份提供商時,此過程可以正常工作。 但是,對於更復雜的場景,註冊過程需要動態化,這時 OpenID Connect 動態客户端註冊規範(https://openid.net/specs/openid-connect-registration-1_0.html)就派上用場了。
例如,英國的 https://www.openbanking.org.uk/ 標準,就使用動態客户端註冊作為其核心協議之一。
3. 動態註冊是如何工作的?
OpenID Connect 標準使用單個註冊 URL,客户端使用該 URL 註冊自身。 這通過向該 URL 發送 POST 請求,請求包含客户端元數據,以執行註冊操作。
重要的是,訪問註冊端點需要身份驗證,通常是 Bearer 令牌。 這當然引出了一個問題: 怎樣才能獲得令牌來執行此操作的希望值客户端?
不幸的是,答案不明確。 一方面,規範指出該端點是一個受保護的資源,因此需要某種形式的身份驗證。 另一方面,它也提到了開放註冊端點的可能性。
對於 Spring Authorization Server,註冊需要使用帶有 client.create 範圍的 Bearer 令牌。 為了創建此令牌,我們使用標準的 OAuth2 令牌端點和基本憑據。
以下是成功的註冊過程:
一旦客户端完成成功的註冊,它就可以使用返回的 client id 和 secret 來執行任何標準的授權流程。
4. 實現動態註冊
現在我們已經瞭解了所需的步驟,讓我們使用兩個 Spring Boot 應用程序創建一個測試場景。一個將託管 Spring 授權服務器,另一個將是一個簡單的 WebMVC 應用程序,它使用 Spring Security Outh2 登錄啓動模塊。
與其使用常規的靜態配置來配置客户端,後者將使用動態註冊端點在啓動時獲取客户端標識符和密鑰。
讓我們從服務器開始。
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 Authorization Server 應用程序,這個依賴項就足夠了。 但是,出於安全考慮,動態註冊功能默認未啓用。 截至目前,僅使用配置屬性也無法啓用它。
這意味着我們必須添加一些代碼——最終。
5.1. 啓用動態註冊
OAuth2AuthorizationServerConfigurer 是配置 Authorization Server 所有方面的入口,包括註冊端點。 此配置應作為 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實例。 此子配置器提供了方法,允許我們控制與開放身份連接標準相關的端點。 要啓用註冊端點,我們使用 clientRegististrationEndpoint()方法,並使用默認配置。 這將啓用在 connect/register路徑上的註冊,並使用 bearer 令牌認證。 進一步的配置選項包括:
- 定義自定義身份驗證
- 對接收到的註冊數據進行自定義處理
- 自定義處理客户端發送的響應
現在,由於我們提供了一個自定義 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. 註冊客户端配置
如上所述,註冊機制本身要求客户端發送 bearer token。Spring Authorization Server 通過要求客户端使用 client credentials flow 來解決雞生蛋,蛋生雞的難題。
用於此 token 請求的 required scope 是 client.create,客户端必須使用服務器支持的一種認證方案。在這裏,我們將使用 Basic credentials,但在實際場景中,我們可以使用其他方法。
這個註冊客户端,從 Authorization Server 的角度來看,只是另一個客户端。因此,我們將使用 RegisteredClient 流式 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</h3
Spring Authorization Server 使用配置的 RegisteredClientRepository 實現來存儲所有註冊的客户端。默認情況下,它提供了基於內存和 JDBC 的實現,覆蓋了基本用例。
這些實現並不能提供任何自定義註冊的機制。在本例中,我們希望修改默認的 ClientProperties 設置,以便在授權用户時不需要 consent 或 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,並根據需要修改 ClientSettings。 隨後,這個新的註冊信息會被傳遞到後端進行存儲,直到需要時使用。
以上完成了服務器端的實現。現在,我們來轉向客户端。
6. 動態註冊客户端實現
我們的客户端也將是一個標準的 Spring Web MVC 應用,包含一個顯示當前用户信息的單頁。Spring Security,更具體地説,其 OAuth2 登錄模塊將處理所有安全方面。
以下是所需的 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 上獲取:
6.1. 安全配置
默認情況下,Spring Boot 的自動配置機制會利用可用的 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 的客户端註冊信息,並將其傳遞給存儲庫。
稍後,當需要啓動認證流程時,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() 方法首先通過填充可用的屬性來創建倉庫。
其次,它利用了 SpringBoot 自配置模塊中提供的 OAuth2ClientPropertiesMapper 類創建了 staticClient 映射。 這種方法允許我們以最少的努力快速地在靜態客户端和動態客户端之間切換,因為兩種客户端的配置結構相同。
7. 測試
最後,我們進行一些集成測試。首先,啓動服務器應用程序,該應用程序配置為監聽 8080 端口:
[ server ] $ mvn spring-boot:run
... lots of messages omitted
[ main] c.b.s.s.a.AuthorizationServerApplication : Started AuthorizationServerApplication in 2.222 seconds (process running for 2.454)
[ main] o.s.b.a.ApplicationAvailabilityBean : Application availability state LivenessState changed to CORRECT
[ main] o.s.b.a.ApplicationAvailabilityBean : Application availability state ReadinessState changed to ACCEPTING_TRAFFIC接下來,是時候在另一個 shell 中啓動客户端:
[client] $ mvn spring-boot:run
// ... lots of messages omitted
[ restartedMain] o.s.b.d.a.OptionalLiveReloadServer : LiveReload server is running on port 35729
[ restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8090 (http) with context path ''
[ restartedMain] d.c.DynamicRegistrationClientApplication : Started DynamicRegistrationClientApplication in 2.063 seconds (process running for 2.425)
兩個應用程序均以調試屬性設置為運行,因此會產生大量的日誌消息。特別是,我們可以看到對身份驗證服務器 connect/register端點的調用:
[nio-8080-exec-3] o.s.security.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] s.s.a.r.CustomRegisteredClientRepository : Saving registered client: id=30OTlhO1Fb7UF110YdXULEDbFva4Uc8hPBGMfi60Wik, name=test-client在客户端,我們可以看到一個帶有註冊標識符 (test-client) 和其對應的 client_id: 的消息。
[ restartedMain] s.d.c.c.OAuth2DynamicClientConfiguration : Creating a dynamic client registration repository
[ restartedMain] .c.s.DynamicClientRegistrationRepository : findByRegistrationId: test-client
[ restartedMain] .c.s.DynamicClientRegistrationRepository : doRegistration: registrationId=test-client
[ restartedMain] .c.s.DynamicClientRegistrationRepository : creating ClientRegistration: registrationId=test-client, client_id=30OTlhO1Fb7UF110YdXULEDbFva4Uc8hPBGMfi60Wik如果我們在瀏覽器中訪問 http://localhost:8090,我們將被重定向到登錄頁面。 請注意地址欄中的 URL 已更改為 http://localhost:8080,這表明該頁面來自授權服務器。
測試憑據是 user1/password。 提交表單併發送後,我們將返回客户端的主頁。 由於我們現在已認證,因此將看到包含從授權令牌中提取的一些詳細信息的頁面。
8. 結論
在本教程中,我們演示瞭如何啓用 Spring Authorization Server 的動態註冊功能,並從基於 Spring Security 的客户端應用程序中對其進行使用。