【问题标题】:How to customize the Authorization header of the OAuth2 token request using spring-security-oauth2 with a WebClient?如何使用带有 WebClient 的 spring-security-oauth2 自定义 OAuth2 令牌请求的授权标头?
【发布时间】:2021-09-19 02:50:06
【问题描述】:

我正在尝试通过 WebClient 调用升级到 Spring Security 5.5.1。 我发现 oauth2 clientIdsecret 现在是在AbstractWebClientReactiveOAuth2AccessTokenResponseClient 中编码的 URL,但我的令牌提供程序不支持这一点(例如,如果秘密包含 @987654325 @ 字符仅当它作为 + 而不是 %2B 发送时才有效。 我知道这被视为 bug fix from spring-security side ),但我无法让令牌提供者轻松改变其行为。

所以我试图找到解决这个问题的方法。

当您使用 WebClient 配置(我的情况)时,关于如何自定义访问令牌请求的 [文档] (https://docs.spring.io/spring-security/site/docs/current/reference/html5/#customizing-the-access-token-request) 似乎并不适用。

为了删除 clientid/secret 编码,我必须扩展和复制大部分现有代码从 AbstractWebClientReactiveOAuth2AccessTokenResponseClient 自定义 WebClientReactiveClientCredentialsTokenResponseClient,因为其中大部分具有私有/默认可见性。 我在 spring-security 项目的 enhancement issue 中跟踪了这一点。

有没有更简单的方法来自定义令牌请求的 Authorization 标头,以跳过 url 编码?

【问题讨论】:

    标签: spring-security spring-webflux spring-security-oauth2 spring-webclient


    【解决方案1】:

    围绕自定义的某些 API 肯定有改进的空间,而且来自社区的这些类型的问题/请求/问题肯定会继续帮助突出这些领域。

    特别是关于AbstractWebClientReactiveOAuth2AccessTokenResponseClient,目前无法覆盖内部方法来填充Authorization 标头中的基本身份验证凭据。但是,您可以自定义用于进行 API 调用的WebClient。如果它在您的用例中是可以接受的(暂时,在处理行为更改和/或添加自定义选项时),您应该能够拦截 WebClient 中的请求。

    下面的配置将创建一个能够使用OAuth2AuthorizedClientWebClient

    @Configuration
    public class WebClientConfiguration {
    
        @Bean
        public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) {
            // @formatter:off
            ServerOAuth2AuthorizedClientExchangeFilterFunction exchangeFilterFunction =
                    new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
            exchangeFilterFunction.setDefaultOAuth2AuthorizedClient(true);
    
            return WebClient.builder()
                    .filter(exchangeFilterFunction)
                    .build();
            // @formatter:on
        }
    
        @Bean
        public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
                ReactiveClientRegistrationRepository clientRegistrationRepository,
                ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {
            // @formatter:off
            WebClientReactiveClientCredentialsTokenResponseClient accessTokenResponseClient =
                    new WebClientReactiveClientCredentialsTokenResponseClient();
            accessTokenResponseClient.setWebClient(createAccessTokenResponseWebClient());
    
            ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
                    ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
                            .clientCredentials(consumer ->
                                    consumer.accessTokenResponseClient(accessTokenResponseClient)
                                            .build())
                            .build();
    
            DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager =
                    new DefaultReactiveOAuth2AuthorizedClientManager(
                            clientRegistrationRepository, authorizedClientRepository);
            authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
            // @formatter:on
    
            return authorizedClientManager;
        }
    
        protected WebClient createAccessTokenResponseWebClient() {
            // @formatter:off
            return WebClient.builder()
                    .filter((clientRequest, exchangeFunction) -> {
                        HttpHeaders headers = clientRequest.headers();
                        String authorizationHeader = headers.getFirst("Authorization");
                        Assert.notNull(authorizationHeader, "Authorization header cannot be null");
                        Assert.isTrue(authorizationHeader.startsWith("Basic "),
                                "Authorization header should start with Basic");
                        String encodedCredentials = authorizationHeader.substring("Basic ".length());
                        byte[] decodedBytes = Base64.getDecoder().decode(encodedCredentials);
                        String credentialsString = new String(decodedBytes, StandardCharsets.UTF_8);
                        Assert.isTrue(credentialsString.contains(":"), "Decoded credentials should contain a \":\"");
                        String[] credentials = credentialsString.split(":");
                        String clientId = URLDecoder.decode(credentials[0], StandardCharsets.UTF_8);
                        String clientSecret = URLDecoder.decode(credentials[1], StandardCharsets.UTF_8);
    
                        ClientRequest newClientRequest = ClientRequest.from(clientRequest)
                                .headers(httpHeaders -> httpHeaders.setBasicAuth(clientId, clientSecret))
                                .build();
                        return exchangeFunction.exchange(newClientRequest);
                    })
                    .build();
            // @formatter:on
        }
    
    }
    

    此测试表明凭据已针对内部访问令牌响应 WebClient 进行解码:

    @ExtendWith(MockitoExtension.class)
    public class WebClientConfigurationTests {
    
        private WebClientConfiguration webClientConfiguration;
    
        @Mock
        private ExchangeFunction exchangeFunction;
    
        @Captor
        private ArgumentCaptor<ClientRequest> clientRequestCaptor;
    
        @BeforeEach
        public void setUp() {
            webClientConfiguration = new WebClientConfiguration();
        }
    
        @Test
        public void exchangeWhenBasicAuthThenDecoded() {
            WebClient webClient = webClientConfiguration.createAccessTokenResponseWebClient()
                    .mutate()
                    .exchangeFunction(exchangeFunction)
                    .build();
            when(exchangeFunction.exchange(any(ClientRequest.class)))
                    .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK).build()));
    
            webClient.post()
                    .uri("/oauth/token")
                    .headers(httpHeaders -> httpHeaders.setBasicAuth("aladdin", URLEncoder.encode("open sesame", StandardCharsets.UTF_8)))
                    .retrieve()
                    .bodyToMono(Void.class)
                    .block();
    
            verify(exchangeFunction).exchange(clientRequestCaptor.capture());
    
            ClientRequest clientRequest = clientRequestCaptor.getValue();
            String authorizationHeader = clientRequest.headers().getFirst("Authorization");
            assertThat(authorizationHeader).isNotNull();
            String encodedCredentials = authorizationHeader.substring("Basic ".length());
            byte[] decodedBytes = Base64.getDecoder().decode(encodedCredentials);
            String credentialsString = new String(decodedBytes, StandardCharsets.UTF_8);
            String[] credentials = credentialsString.split(":");
    
            assertThat(credentials[0]).isEqualTo("aladdin");
            assertThat(credentials[1]).isEqualTo("open sesame");
        }
    
    }
    

    【讨论】:

    • 嗨,我在我的代码中测试了这个解决方案,它工作正常。无论如何,使用自定义 Web 客户端实际上似乎是个好主意,因为它使我能够自定义检索令牌时使用的超时。谢谢。
    最近更新 更多