likes
comments
collection
share

Spring Authorization Server 动态注册OAuthClient

作者站长头像
站长
· 阅读数 9

Spring Authorization Server

到目前为止,我注册OAuth客户端的时候,都是在代码里写死的。在实际生产环境中这样肯定是不行的,往往要按需动态添加OAuth客户端,提供给不同业务方去使用。很幸运的是Spring Authorization Server本身内置了该功能,不过需要我们配置一番。

1.注册配置

首先需要编写一个配置生成类:


/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/12 9:50
 */
@Configuration
public class ClientRegistrationConfig {

    @Autowired
    private RegisteredClientRepository registeredClientRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;


    @PostConstruct
    private void initClientRegistrationClient() {
        String clientId = "registrar";
        RegisteredClient registrarClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId(clientId)
                .clientSecret(passwordEncoder.encode("123456"))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .scope("client.create")
                .scope("client.read")
                .build();
        if (registeredClientRepository.findByClientId(clientId) != null) {
            return;
        }
        registeredClientRepository.save(registrarClient);

    }


    public static Consumer<List<AuthenticationProvider>> configureCustomClientMetadataConverters() {
        List<String> customClientMetadata = Arrays.asList("logo_uri", "contacts");

        return (authenticationProviders) -> {
            CustomRegisteredClientConverter registeredClientConverter =
                    new CustomRegisteredClientConverter(customClientMetadata);
            CustomClientRegistrationConverter clientRegistrationConverter =
                    new CustomClientRegistrationConverter(customClientMetadata);

            authenticationProviders.forEach((authenticationProvider) -> {
                if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider) {
                    OidcClientRegistrationAuthenticationProvider provider = (OidcClientRegistrationAuthenticationProvider) authenticationProvider;
                    provider.setRegisteredClientConverter(registeredClientConverter);
                    provider.setClientRegistrationConverter(clientRegistrationConverter);
                }
                if (authenticationProvider instanceof OidcClientConfigurationAuthenticationProvider) {
                    OidcClientConfigurationAuthenticationProvider provider = (OidcClientConfigurationAuthenticationProvider) authenticationProvider;
                    provider.setClientRegistrationConverter(clientRegistrationConverter);
                }
            });
        };
    }

    private static class CustomRegisteredClientConverter
            implements Converter<OidcClientRegistration, RegisteredClient> {

        private final List<String> customClientMetadata;
        private final OidcClientRegistrationRegisteredClientConverter delegate;

        private CustomRegisteredClientConverter(List<String> customClientMetadata) {
            this.customClientMetadata = customClientMetadata;
            this.delegate = new OidcClientRegistrationRegisteredClientConverter();
        }

        @Override
        public RegisteredClient convert(OidcClientRegistration clientRegistration) {
            RegisteredClient registeredClient = this.delegate.convert(clientRegistration);
            ClientSettings.Builder clientSettingsBuilder = ClientSettings.withSettings(
                    registeredClient.getClientSettings().getSettings());
            if (!CollectionUtils.isEmpty(this.customClientMetadata)) {
                clientRegistration.getClaims().forEach((claim, value) -> {
                    if (this.customClientMetadata.contains(claim)) {
                        clientSettingsBuilder.setting(claim, value);
                    }
                });
            }

            return RegisteredClient.from(registeredClient)
                    .clientSettings(clientSettingsBuilder.build())
                    .build();
        }
    }

    private static class CustomClientRegistrationConverter
            implements Converter<RegisteredClient, OidcClientRegistration> {

        private final List<String> customClientMetadata;
        private final RegisteredClientOidcClientRegistrationConverter delegate;

        private CustomClientRegistrationConverter(List<String> customClientMetadata) {
            this.customClientMetadata = customClientMetadata;
            this.delegate = new RegisteredClientOidcClientRegistrationConverter();
        }

        @Override
        public OidcClientRegistration convert(RegisteredClient registeredClient) {
            OidcClientRegistration clientRegistration = this.delegate.convert(registeredClient);
            Map<String, Object> claims = new HashMap<>(clientRegistration.getClaims());
            if (!CollectionUtils.isEmpty(this.customClientMetadata)) {
                ClientSettings clientSettings = registeredClient.getClientSettings();
                claims.putAll(this.customClientMetadata.stream()
                        .filter(metadata -> clientSettings.getSetting(metadata) != null)
                        .collect(Collectors.toMap(Function.identity(), clientSettings::getSetting)));
            }

            return OidcClientRegistration.withClaims(claims).build();
        }

    }

}

这里面干的事情是,定义了RegisteredClient和OidcClientRegistration两个类型之间互相转换的转换器,然后提供了一个静态方法,将这两个转换器配置到认证处理器中去,最后再往数据库中注册一个用户动态创建OidcClient的OidcClient客户端,他的授权类型是client_credentials类型,这样就可以通过用这个初始客户端的client_id和client_secret去调用AuthServer的API去创建其他OidcClient。

2.授权配置

在授权配置中需要调用上述静态方法去配置clientRegistration端点:

    /**
     * 授权服务器端点配置
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(
            HttpSecurity http,
            OAuth2AuthorizationService authorizationService,
            OAuth2TokenGenerator<?> tokenGenerator,
            RegisteredClientRepository registeredClientRepository,
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) throws Exception {

        // 配置默认的设置,忽略认证端点的csrf校验
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        // 开启OpenID Connect 1.0协议相关端点
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(Customizer.withDefaults());

        // 开启动态客户端注册
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(oidc -> oidc.clientRegistrationEndpoint(clientRegistrationEndpoint -> {
                    clientRegistrationEndpoint
                            .authenticationProviders(ClientRegistrationConfig.configureCustomClientMetadataConverters());
                }));
        // ...
        
        // 当未登录时访问认证端点时重定向至login页面
        http.exceptionHandling((exceptions) -> exceptions
                .defaultAuthenticationEntryPointFor(
                        new LoginUrlAuthenticationEntryPoint("/login"),
                        new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                ))
                // 处理使用access token访问用户信息端点和客户端注册端点
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .jwt(Customizer.withDefaults()));

        return http.build();
    }

完成以上配置后就可以启动AuthServer了。

3.动态注册

接下来我们就可以调用/connect/register接口去动态添加客户端了,以下是先获取access_token再注册client的示例:


/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/12 10:49
 */
public class ClientRegistrarTests {

    private final WebClient webClient = WebClient.create("http://127.0.0.1:8000");

    static class ClientRegistrationRequest {

        @JsonProperty("client_name")
        String clientName;
        @JsonProperty("grant_types")
        List<String> grantTypes;
        @JsonProperty("redirect_uris")
        List<String> redirectUris;
        @JsonProperty("logo_uri")
        String logoUri;
        @JsonProperty("contacts")
        List<String> contacts;
        @JsonProperty("scope")
        String scope;

        public ClientRegistrationRequest(String clientName, List<String> grantTypes, List<String> redirectUris, String logoUri, List<String> contacts, String scope) {
            this.clientName = clientName;
            this.grantTypes = grantTypes;
            this.redirectUris = redirectUris;
            this.logoUri = logoUri;
            this.contacts = contacts;
            this.scope = scope;
        }
    }


    static class ClientRegistrationResponse {

        @JsonProperty("registration_access_token")
        String registrationAccessToken;
        @JsonProperty("registration_client_uri")
        String registrationClientUri;
        @JsonProperty("client_name")
        String clientName;
        @JsonProperty("client_id")
        String clientId;
        @JsonProperty("client_secret")
        String clientSecret;
        @JsonProperty("grant_types")
        List<String> grantTypes;
        @JsonProperty("redirect_uris")
        List<String> redirectUris;
        @JsonProperty("logo_uri")
        String logoUri;
        @JsonProperty("contacts")
        List<String> contacts;
        @JsonProperty("scope")
        String scope;

        public ClientRegistrationResponse(String registrationAccessToken, String registrationClientUri, String clientName, String clientId, String clientSecret, List<String> grantTypes, List<String> redirectUris, String logoUri, List<String> contacts, String scope) {
            this.registrationAccessToken = registrationAccessToken;
            this.registrationClientUri = registrationClientUri;
            this.clientName = clientName;
            this.clientId = clientId;
            this.clientSecret = clientSecret;
            this.grantTypes = grantTypes;
            this.redirectUris = redirectUris;
            this.logoUri = logoUri;
            this.contacts = contacts;
            this.scope = scope;
        }
    }

    static class AccessToken {
        @JsonProperty("access_token")
        String accessToken;
        @JsonProperty("token_type")
        String tokenType;
        @JsonProperty("expires_in")
        Integer expiresIn;
        @JsonProperty("scope")
        String scope;

        public AccessToken(String accessToken, String tokenType, Integer expiresIn, String scope) {
            this.accessToken = accessToken;
            this.tokenType = tokenType;
            this.expiresIn = expiresIn;
            this.scope = scope;
        }
    }

    @Test
    public void exampleRegistration() {

        String clientId = "registrar";
        String clientSecret = "123456";
        MultiValueMap<String, String> formMap = new LinkedMultiValueMap<>();
        formMap.add("grant_type", "client_credentials");
        formMap.add("scope", "client.create");

        String basicAuth = Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
        // 获取access_token
        AccessToken accessToken = webClient.post().uri("/oauth2/token")
                .header("Authorization", "Basic " + basicAuth)
                .header("Content-Type", MediaType.MULTIPART_FORM_DATA_VALUE)
                .body(BodyInserters.fromFormData(formMap))
                .retrieve()
                .bodyToMono(AccessToken.class)
                .block();

        assert accessToken != null;
        String initialAccessToken = accessToken.accessToken;


//        (3)
        ClientRegistrationRequest clientRegistrationRequest = new ClientRegistrationRequest( // (4)
                "client-1",
                Collections.singletonList(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
                Arrays.asList("https://client.example.org/callback", "https://client.example.org/callback2"),
                "https://client.example.org/logo",
                Arrays.asList("contact-1", "contact-2"),
                "openid email profile"
        );

        ClientRegistrationResponse clientRegistrationResponse =
                registerClient(initialAccessToken, clientRegistrationRequest);
//        (5)

        assert (clientRegistrationResponse.clientName.contentEquals("client-1"));
//        (6)
        assert (!Objects.isNull(clientRegistrationResponse.clientSecret));
        assert (clientRegistrationResponse.scope.contains("openid"));
        assert (clientRegistrationResponse.scope.contains("profile"));
        assert (clientRegistrationResponse.scope.contains("email"));
        assert (clientRegistrationResponse.grantTypes.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
        assert (clientRegistrationResponse.redirectUris.contains("https://client.example.org/callback"));
        assert (clientRegistrationResponse.redirectUris.contains("https://client.example.org/callback2"));
        assert (!clientRegistrationResponse.registrationAccessToken.isEmpty());
        assert (!clientRegistrationResponse.registrationClientUri.isEmpty());
        assert (clientRegistrationResponse.logoUri.contentEquals("https://client.example.org/logo"));
        assert (clientRegistrationResponse.contacts.size() == 2);
        assert (clientRegistrationResponse.contacts.contains("contact-1"));
        assert (clientRegistrationResponse.contacts.contains("contact-2"));

        String registrationAccessToken = clientRegistrationResponse.registrationAccessToken;
//        (7)
        String registrationClientUri = clientRegistrationResponse.registrationClientUri;

        ClientRegistrationResponse retrievedClient = retrieveClient(registrationAccessToken, registrationClientUri);
//        (8)

        assert (retrievedClient.clientName.contentEquals("client-1"));
//        (9)
        assert (!Objects.isNull(retrievedClient.clientId));
        assert (!Objects.isNull(retrievedClient.clientSecret));
        assert (clientRegistrationResponse.scope.contains("openid"));
        assert (clientRegistrationResponse.scope.contains("profile"));
        assert (clientRegistrationResponse.scope.contains("email"));
        assert (retrievedClient.grantTypes.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
        assert (retrievedClient.redirectUris.contains("https://client.example.org/callback"));
        assert (retrievedClient.redirectUris.contains("https://client.example.org/callback2"));
        assert (retrievedClient.logoUri.contentEquals("https://client.example.org/logo"));
        assert (retrievedClient.contacts.size() == 2);
        assert (retrievedClient.contacts.contains("contact-1"));
        assert (retrievedClient.contacts.contains("contact-2"));
        assert (Objects.isNull(retrievedClient.registrationAccessToken));
        assert (!retrievedClient.registrationClientUri.isEmpty());
    }

    public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest request) {
//        (10)
        return this.webClient
                .post()
                .uri("/connect/register")
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + initialAccessToken)
                .body(Mono.just(request), ClientRegistrationRequest.class)
                .retrieve()
                .bodyToMono(ClientRegistrationResponse.class)
                .block();
    }

    public ClientRegistrationResponse retrieveClient(String registrationAccessToken, String registrationClientUri) {
//        (11)
        return this.webClient
                .get()
                .uri(registrationClientUri)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + registrationAccessToken)
                .retrieve()
                .bodyToMono(ClientRegistrationResponse.class)
                .block();
    }

}

执行这个exampleRegistration单元测试,顺利的话,就能看到数据库中添加了新的OAuth客户端了。