Add support for access token in body parameter as per rfc 6750 Sec. 2.2

Issue gh-15818
This commit is contained in:
Jonah Klöckner 2024-09-17 11:17:32 +02:00 committed by Steve Riesenberg
parent 03e090c2d7
commit 9674532f4d
No known key found for this signature in database
GPG Key ID: 3D0169B18AB8F0A9
2 changed files with 198 additions and 29 deletions

View File

@ -16,14 +16,20 @@
package org.springframework.security.oauth2.server.resource.web.server.authentication;
import static org.springframework.security.oauth2.server.resource.BearerTokenErrors.invalidRequest;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import reactor.util.function.Tuples;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
@ -47,16 +53,20 @@ import org.springframework.web.server.ServerWebExchange;
*/
public class ServerBearerTokenAuthenticationConverter implements ServerAuthenticationConverter {
public static final String ACCESS_TOKEN_NAME = "access_token";
public static final String MULTIPLE_BEARER_TOKENS_ERROR_MSG = "Found multiple bearer tokens in the request";
private static final Pattern authorizationPattern = Pattern.compile("^Bearer (?<token>[a-zA-Z0-9-._~+/]+=*)$",
Pattern.CASE_INSENSITIVE);
private boolean allowUriQueryParameter = false;
private boolean allowFormEncodedBodyParameter = false;
private String bearerTokenHeaderName = HttpHeaders.AUTHORIZATION;
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return Mono.fromCallable(() -> token(exchange.getRequest())).map((token) -> {
return Mono.defer(() -> token(exchange)).map(token -> {
if (token.isEmpty()) {
BearerTokenError error = invalidTokenError();
throw new OAuth2AuthenticationException(error);
@ -65,43 +75,53 @@ public class ServerBearerTokenAuthenticationConverter implements ServerAuthentic
});
}
private String token(ServerHttpRequest request) {
String authorizationHeaderToken = resolveFromAuthorizationHeader(request.getHeaders());
String parameterToken = resolveAccessTokenFromRequest(request);
private Mono<String> token(ServerWebExchange exchange) {
final ServerHttpRequest request = exchange.getRequest();
if (authorizationHeaderToken != null) {
if (parameterToken != null) {
BearerTokenError error = BearerTokenErrors
.invalidRequest("Found multiple bearer tokens in the request");
throw new OAuth2AuthenticationException(error);
}
return authorizationHeaderToken;
}
if (parameterToken != null && !StringUtils.hasText(parameterToken)) {
BearerTokenError error = BearerTokenErrors
.invalidRequest("The requested token parameter is an empty string");
throw new OAuth2AuthenticationException(error);
}
return parameterToken;
return Flux.merge(resolveFromAuthorizationHeader(request.getHeaders()).map(s -> Tuples.of(s, TokenSource.HEADER)),
resolveAccessTokenFromRequest(request).map(s -> Tuples.of(s, TokenSource.QUERY_PARAMETER)),
resolveAccessTokenFromBody(exchange).map(s -> Tuples.of(s, TokenSource.BODY_PARAMETER)))
.collectList()
.mapNotNull(tokenTuples -> {
switch (tokenTuples.size()) {
case 0:
return null;
case 1:
return getTokenIfSupported(tokenTuples.get(0), request);
default:
BearerTokenError error = invalidRequest(MULTIPLE_BEARER_TOKENS_ERROR_MSG);
throw new OAuth2AuthenticationException(error);
}
});
}
private String resolveAccessTokenFromRequest(ServerHttpRequest request) {
if (!isParameterTokenSupportedForRequest(request)) {
return null;
}
List<String> parameterTokens = request.getQueryParams().get("access_token");
private static Mono<String> resolveAccessTokenFromRequest(ServerHttpRequest request) {
List<String> parameterTokens = request.getQueryParams().get(ACCESS_TOKEN_NAME);
if (CollectionUtils.isEmpty(parameterTokens)) {
return null;
return Mono.empty();
}
if (parameterTokens.size() == 1) {
return parameterTokens.get(0);
return Mono.just(parameterTokens.get(0));
}
BearerTokenError error = BearerTokenErrors.invalidRequest("Found multiple bearer tokens in the request");
BearerTokenError error = invalidRequest(MULTIPLE_BEARER_TOKENS_ERROR_MSG);
throw new OAuth2AuthenticationException(error);
}
private String getTokenIfSupported(Tuple2<String, TokenSource> tokenTuple, ServerHttpRequest request) {
switch (tokenTuple.getT2()) {
case HEADER:
return tokenTuple.getT1();
case QUERY_PARAMETER:
return isParameterTokenSupportedForRequest(request) ? tokenTuple.getT1() : null;
case BODY_PARAMETER:
return isBodyParameterTokenSupportedForRequest(request) ? tokenTuple.getT1() : null;
default:
throw new IllegalArgumentException();
}
}
/**
* Set if transport of access token using URI query parameter is supported. Defaults
* to {@code false}.
@ -127,25 +147,70 @@ public class ServerBearerTokenAuthenticationConverter implements ServerAuthentic
this.bearerTokenHeaderName = bearerTokenHeaderName;
}
private String resolveFromAuthorizationHeader(HttpHeaders headers) {
/**
* Set if transport of access token using form-encoded body parameter is supported.
* Defaults to {@code false}.
* @param allowFormEncodedBodyParameter if the form-encoded body parameter is
* supported
* @since 6.5
*/
public void setAllowFormEncodedBodyParameter(boolean allowFormEncodedBodyParameter) {
this.allowFormEncodedBodyParameter = allowFormEncodedBodyParameter;
}
private Mono<String> resolveFromAuthorizationHeader(HttpHeaders headers) {
String authorization = headers.getFirst(this.bearerTokenHeaderName);
if (!StringUtils.startsWithIgnoreCase(authorization, "bearer")) {
return null;
return Mono.empty();
}
Matcher matcher = authorizationPattern.matcher(authorization);
if (!matcher.matches()) {
BearerTokenError error = invalidTokenError();
throw new OAuth2AuthenticationException(error);
}
return matcher.group("token");
return Mono.just(matcher.group("token"));
}
private static BearerTokenError invalidTokenError() {
return BearerTokenErrors.invalidToken("Bearer token is malformed");
}
private Mono<String> resolveAccessTokenFromBody(ServerWebExchange exchange) {
if (!allowFormEncodedBodyParameter) {
return Mono.empty();
}
final ServerHttpRequest request = exchange.getRequest();
if (request.getMethod() == HttpMethod.POST &&
MediaType.APPLICATION_FORM_URLENCODED.equalsTypeAndSubtype(request.getHeaders().getContentType())) {
return exchange.getFormData().mapNotNull(formData -> {
if (formData.isEmpty()) {
return null;
}
final List<String> tokens = formData.get(ACCESS_TOKEN_NAME);
if (tokens == null) {
return null;
}
if (tokens.size() > 1) {
BearerTokenError error = invalidRequest(MULTIPLE_BEARER_TOKENS_ERROR_MSG);
throw new OAuth2AuthenticationException(error);
}
return formData.getFirst(ACCESS_TOKEN_NAME);
});
}
return Mono.empty();
}
private boolean isBodyParameterTokenSupportedForRequest(ServerHttpRequest request) {
return this.allowFormEncodedBodyParameter && HttpMethod.POST == request.getMethod();
}
private boolean isParameterTokenSupportedForRequest(ServerHttpRequest request) {
return this.allowUriQueryParameter && HttpMethod.GET.equals(request.getMethod());
}
private enum TokenSource {HEADER, QUERY_PARAMETER, BODY_PARAMETER}
}

View File

@ -32,6 +32,9 @@ import org.springframework.security.oauth2.server.resource.authentication.Bearer
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.post;
/**
* @author Rob Winch
@ -219,6 +222,107 @@ public class ServerBearerTokenAuthenticationConverterTests {
}
@Test
void resolveWhenBodyParameterIsPresentThenTokenIsResolved() {
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN);
assertThat(convertToToken(request).getToken()).isEqualTo(TEST_TOKEN);
}
@Test
void resolveWhenBodyParameterIsPresentButNotAllowedThenTokenIsNotResolved() {
this.converter.setAllowFormEncodedBodyParameter(false);
MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN);
assertThat(convertToToken(request)).isNull();
}
@Test
void resolveWhenBodyParameterHasMultipleAccessTokensThenOAuth2AuthenticationException() {
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN + "&access_token=" + TEST_TOKEN);
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> convertToToken(request))
.satisfies(ex -> {
BearerTokenError error = (BearerTokenError) ex.getError();
assertThat(error.getDescription()).isEqualTo("Found multiple bearer tokens in the request");
assertThat(error.getErrorCode()).isEqualTo(BearerTokenErrorCodes.INVALID_REQUEST);
assertThat(error.getUri()).isEqualTo("https://tools.ietf.org/html/rfc6750#section-3.1");
assertThat(error.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST);
});
}
@Test
void resolveBodyContainsOtherParameterAsWellThenTokenIsResolved() {
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN + "&other_param=value");
assertThat(convertToToken(request).getToken()).isEqualTo(TEST_TOKEN);
}
@Test
void resolveWhenNoBodyParameterThenTokenIsNotResolved() {
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest.BaseBuilder<?> request = post("/").contentType(APPLICATION_FORM_URLENCODED);
assertThat(convertToToken(request)).isNull();
}
@Test
void resolveWhenWrongBodyParameterThenTokenIsNotResolved() {
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED)
.body("other_param=value");
assertThat(convertToToken(request)).isNull();
}
@Test
void resolveWhenValidHeaderIsPresentTogetherWithBodyParameterThenAuthenticationExceptionIsThrown() {
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").header(AUTHORIZATION, "Bearer " + TEST_TOKEN)
.contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN);
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> convertToToken(request))
.withMessageContaining("Found multiple bearer tokens in the request");
}
@Test
void resolveWhenValidQueryParameterIsPresentTogetherWithBodyParameterThenAuthenticationExceptionIsThrown() {
this.converter.setAllowUriQueryParameter(true);
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").queryParam("access_token", TEST_TOKEN)
.contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN);
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> convertToToken(request))
.withMessageContaining("Found multiple bearer tokens in the request");
}
@Test
void resolveWhenValidQueryParameterIsPresentTogetherWithBodyParameterAndValidHeaderThenAuthenticationExceptionIsThrown() {
this.converter.setAllowUriQueryParameter(true);
this.converter.setAllowFormEncodedBodyParameter(true);
MockServerHttpRequest request = post("/").header(AUTHORIZATION, "Bearer " + TEST_TOKEN)
.queryParam("access_token", TEST_TOKEN)
.contentType(APPLICATION_FORM_URLENCODED)
.body("access_token=" + TEST_TOKEN);
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> convertToToken(request))
.withMessageContaining("Found multiple bearer tokens in the request");
}
// gh-16038
@Test
void resolveWhenRequestContainsTwoAccessTokenQueryParametersAndSupportIsDisabledThenTokenIsNotResolved() {