在 Spring Authorization Server 中动态注册客户端

1、简介

Spring Authorization Server(授权服务器)自带一系列合理的默认设置,开箱即用。

但是,它还有一个功能,默认下没有启动:态客户端注册。本文将带你了解如何在客户端应用中启用和使用它。

2、为什么使用动态注册?

当基于 OAuth2 的客户端应用(在 OIDC 术语中称为依赖方)启动认证流程时,它将自己的客户端标识符发送给身份提供者(Provider)。

一般情况下,这个标识符是通过外部流程(如邮件发送等其他手段)发放给客户端的,客户端随后将其添加到配置中,并在需要时使用。

例如,在使用 Azure 的 EntraID 或 Auth0 等流行的身份提供商(Identity Provider)解决方案时,我们可以使用管理控制台或 API 来配置新客户端。在此过程中,我们需要告知应用名称、授权回调 URL、支持的作用域等信息。

提供所需信息后,我们会得到一个新的客户端标识符,对于所谓的 “secret” 客户端,还将得到一个 client secret。然后,我们将这些信息添加到应用的配置中,就可以开始部署了。

现在,当我们应用不多,或者总是使用单一的一个身份供应商时(Identity Provider),这种方式就能正常工作。但对于更复杂的情况,注册过程需要是动态的,这就是 OpenID Connect 动态客户端注册规范 的用武之地。

在现实世界中,英国的 OpenBanking 标准就是一个很好的例子,该标准将动态客户注册作为其核心协议之一。

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 依赖:

<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 是配置授权服务器所有方面的入口,包括注册端点。这个配置应该作为创建 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();
    }

    // 。。。 其他 Bean
}

如上,我们使用 OAuth2AuthorizationServerConfigureroidc() 方法来访问 OidConfigurer 实例,该方法允许我们控制与 OpenID Connect 标准相关的端点。要启用注册端点,我们使用带有默认配置的 clientRegistrationEndpoint() 方法。这将在 /connect/register 路径下启用注册端点,并使用 Bearer Token 授权。其他配置选项包括:

  • 定义自定义认证
  • 对收到的注册数据进行自定义处理
  • 对发送给客户端的响应进行自定义处理

现在,由于我们提供的是自定义的 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 授权服务器要求客户端使用客户端凭证流(Client Credentials Flow)来生成该 Token,从而解决了这个先有鸡还是先有蛋的问题。

此 Token 请求所需的 scope 是 client.create,客户端必须使用服务器支持的认证方案之一。在这里,我们使用 Basic 凭证,但在实际场景中,我们也可以使用其他方法。

从授权服务器的角度来看,这个注册客户端只是另一个客户端。因此,我们使用 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 来配置 client IDsecret 属性。

5.3、自定义 RegisteredClientRepository

Spring 授权服务器使用配置的 RegisteredClientRepository 实现将所有注册客户端存储在服务器中。开箱即用的是基于内存和 JDBC 的实现,涵盖了基本用例。

然而,这些实现在保存注册信息之前并没有提供任何自定义的能力。在我们的案例中,我们希望修改默认的 ClientProperties 设置,这样在授权用户时就不需要 ConsentPKCE

我们的实现将大多数方法委托给构建时传递的实际 Repository。重要的例外是 save() 方法:

@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 依赖开始:

<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、Security 配置

默认情况下,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-clientClientRegistration 并将其传递给 Repository。

之后,当需要启动身份认证流程时,OAuth2 引擎就会查询该 Repository,并根据其注册标识符(在我们的例子中为 test-client)恢复注册信息。

这里的关键点是,授权服务器应该已经知道此时返回的 ClientRegistration。这意味着,为了支持动态客户端,我们必须实现一个替代 Repository,并将其作为 @Bean 暴露。

这样,Spring Boot 的自动配置就会自动使用它,而不是默认配置。

6.2、动态 ClientRegistration Repository

我们必须实现 ClientRegistration 接口,而该接口只包含一个方法:findByRegistrationId()。这就有一个问题: OAuth2 引擎如何知道哪些注册信息是可用的?毕竟,它可以在默认登录页面上列出这些注册信息。

事实证明,Spring Security 也希望 Repository 也能实现 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<>();

    // 实现省略。。。
}

该类需要一些关键属性才可以运行:

  • 一个 RegistrationDetails,其中包含执行动态注册所需的所有参数
  • 存储动态注册的 ClientRegistrationMap
  • 用于访问授权服务器的 RestTemplate

注意,在本例中,我们假设所有客户端都在同一授权服务器上进行注册。

另一个重要的设计决策是定义何时进行动态注册。这里,我们采取一种简单的方法,公开 doRegistrations() 方法,该方法将注册所有已知客户端,并保存返回的客户端标识符和 secret,以供以后使用:

public void doRegistrations() {
    staticClients.forEach((key, value) -> findByRegistrationId(key));
}

对于传递给构造函数的每个 staticClients,实现过程都会调用 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);
    // ... 省略异常处理
    return createClientRegistration(staticRegistration, response.getBody());
}

首先,我们必须获取调用注册端点所需的注册 Token。注意,我们必须为每次注册尝试获取一个新 Token,因为正如 Spring Authorization 的服务器文档所述,我们只能使用该 Token 一次。

接下来,使用静态注册对象中的数据构建注册 Payload,添加所需的 authorizationcontent-type Header,然后将请求发送到注册端点。

最后,使用响应数据创建最终的 ClientRegistration,并将其保存在 Repository 的缓存中,然后返回给 OAuth2 引擎。

6.4、注册 ClientRegistrationRepository @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 记录,从而创建 Repository。

其次,它利用 Spring Boot 自动配置模块中的 OAuth2ClientPropertiesMapper 类创建 staticClient map。由于两者的配置结构相同,因此这种方法能让我们以最小的工作量快速从静态客户端(staticClients)切换到动态客户端,然后再切换回来。

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
// ... 省略其他消息
[  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 端点的调用:

[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。将其填入表单并发送后,就会返回客户端主页。由于我们现在已通过身份认证,我们可以看到一个页面,其中包含从 Authorization Token 中提取的一些详细信息。

8、总结

本文介绍了如何启用 Spring Authorization Server 的动态注册功能,并在基于 Spring Security 的客户端应用中使用该功能。


Ref:https://www.baeldung.com/spring-dynamic-client-registration