diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenResolver.java b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenResolver.java new file mode 100644 index 000000000..f006a46cd --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenResolver.java @@ -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 + * Bearer Token + * s from the {@link HttpServletRequest}. + * + * @author guqing + * @see + * RFC 6750 Section 2: Authenticated Requests + * @since 2.0.0 + */ +@FunctionalInterface +public interface BearerTokenResolver { + /** + * Resolve any + * Bearer Token + * 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); + +} diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/DefaultBearerTokenResolver.java b/src/main/java/run/halo/app/identity/authentication/verifier/DefaultBearerTokenResolver.java new file mode 100644 index 000000000..8513b14df --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/verifier/DefaultBearerTokenResolver.java @@ -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 + * RFC 6750 Section 2: Authenticated Requests + * @since 2.0.0 + */ +public final class DefaultBearerTokenResolver implements BearerTokenResolver { + + private static final Pattern authorizationPattern = + Pattern.compile("^Bearer (?[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}. + *

+ * 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}. + *

+ * 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()))); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/authentication/verifyer/DefaultBearerTokenResolverTest.java b/src/test/java/run/halo/app/authentication/verifyer/DefaultBearerTokenResolverTest.java new file mode 100644 index 000000000..8e20a2ba0 --- /dev/null +++ b/src/test/java/run/halo/app/authentication/verifyer/DefaultBearerTokenResolverTest.java @@ -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(); + } +}