mirror of https://github.com/halo-dev/halo
feat: Add default bearer token resolver for rfc6750#section-2.1-2.3 (#1869)
parent
599ab5618b
commit
673c2068d9
|
@ -0,0 +1,29 @@
|
|||
package run.halo.app.identity.authentication.verifier;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
|
||||
/**
|
||||
* A strategy for resolving
|
||||
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>
|
||||
* s from the {@link HttpServletRequest}.
|
||||
*
|
||||
* @author guqing
|
||||
* @see
|
||||
* <a href="https://tools.ietf.org/html/rfc6750#section-2">RFC 6750 Section 2: Authenticated Requests</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface BearerTokenResolver {
|
||||
/**
|
||||
* Resolve any
|
||||
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>
|
||||
* value from the request.
|
||||
*
|
||||
* @param request the request
|
||||
* @return the Bearer Token value or {@code null} if none found
|
||||
* @throws OAuth2AuthenticationException if the found token is invalid
|
||||
*/
|
||||
String resolve(HttpServletRequest request);
|
||||
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
package run.halo.app.identity.authentication.verifier;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* The default {@link BearerTokenResolver} implementation based on RFC 6750.
|
||||
*
|
||||
* @author guqing
|
||||
* @see
|
||||
* <a href="https://tools.ietf.org/html/rfc6750#section-2">RFC 6750 Section 2: Authenticated Requests</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public final class DefaultBearerTokenResolver implements BearerTokenResolver {
|
||||
|
||||
private static final Pattern authorizationPattern =
|
||||
Pattern.compile("^Bearer (?<token>[a-zA-Z0-9-._~+/]+=*)$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private boolean allowFormEncodedBodyParameter = false;
|
||||
|
||||
private boolean allowUriQueryParameter = false;
|
||||
|
||||
private String bearerTokenHeaderName = HttpHeaders.AUTHORIZATION;
|
||||
|
||||
@Override
|
||||
public String resolve(final HttpServletRequest request) {
|
||||
final String authorizationHeaderToken = resolveFromAuthorizationHeader(request);
|
||||
final String parameterToken = isParameterTokenSupportedForRequest(request)
|
||||
? resolveFromRequestParameters(request) : null;
|
||||
if (authorizationHeaderToken != null) {
|
||||
if (parameterToken != null) {
|
||||
final BearerTokenError error = BearerTokenErrors
|
||||
.invalidRequest("Found multiple bearer tokens in the request");
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
return authorizationHeaderToken;
|
||||
}
|
||||
if (parameterToken != null && isParameterTokenEnabledForRequest(request)) {
|
||||
return parameterToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public void setAllowFormEncodedBodyParameter(boolean allowFormEncodedBodyParameter) {
|
||||
this.allowFormEncodedBodyParameter = allowFormEncodedBodyParameter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if transport of access token using URI query parameter is supported. Defaults
|
||||
* to {@code false}.
|
||||
* <p>
|
||||
* The spec recommends against using this mechanism for sending bearer tokens, and
|
||||
* even goes as far as stating that it was only included for completeness.
|
||||
*
|
||||
* @param allowUriQueryParameter if the URI query parameter is supported
|
||||
*/
|
||||
public void setAllowUriQueryParameter(boolean allowUriQueryParameter) {
|
||||
this.allowUriQueryParameter = allowUriQueryParameter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this value to configure what header is checked when resolving a Bearer Token.
|
||||
* This value is defaulted to {@link HttpHeaders#AUTHORIZATION}.
|
||||
* <p>
|
||||
* This allows other headers to be used as the Bearer Token source such as
|
||||
* {@link HttpHeaders#PROXY_AUTHORIZATION}
|
||||
*
|
||||
* @param bearerTokenHeaderName the header to check when retrieving the Bearer Token.
|
||||
* @since 5.4
|
||||
*/
|
||||
public void setBearerTokenHeaderName(String bearerTokenHeaderName) {
|
||||
this.bearerTokenHeaderName = bearerTokenHeaderName;
|
||||
}
|
||||
|
||||
private String resolveFromAuthorizationHeader(HttpServletRequest request) {
|
||||
String authorization = request.getHeader(this.bearerTokenHeaderName);
|
||||
if (!StringUtils.startsWithIgnoreCase(authorization, "bearer")) {
|
||||
return null;
|
||||
}
|
||||
Matcher matcher = authorizationPattern.matcher(authorization);
|
||||
if (!matcher.matches()) {
|
||||
BearerTokenError error = BearerTokenErrors.invalidToken("Bearer token is malformed");
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
return matcher.group("token");
|
||||
}
|
||||
|
||||
private static String resolveFromRequestParameters(HttpServletRequest request) {
|
||||
String[] values = request.getParameterValues("access_token");
|
||||
if (values == null || values.length == 0) {
|
||||
return null;
|
||||
}
|
||||
if (values.length == 1) {
|
||||
return values[0];
|
||||
}
|
||||
BearerTokenError error =
|
||||
BearerTokenErrors.invalidRequest("Found multiple bearer tokens in the request");
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
private boolean isParameterTokenSupportedForRequest(final HttpServletRequest request) {
|
||||
return (("POST".equals(request.getMethod())
|
||||
&& MediaType.APPLICATION_FORM_URLENCODED_VALUE.equals(request.getContentType()))
|
||||
|| "GET".equals(request.getMethod()));
|
||||
}
|
||||
|
||||
private boolean isParameterTokenEnabledForRequest(final HttpServletRequest request) {
|
||||
return ((this.allowFormEncodedBodyParameter && "POST".equals(request.getMethod())
|
||||
&& MediaType.APPLICATION_FORM_URLENCODED_VALUE.equals(request.getContentType()))
|
||||
|| (this.allowUriQueryParameter && "GET".equals(request.getMethod())));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
package run.halo.app.authentication.verifyer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import run.halo.app.identity.authentication.verifier.DefaultBearerTokenResolver;
|
||||
|
||||
/**
|
||||
* Tests for {@link DefaultBearerTokenResolver}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class DefaultBearerTokenResolverTest {
|
||||
private static final String CUSTOM_HEADER = "custom-header";
|
||||
private static final String TEST_TOKEN = "test-token";
|
||||
|
||||
private DefaultBearerTokenResolver resolver;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
this.resolver = new DefaultBearerTokenResolver();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveWhenValidHeaderIsPresentThenTokenIsResolved() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer " + TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveWhenHeaderEndsWithPaddingIndicatorThenTokenIsResolved() {
|
||||
String token = TEST_TOKEN + "==";
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer " + token);
|
||||
assertThat(this.resolver.resolve(request)).isEqualTo(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveWhenCustomDefinedHeaderIsValidAndPresentThenTokenIsResolved() {
|
||||
this.resolver.setBearerTokenHeaderName(CUSTOM_HEADER);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(CUSTOM_HEADER, "Bearer " + TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveWhenLowercaseHeaderIsPresentThenTokenIsResolved() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("authorization", "bearer " + TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveWhenNoHeaderIsPresentThenTokenIsNotResolved() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
assertThat(this.resolver.resolve(request)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveWhenHeaderWithWrongSchemeIsPresentThenTokenIsNotResolved() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization",
|
||||
"Basic " + Base64.getEncoder().encodeToString("test:test".getBytes()));
|
||||
assertThat(this.resolver.resolve(request)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveWhenHeaderWithMissingTokenIsPresentThenAuthenticationExceptionIsThrown")
|
||||
public void resolveThenAuthenticationExceptionIsThrown() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer ");
|
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(
|
||||
() -> this.resolver.resolve(request))
|
||||
.withMessageContaining(("Bearer token is malformed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"resolveWhenHeaderWithInvalidCharactersIsPresentThenAuthenticationExceptionIsThrown")
|
||||
public void resolveThenAuthenticationExceptionIsThrown2() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer an\"invalid\"token");
|
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(
|
||||
() -> this.resolver.resolve(request))
|
||||
.withMessageContaining(("Bearer token is malformed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve when valid header is present together with "
|
||||
+ "form parameter then AuthenticationException is thrown")
|
||||
public void resolveThenAuthenticationExceptionIsThrown3() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer " + TEST_TOKEN);
|
||||
request.setMethod("POST");
|
||||
request.setContentType("application/x-www-form-urlencoded");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(
|
||||
() -> this.resolver.resolve(request))
|
||||
.withMessageContaining("Found multiple bearer tokens in the request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve when valid header is present together with query"
|
||||
+ " parameter then AuthenticationException is thrown")
|
||||
public void resolveThenAuthenticationExceptionIsThrown4() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer " + TEST_TOKEN);
|
||||
request.setMethod("GET");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(
|
||||
() -> this.resolver.resolve(request))
|
||||
.withMessageContaining("Found multiple bearer tokens in the request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve when request contains two access token query"
|
||||
+ " parameters then AuthenticationException is thrown")
|
||||
public void resolveThenAuthenticationExceptionIsThrown5() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("GET");
|
||||
request.addParameter("access_token", "token1", "token2");
|
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(
|
||||
() -> this.resolver.resolve(request))
|
||||
.withMessageContaining("Found multiple bearer tokens in the request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve when request contains two access token form parameters"
|
||||
+ " then AuthenticationException is thrown")
|
||||
public void resolveThenAuthenticationExceptionIsThrown6() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("POST");
|
||||
request.setContentType("application/x-www-form-urlencoded");
|
||||
request.addParameter("access_token", "token1", "token2");
|
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(
|
||||
() -> this.resolver.resolve(request))
|
||||
.withMessageContaining("Found multiple bearer tokens in the request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve when parameter is present in multipart request"
|
||||
+ " and form parameter supported then token is not resolved")
|
||||
public void resolveThenTokenIsNotResolved() {
|
||||
this.resolver.setAllowFormEncodedBodyParameter(true);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("POST");
|
||||
request.setContentType("multipart/form-data");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveWhenFormParameterIsPresentAndSupportedThenTokenIsResolved")
|
||||
public void resolveThenTokenIsResolved() {
|
||||
this.resolver.setAllowFormEncodedBodyParameter(true);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("POST");
|
||||
request.setContentType("application/x-www-form-urlencoded");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveWhenFormParameterIsPresentAndNotSupportedThenTokenIsNotResolved")
|
||||
public void resolveThenTokenIsNotResolved2() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("POST");
|
||||
request.setContentType("application/x-www-form-urlencoded");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveWhenQueryParameterIsPresentAndSupportedThenTokenIsResolved")
|
||||
public void resolveThenTokenIsResolved2() {
|
||||
this.resolver.setAllowUriQueryParameter(true);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("GET");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveWhenQueryParameterIsPresentAndNotSupportedThenTokenIsNotResolved")
|
||||
public void resolveThenTokenIsNotResolved3() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setMethod("GET");
|
||||
request.addParameter("access_token", TEST_TOKEN);
|
||||
assertThat(this.resolver.resolve(request)).isNull();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue