diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java b/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java new file mode 100644 index 000000000..e9a7f14ad --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java @@ -0,0 +1,92 @@ +package run.halo.app.identity.authentication.verifier; + +import java.util.Collection; +import java.util.Map; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.util.Assert; + +/** + * Base class for {@link AbstractAuthenticationToken} implementations that expose common + * attributes between different OAuth 2.0 Access Token Formats. + * + * <p> + * For example, a {@link Jwt} could expose its {@link Jwt#getClaims() claims} via + * {@link #getTokenAttributes()} or an "Introspected" OAuth 2.0 Access Token + * could expose the attributes of the Introspection Response via + * {@link #getTokenAttributes()}. + * + * @author guqing + * @see OAuth2AccessToken + * @see Jwt + * @see <a href="https://tools.ietf.org/search/rfc7662#section-2.2">2.2 Introspection Response</a> + * @since 2.0.0 + */ +public abstract class AbstractOAuth2TokenAuthenticationToken<T extends AbstractOAuth2Token> + extends AbstractAuthenticationToken { + + private Object principal; + + private Object credentials; + + private T token; + + /** + * Sub-class constructor. + */ + protected AbstractOAuth2TokenAuthenticationToken(T token) { + + this(token, null); + } + + /** + * Sub-class constructor. + * + * @param authorities the authorities assigned to the Access Token + */ + protected AbstractOAuth2TokenAuthenticationToken(T token, + Collection<? extends GrantedAuthority> authorities) { + + this(token, token, token, authorities); + } + + protected AbstractOAuth2TokenAuthenticationToken(T token, Object principal, Object credentials, + Collection<? extends GrantedAuthority> authorities) { + + super(authorities); + Assert.notNull(token, "token cannot be null"); + Assert.notNull(principal, "principal cannot be null"); + this.principal = principal; + this.credentials = credentials; + this.token = token; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + /** + * Get the token bound to this {@link Authentication}. + */ + public final T getToken() { + return this.token; + } + + /** + * Returns the attributes of the access token. + * + * @return a {@code Map} of the attributes in the access token. + */ + public abstract Map<String, Object> getTokenAttributes(); + +} diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthentication.java b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthentication.java new file mode 100644 index 000000000..e97c3c26b --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthentication.java @@ -0,0 +1,47 @@ +package run.halo.app.identity.authentication.verifier; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.Transient; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.util.Assert; + +/** + * An {@link org.springframework.security.core.Authentication} token that represents a + * successful authentication as obtained through a bearer token. + * + * @author guqing + * @since 2.0.0 + */ +@Transient +public class BearerTokenAuthentication + extends AbstractOAuth2TokenAuthenticationToken<OAuth2AccessToken> { + private final Map<String, Object> attributes; + + /** + * Constructs a {@link BearerTokenAuthentication} with the provided arguments + * + * @param principal The OAuth 2.0 attributes + * @param credentials The verified token + * @param authorities The authorities associated with the given token + */ + public BearerTokenAuthentication(OAuth2AuthenticatedPrincipal principal, + OAuth2AccessToken credentials, + Collection<? extends GrantedAuthority> authorities) { + super(credentials, principal, credentials, authorities); + Assert.isTrue(credentials.getTokenType() == OAuth2AccessToken.TokenType.BEARER, + "credentials must be a bearer token"); + this.attributes = + Collections.unmodifiableMap(new LinkedHashMap<>(principal.getAttributes())); + setAuthenticated(true); + } + + @Override + public Map<String, Object> getTokenAttributes() { + return this.attributes; + } +} diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationToken.java b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationToken.java new file mode 100644 index 000000000..c0606f926 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationToken.java @@ -0,0 +1,49 @@ +package run.halo.app.identity.authentication.verifier; + +import java.util.Collections; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} that contains a + * <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>. + * + * @author guqing + * @since 2.0.0 + */ +public class BearerTokenAuthenticationToken extends AbstractAuthenticationToken { + private final String token; + + /** + * Create a {@code BearerTokenAuthenticationToken} using the provided parameter(s) + * + * @param token - the bearer token + */ + public BearerTokenAuthenticationToken(String token) { + super(Collections.emptyList()); + Assert.hasText(token, "token cannot be empty"); + this.token = token; + } + + /** + * Get the + * <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a> + * + * @return the token that proves the caller's authority to perform the + * {@link jakarta.servlet.http.HttpServletRequest} + */ + public String getToken() { + return this.token; + } + + @Override + public Object getCredentials() { + return this.getToken(); + } + + @Override + public Object getPrincipal() { + return this.getToken(); + } +} diff --git a/src/test/java/run/halo/app/authentication/verifyer/BearerTokenAuthenticationTest.java b/src/test/java/run/halo/app/authentication/verifyer/BearerTokenAuthenticationTest.java new file mode 100644 index 000000000..23a08187a --- /dev/null +++ b/src/test/java/run/halo/app/authentication/verifyer/BearerTokenAuthenticationTest.java @@ -0,0 +1,142 @@ +package run.halo.app.authentication.verifyer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import com.nimbusds.jose.shaded.json.JSONObject; +import java.net.URL; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; +import run.halo.app.identity.authentication.verifier.BearerTokenAuthentication; + +/** + * Tests for {@link BearerTokenAuthentication} + * + * @author guqing + * @since 2.0.0 + */ +public class BearerTokenAuthenticationTest { + private final OAuth2AccessToken + token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "token", + Instant.now(), Instant.now().plusSeconds(3600)); + + private final String name = "sub"; + + private final Map<String, Object> attributesMap = new HashMap<>(); + + private DefaultOAuth2AuthenticatedPrincipal principal; + + private final Collection<GrantedAuthority> authorities = + AuthorityUtils.createAuthorityList("USER"); + + @BeforeEach + public void setUp() { + this.attributesMap.put(OAuth2TokenIntrospectionClaimNames.SUB, this.name); + this.attributesMap.put(OAuth2TokenIntrospectionClaimNames.USERNAME, "username"); + this.principal = new DefaultOAuth2AuthenticatedPrincipal(this.attributesMap, null); + } + + @Test + public void getNameWhenConfiguredInConstructorThenReturnsName() { + OAuth2AuthenticatedPrincipal principal = + new DefaultOAuth2AuthenticatedPrincipal(this.name, this.attributesMap, + this.authorities); + BearerTokenAuthentication authenticated = + new BearerTokenAuthentication(principal, this.token, + this.authorities); + assertThat(authenticated.getName()).isEqualTo(this.name); + } + + @Test + public void getNameWhenHasNoSubjectThenReturnsNull() { + OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal( + Collections.singletonMap("claim", "value"), null); + BearerTokenAuthentication authenticated = + new BearerTokenAuthentication(principal, this.token, null); + assertThat(authenticated.getName()).isNull(); + } + + @Test + public void getNameWhenTokenHasUsernameThenReturnsUsernameAttribute() { + BearerTokenAuthentication authenticated = + new BearerTokenAuthentication(this.principal, this.token, null); + assertThat(authenticated.getName()) + .isEqualTo(this.principal.getAttribute(OAuth2TokenIntrospectionClaimNames.SUB)); + } + + @Test + public void constructorWhenTokenIsNullThenThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BearerTokenAuthentication(this.principal, null, null)) + .withMessageContaining("token cannot be null"); + } + + @Test + public void constructorWhenCredentialIsNullThenThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BearerTokenAuthentication(null, this.token, null)) + .withMessageContaining("principal cannot be null"); + } + + @Test + public void constructorWhenPassingAllAttributesThenTokenIsAuthenticated() { + OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal("harris", + Collections.singletonMap("claim", "value"), null); + BearerTokenAuthentication authenticated = + new BearerTokenAuthentication(principal, this.token, null); + assertThat(authenticated.isAuthenticated()).isTrue(); + } + + @Test + public void getTokenAttributesWhenHasTokenThenReturnsThem() { + BearerTokenAuthentication authenticated = + new BearerTokenAuthentication(this.principal, this.token, + Collections.emptyList()); + assertThat(authenticated.getTokenAttributes()).isEqualTo(this.principal.getAttributes()); + } + + @Test + public void getAuthoritiesWhenHasAuthoritiesThenReturnsThem() { + List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("USER"); + BearerTokenAuthentication authenticated = + new BearerTokenAuthentication(this.principal, this.token, + authorities); + assertThat(authenticated.getAuthorities()).isEqualTo(authorities); + } + + @Test + public void constructorWhenDefaultParametersThenSetsPrincipalToAttributesCopy() { + JSONObject attributes = new JSONObject(); + attributes.put("active", true); + OAuth2AuthenticatedPrincipal + principal = new DefaultOAuth2AuthenticatedPrincipal(attributes, null); + BearerTokenAuthentication token = + new BearerTokenAuthentication(principal, this.token, null); + assertThat(token.getPrincipal()).isNotSameAs(attributes); + assertThat(token.getTokenAttributes()).isNotSameAs(attributes); + } + + @Test + public void toStringWhenAttributesContainsURLThenDoesNotFail() throws Exception { + JSONObject attributes = + new JSONObject(Collections.singletonMap("iss", new URL("https://idp.example.com"))); + OAuth2AuthenticatedPrincipal principal = + new DefaultOAuth2AuthenticatedPrincipal(attributes, null); + BearerTokenAuthentication token = + new BearerTokenAuthentication(principal, this.token, null); + assertThat(token.toString()).isNotNull(); + } + +} diff --git a/src/test/java/run/halo/app/authentication/verifyer/BearerTokenAuthenticationTokenTest.java b/src/test/java/run/halo/app/authentication/verifyer/BearerTokenAuthenticationTokenTest.java new file mode 100644 index 000000000..a809b73b3 --- /dev/null +++ b/src/test/java/run/halo/app/authentication/verifyer/BearerTokenAuthenticationTokenTest.java @@ -0,0 +1,37 @@ +package run.halo.app.authentication.verifyer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; +import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationToken; + +/** + * Tests for {@link BearerTokenAuthenticationToken} + * + * @author guqing + * @since 2.0.0 + */ +public class BearerTokenAuthenticationTokenTest { + @Test + public void constructorWhenTokenIsNullThenThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BearerTokenAuthenticationToken(null)) + .withMessageContaining("token cannot be empty"); + } + + @Test + public void constructorWhenTokenIsEmptyThenThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BearerTokenAuthenticationToken("")) + .withMessageContaining("token cannot be empty"); + } + + @Test + public void constructorWhenTokenHasValueThenConstructedCorrectly() { + BearerTokenAuthenticationToken token = new BearerTokenAuthenticationToken("token"); + assertThat(token.getToken()).isEqualTo("token"); + assertThat(token.getPrincipal()).isEqualTo("token"); + assertThat(token.getCredentials()).isEqualTo("token"); + } +}