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