如何使用带有 WebClient 的 spring-security-oauth2 自定义 OAuth2 令牌请求的授权 header?
How to customize the Authorization header of the OAuth2 token request using spring-security-oauth2 with a WebClient?
我正在尝试通过 WebClient 调用升级到 spring security 5.5.1。
我发现 oauth2 clientId 和 secret 现在 URL 在 AbstractWebClientReactiveOAuth2AccessTokenResponseClient
中编码,但我的令牌提供者没有支持这一点(例如,如果秘密包含 +
字符,则它仅在作为 +
而不是 %2B
发送时有效)。
我知道这被视为 bug fix from spring-security side ),但我无法让令牌提供者轻易改变其行为。
所以我试图找到解决这个问题的方法。
有关如何自定义访问令牌请求的[文档] (https://docs.spring.io/spring-security/site/docs/current/reference/html5/#customizing-the-access-token-request) 似乎不适用于您使用 WebClient 配置(我的情况)。
为了删除 clientid/secret 编码,我不得不扩展并复制 AbstractWebClientReactiveOAuth2AccessTokenResponseClient
中的大部分现有代码来自定义 WebClientReactiveClientCredentialsTokenResponseClient
,因为其中大部分都有 private/default 能见度。
我在 spring-security 项目的 enhancement issue 中追踪到了这一点。
是否有更简单的方法来自定义令牌请求的授权 header,以跳过 url 编码?
围绕自定义的一些 API 肯定有改进的余地,而且来自社区的这些类型 questions/requests/issues 肯定会继续帮助突出这些领域。
特别是关于 AbstractWebClientReactiveOAuth2AccessTokenResponseClient
,目前无法覆盖内部方法来填充 Authorization
header 中的基本身份验证凭据。但是,您可以自定义用于进行 API 调用的 WebClient
。如果它在您的用例中是可以接受的(暂时,在处理行为更改时 and/or 添加了自定义选项),您应该能够在 WebClient
.
中拦截请求
这里的配置将创建一个能够使用 OAuth2AuthorizedClient
的 WebClient
:
@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");
}
}
我正在尝试通过 WebClient 调用升级到 spring security 5.5.1。
我发现 oauth2 clientId 和 secret 现在 URL 在 AbstractWebClientReactiveOAuth2AccessTokenResponseClient
中编码,但我的令牌提供者没有支持这一点(例如,如果秘密包含 +
字符,则它仅在作为 +
而不是 %2B
发送时有效)。
我知道这被视为 bug fix from spring-security side ),但我无法让令牌提供者轻易改变其行为。
所以我试图找到解决这个问题的方法。
有关如何自定义访问令牌请求的[文档] (https://docs.spring.io/spring-security/site/docs/current/reference/html5/#customizing-the-access-token-request) 似乎不适用于您使用 WebClient 配置(我的情况)。
为了删除 clientid/secret 编码,我不得不扩展并复制 AbstractWebClientReactiveOAuth2AccessTokenResponseClient
中的大部分现有代码来自定义 WebClientReactiveClientCredentialsTokenResponseClient
,因为其中大部分都有 private/default 能见度。
我在 spring-security 项目的 enhancement issue 中追踪到了这一点。
是否有更简单的方法来自定义令牌请求的授权 header,以跳过 url 编码?
围绕自定义的一些 API 肯定有改进的余地,而且来自社区的这些类型 questions/requests/issues 肯定会继续帮助突出这些领域。
特别是关于 AbstractWebClientReactiveOAuth2AccessTokenResponseClient
,目前无法覆盖内部方法来填充 Authorization
header 中的基本身份验证凭据。但是,您可以自定义用于进行 API 调用的 WebClient
。如果它在您的用例中是可以接受的(暂时,在处理行为更改时 and/or 添加了自定义选项),您应该能够在 WebClient
.
这里的配置将创建一个能够使用 OAuth2AuthorizedClient
的 WebClient
:
@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");
}
}