403 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			403 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
# 在 Spring Authorization Server 中动态注册客户端
 | 
						||
 | 
						||
2024-08-27
 | 
						||
 | 
						||
[教程](https://springdoc.cn/categories/教程/)
 | 
						||
 | 
						||
## 1、简介
 | 
						||
 | 
						||
[Spring Authorization Server](https://springdoc.cn/spring-authorization-server/)(授权服务器)自带一系列合理的默认设置,开箱即用。
 | 
						||
 | 
						||
但是,它还有一个功能,默认下没有启动:**态客户端注册**。本文将带你了解如何在客户端应用中启用和使用它。
 | 
						||
 | 
						||
## 2、为什么使用动态注册?
 | 
						||
 | 
						||
当基于 OAuth2 的客户端应用(在 OIDC 术语中称为依赖方)启动认证流程时,它将自己的客户端标识符发送给身份提供者(Provider)。
 | 
						||
 | 
						||
一般情况下,这个标识符是通过外部流程(如邮件发送等其他手段)发放给客户端的,客户端随后将其添加到配置中,并在需要时使用。
 | 
						||
 | 
						||
例如,在使用 Azure 的 EntraID 或 Auth0 等流行的身份提供商(Identity Provider)解决方案时,我们可以使用管理控制台或 API 来配置新客户端。在此过程中,我们需要告知应用名称、授权回调 URL、支持的作用域等信息。
 | 
						||
 | 
						||
提供所需信息后,我们会得到一个新的客户端标识符,对于所谓的 “secret” 客户端,还将得到一个 *client secret*。然后,我们将这些信息添加到应用的配置中,就可以开始部署了。
 | 
						||
 | 
						||
现在,当我们应用不多,或者总是使用单一的一个身份供应商时(Identity Provider),这种方式就能正常工作。但对于更复杂的情况,注册过程需要是动态的,这就是 [OpenID Connect 动态客户端注册规范](https://openid.net/specs/openid-connect-registration-1_0.html) 的用武之地。
 | 
						||
 | 
						||
在现实世界中,英国的 [OpenBanking](https://www.openbanking.org.uk/) 标准就是一个很好的例子,该标准将动态客户注册作为其核心协议之一。
 | 
						||
 | 
						||
## 3、动态注册是如何实现的?
 | 
						||
 | 
						||
OpenID Connect 标准使用一个注册 URL,客户端使用该 URL 注册自己。注册是通过 POST 请求完成的,该请求包含一个 JSON 对象,其中有执行注册所需的客户端元数据。
 | 
						||
 | 
						||
**重要的是,访问注册端点需要身份认证,通常是一个 \*Bearer Token\*。当然,这就引出了一个问题:想成为客户端的人如何获得用于此操作的 Token?**
 | 
						||
 | 
						||
遗憾的是,答案并不明确。一方面,规范指出端点是受保护的资源,因此需要某种形式的身份认证。另一方面,它也提到了开放注册端点的可能性。
 | 
						||
 | 
						||
对于 Spring 授权服务器来说,注册需要一个具有 `client.create` scope 的 *Bearer Token*。要创建该令牌,我们需要使用常规 OAuth2 的 Token 端点和基本凭证。
 | 
						||
 | 
						||
动态注册的流程如下:
 | 
						||
 | 
						||

 | 
						||
 | 
						||
客户端注册成功后,就可以使用返回的客户端 ID 和 secret *secret* 执行任何标准授权流程。
 | 
						||
 | 
						||
## 4、实现动态注册
 | 
						||
 | 
						||
了解了所需的步骤后,让我们使用两个 Spring Boot 应用创建一个测试场景。一个托管 Spring 授权服务器,另一个是一个简单的 WebMVC 应用程序,它使用 Spring Security Outh2 Login Starter 模块。
 | 
						||
 | 
						||
我们先从服务器开始。
 | 
						||
 | 
						||
## 5、授权服务器的实现
 | 
						||
 | 
						||
首先添加所需的 Maven 依赖:
 | 
						||
 | 
						||
```xml
 | 
						||
<dependency>
 | 
						||
    <groupId>org.springframework.boot</groupId>
 | 
						||
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
 | 
						||
    <version>1.3.1</version>
 | 
						||
</dependency>
 | 
						||
```
 | 
						||
 | 
						||
最新版本可从 [Maven Central](https://mvnrepository.com/artifact/org.springframework.security/spring-security-oauth2-authorization-server) 获取。
 | 
						||
 | 
						||
对于普通的 *Spring Authorization Server* 来说,只需要这个依赖。
 | 
						||
 | 
						||
出于安全考虑,默认情况下不会启用动态注册。此外,截至本文撰写时,还 **无法通过配置属性来启用动态注册**,这意味着我们要通过一些代码来进行配置。
 | 
						||
 | 
						||
### 5.1、启用动态注册
 | 
						||
 | 
						||
`OAuth2AuthorizationServerConfigurer` 是配置授权服务器所有方面的入口,包括注册端点。这个配置应该作为创建 `SecurityFilterChain` Bean 的一部分完成:
 | 
						||
 | 
						||
```java
 | 
						||
@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();
 | 
						||
    }
 | 
						||
 | 
						||
    // 。。。 其他 Bean
 | 
						||
}
 | 
						||
```
 | 
						||
 | 
						||
如上,我们使用 `OAuth2AuthorizationServerConfigurer` 的 `oidc()` 方法来访问 `OidConfigurer` 实例,该方法允许我们控制与 OpenID Connect 标准相关的端点。要启用注册端点,我们使用带有默认配置的 `clientRegistrationEndpoint()` 方法。这将在 `/connect/register` 路径下启用注册端点,并使用 Bearer Token 授权。其他配置选项包括:
 | 
						||
 | 
						||
- 定义自定义认证
 | 
						||
- 对收到的注册数据进行自定义处理
 | 
						||
- 对发送给客户端的响应进行自定义处理
 | 
						||
 | 
						||
现在,由于我们提供的是自定义的 `SecurityFilterChain`,Spring Boot 默认的自动配置将不会生效,我们需要负责向配置中添加一些额外的部分。
 | 
						||
 | 
						||
尤其需要添加设置表单登录身份认证的逻辑:
 | 
						||
 | 
						||
```java
 | 
						||
@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 授权服务器要求客户端使用客户端凭证流(Client Credentials Flow)来生成该 Token,从而解决了这个先有鸡还是先有蛋的问题。
 | 
						||
 | 
						||
此 Token 请求所需的 scope 是 `client.create`,客户端必须使用服务器支持的认证方案之一。在这里,我们使用 [Basic 凭证](https://datatracker.ietf.org/doc/html/rfc7617),但在实际场景中,我们也可以使用其他方法。
 | 
						||
 | 
						||
从授权服务器的角度来看,这个注册客户端只是另一个客户端。因此,我们使用 `RegisteredClient` Fluent API 来创建它:
 | 
						||
 | 
						||
```java
 | 
						||
@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` 来配置 *client ID* 和 *secret* 属性。
 | 
						||
 | 
						||
### 5.3、自定义 RegisteredClientRepository
 | 
						||
 | 
						||
Spring 授权服务器使用配置的 `RegisteredClientRepository` 实现将所有注册客户端存储在服务器中。开箱即用的是基于内存和 JDBC 的实现,涵盖了基本用例。
 | 
						||
 | 
						||
然而,这些实现在保存注册信息之前并没有提供任何自定义的能力。在我们的案例中,我们希望修改默认的 `ClientProperties` 设置,这样在授权用户时就不需要 *Consent* 或 [PKCE](https://www.baeldung.com/spring-security-pkce-secret-clients)。
 | 
						||
 | 
						||
我们的实现将大多数方法委托给构建时传递的实际 Repository。重要的例外是 `save()` 方法:
 | 
						||
 | 
						||
```java
 | 
						||
@Override
 | 
						||
public void save(RegisteredClient registeredClient) {
 | 
						||
    Set<String> scopes = ( registeredClient.getScopes() == null || registeredClient.getScopes().isEmpty())?
 | 
						||
      Set.of("openid","email","profile"):
 | 
						||
      registeredClient.getScopes();
 | 
						||
 | 
						||
    // 禁用 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`,并根据需要更改客户端设置。然后,新注册的客户端将被传递到后台,并在需要时存储起来。
 | 
						||
 | 
						||
至此,服务器的实现就结束了。现在,开始客户端部分。
 | 
						||
 | 
						||
## 6、动态注册客户端的实现
 | 
						||
 | 
						||
我们的客户端也是一个标准的 Spring Web MVC 应用,只有一个页面显示当前用户信息。
 | 
						||
 | 
						||
Spring Security,或者更具体地说,其 *OAuth2* Login 模块,将处理所有安全方面的问题。
 | 
						||
 | 
						||
从所需的 Maven 依赖开始:
 | 
						||
 | 
						||
```xml
 | 
						||
<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、Security 配置
 | 
						||
 | 
						||
默认情况下,Spring Boot 的自动配置机制使用来自可用 `PropertySources` 的信息来收集所需数据,以创建一个或多个 `ClientRegistration` 实例,然后将其存储在基于内存的 `ClientRegistrationRepository` 中。
 | 
						||
 | 
						||
例如,给定的 `application.yaml` 如下:
 | 
						||
 | 
						||
```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` 并将其传递给 Repository。
 | 
						||
 | 
						||
之后,当需要启动身份认证流程时,OAuth2 引擎就会查询该 Repository,并根据其注册标识符(在我们的例子中为 `test-client`)恢复注册信息。
 | 
						||
 | 
						||
这里的关键点是,授权服务器应该已经知道此时返回的 `ClientRegistration`。这意味着,为了支持动态客户端,我们必须实现一个替代 Repository,并将其作为 `@Bean` 暴露。
 | 
						||
 | 
						||
这样,Spring Boot 的自动配置就会自动使用它,而不是默认配置。
 | 
						||
 | 
						||
### 6.2、动态 ClientRegistration Repository
 | 
						||
 | 
						||
我们必须实现 `ClientRegistration` 接口,而该接口只包含一个方法:`findByRegistrationId()`。这就有一个问题: OAuth2 引擎如何知道哪些注册信息是可用的?毕竟,它可以在默认登录页面上列出这些注册信息。
 | 
						||
 | 
						||
事实证明,Spring Security 也希望 Repository 也能实现 `Iterable<ClientRegistration>`,这样它就能枚举可用的客户端:
 | 
						||
 | 
						||
```java
 | 
						||
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<>();
 | 
						||
 | 
						||
    // 实现省略。。。
 | 
						||
}
 | 
						||
```
 | 
						||
 | 
						||
该类需要一些关键属性才可以运行:
 | 
						||
 | 
						||
- 一个 `RegistrationDetails`,其中包含执行动态注册所需的所有参数
 | 
						||
- 存储动态注册的 `ClientRegistration` 的 `Map`。
 | 
						||
- 用于访问授权服务器的 `RestTemplate`。
 | 
						||
 | 
						||
注意,在本例中,我们假设所有客户端都在同一授权服务器上进行注册。
 | 
						||
 | 
						||
另一个重要的设计决策是定义何时进行动态注册。这里,我们采取一种简单的方法,公开 `doRegistrations()` 方法,该方法将注册所有已知客户端,并保存返回的客户端标识符和 *secret*,以供以后使用:
 | 
						||
 | 
						||
```java
 | 
						||
public void doRegistrations() {
 | 
						||
    staticClients.forEach((key, value) -> findByRegistrationId(key));
 | 
						||
}
 | 
						||
```
 | 
						||
 | 
						||
对于传递给构造函数的每个 *staticClients*,实现过程都会调用 `findByRegistrationId()`。该方法会检查给定标识符是否存在有效注册,如果没有,则会触发实际注册流程。
 | 
						||
 | 
						||
### 6.3、动态注册
 | 
						||
 | 
						||
`doRegistration()` 函数才是真正发挥作用的地方:
 | 
						||
 | 
						||
```java
 | 
						||
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);
 | 
						||
    // ... 省略异常处理
 | 
						||
    return createClientRegistration(staticRegistration, response.getBody());
 | 
						||
}
 | 
						||
```
 | 
						||
 | 
						||
首先,我们必须获取调用注册端点所需的注册 Token。注意,我们必须为每次注册尝试获取一个新 Token,因为正如 Spring Authorization 的服务器文档所述,我们只能使用该 Token 一次。
 | 
						||
 | 
						||
接下来,使用静态注册对象中的数据构建注册 Payload,添加所需的 `authorization` 和 `content-type` Header,然后将请求发送到注册端点。
 | 
						||
 | 
						||
最后,使用响应数据创建最终的 `ClientRegistration`,并将其保存在 Repository 的缓存中,然后返回给 *OAuth2* 引擎。
 | 
						||
 | 
						||
### 6.4、注册 ClientRegistrationRepository @Bean
 | 
						||
 | 
						||
完成客户端的最后一步是将 `DynamicClientRegistrationRepository` 作为 `@Bean` 公开。
 | 
						||
 | 
						||
创建一个 `@Configuration` 类:
 | 
						||
 | 
						||
```java
 | 
						||
@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` 记录,从而创建 Repository。
 | 
						||
 | 
						||
其次,它利用 Spring Boot 自动配置模块中的 `OAuth2ClientPropertiesMapper` 类创建 *staticClient* map。由于两者的配置结构相同,因此这种方法能让我们以最小的工作量快速从静态客户端(*staticClients*)切换到动态客户端,然后再切换回来。
 | 
						||
 | 
						||
## 7、测试
 | 
						||
 | 
						||
最后,进行一些集成测试。首先,启动服务器应用,将其配置为监听 *8080* 端口:
 | 
						||
 | 
						||
```txt
 | 
						||
[ 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 中启动客户端:
 | 
						||
 | 
						||
```txt
 | 
						||
[client] $ mvn spring-boot:run
 | 
						||
// ... 省略其他消息
 | 
						||
[  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)
 | 
						||
```
 | 
						||
 | 
						||
这两个应用在运行时都设置了 *debug* 属性,因此会产生大量日志信息。重点是,我们可以看到对授权服务器 `/connect/register` 端点的调用:
 | 
						||
 | 
						||
```txt
 | 
						||
[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` 的信息:
 | 
						||
 | 
						||
```txt
 | 
						||
[  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`。将其填入表单并发送后,就会返回客户端主页。由于我们现在已通过身份认证,我们可以看到一个页面,其中包含从 Authorization Token 中提取的一些详细信息。
 | 
						||
 | 
						||
## 8、总结
 | 
						||
 | 
						||
本文介绍了如何启用 *Spring Authorization Server* 的动态注册功能,并在基于 Spring Security 的客户端应用中使用该功能。 |