From 029ca5b962346589c452be8d5dab36f3078cd6bf Mon Sep 17 00:00:00 2001 From: chenkuangwei Date: Mon, 6 Jan 2025 13:21:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20java/oauth2-server.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: chenkuangwei --- java/oauth2-server.md | 336 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 java/oauth2-server.md diff --git a/java/oauth2-server.md b/java/oauth2-server.md new file mode 100644 index 0000000..56ee602 --- /dev/null +++ b/java/oauth2-server.md @@ -0,0 +1,336 @@ +在 Spring Authorization Server 中动态注册客户端 +2024-08-27教程 +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 依赖: + + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + 1.3.1 + +最新版本可从 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 +} +如上,我们使用 OAuth2AuthorizationServerConfigurer 的 oidc() 方法来访问 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 ID 和 secret 属性。 + +5.3、自定义 RegisteredClientRepository +Spring 授权服务器使用配置的 RegisteredClientRepository 实现将所有注册客户端存储在服务器中。开箱即用的是基于内存和 JDBC 的实现,涵盖了基本用例。 + +然而,这些实现在保存注册信息之前并没有提供任何自定义的能力。在我们的案例中,我们希望修改默认的 ClientProperties 设置,这样在授权用户时就不需要 Consent 或 PKCE。 + +我们的实现将大多数方法委托给构建时传递的实际 Repository。重要的例外是 save() 方法: + +@Override +public void save(RegisteredClient registeredClient) { + Set 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 依赖开始: + + + org.springframework.boot + spring-boot-starter-web + 3.3.2 + + + org.springframework.boot + spring-boot-starter-thymeleaf + 3.3.2 + + + org.springframework.boot + spring-boot-starter-oauth2-client + 3.3.2 + +这些依赖的最新版本可从 Maven Central 获取: + +spring-boot-starter-web +spring-boot-starter-thymeleaf +spring-boot-starter-oauth2-client +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-client 的 ClientRegistration 并将其传递给 Repository。 + +之后,当需要启动身份认证流程时,OAuth2 引擎就会查询该 Repository,并根据其注册标识符(在我们的例子中为 test-client)恢复注册信息。 + +这里的关键点是,授权服务器应该已经知道此时返回的 ClientRegistration。这意味着,为了支持动态客户端,我们必须实现一个替代 Repository,并将其作为 @Bean 暴露。 + +这样,Spring Boot 的自动配置就会自动使用它,而不是默认配置。 + +6.2、动态 ClientRegistration Repository +我们必须实现 ClientRegistration 接口,而该接口只包含一个方法:findByRegistrationId()。这就有一个问题: OAuth2 引擎如何知道哪些注册信息是可用的?毕竟,它可以在默认登录页面上列出这些注册信息。 + +事实证明,Spring Security 也希望 Repository 也能实现 Iterable,这样它就能枚举可用的客户端: + +public class DynamicClientRegistrationRepository implements ClientRegistrationRepository, Iterable { + private final RegistrationDetails registrationDetails; + private final Map staticClients; + private final RegistrationRestTemplate registrationClient; + private final Map registrations = new HashMap<>(); + + // 实现省略。。。 +} +该类需要一些关键属性才可以运行: + +一个 RegistrationDetails,其中包含执行动态注册所需的所有参数 +存储动态注册的 ClientRegistration 的 Map。 +用于访问授权服务器的 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,添加所需的 authorization 和 content-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 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 的客户端应用中使用该功能。 \ No newline at end of file