feat: Add jwt token and provider for jwt authentication (#1871)

pull/1872/head
guqing 2022-04-22 14:00:12 +08:00 committed by GitHub
parent 673c2068d9
commit e8d8ef8f28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 649 additions and 0 deletions

View File

@ -0,0 +1,40 @@
package run.halo.app.identity.authentication.verifier;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
/**
* An {@link OAuth2AuthenticationException} that indicates an invalid bearer token.
*
* @author guqing
* @since 2.0.0
*/
public class InvalidBearerTokenException extends OAuth2AuthenticationException {
/**
* Construct an instance of {@link InvalidBearerTokenException} given the provided
* description.
* <p>
* The description will be wrapped into an
* {@link org.springframework.security.oauth2.core.OAuth2Error} instance as the
* {@code error_description}.
*
* @param description the description
*/
public InvalidBearerTokenException(String description) {
super(BearerTokenErrors.invalidToken(description));
}
/**
* Construct an instance of {@link InvalidBearerTokenException} given the provided
* description and cause
* <p>
* The description will be wrapped into an
* {@link org.springframework.security.oauth2.core.OAuth2Error} instance as the
* {@code error_description}.
*
* @param description the description
* @param cause the causing exception
*/
public InvalidBearerTokenException(String description, Throwable cause) {
super(BearerTokenErrors.invalidToken(description), cause);
}
}

View File

@ -0,0 +1,55 @@
package run.halo.app.identity.authentication.verifier;
import java.util.Collection;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.util.Assert;
/**
* @author guqing
* @since 2.0.0
*/
public class JwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
private String principalClaimName = JwtClaimNames.SUB;
@Override
public final AbstractAuthenticationToken convert(@NonNull Jwt jwt) {
Collection<GrantedAuthority> authorities = this.jwtGrantedAuthoritiesConverter.convert(jwt);
String principalClaimValue = jwt.getClaimAsString(this.principalClaimName);
return new JwtAuthenticationToken(jwt, authorities, principalClaimValue);
}
/**
* Sets the {@link Converter Converter&lt;Jwt, Collection&lt;GrantedAuthority&gt;&gt;}
* to use. Defaults to {@link JwtGrantedAuthoritiesConverter}.
*
* @param jwtGrantedAuthoritiesConverter The converter
* @see JwtGrantedAuthoritiesConverter
* @since 5.2
*/
public void setJwtGrantedAuthoritiesConverter(
Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter) {
Assert.notNull(jwtGrantedAuthoritiesConverter,
"jwtGrantedAuthoritiesConverter cannot be null");
this.jwtGrantedAuthoritiesConverter = jwtGrantedAuthoritiesConverter;
}
/**
* Sets the principal claim name. Defaults to {@link JwtClaimNames#SUB}.
*
* @param principalClaimName The principal claim name
* @since 5.4
*/
public void setPrincipalClaimName(String principalClaimName) {
Assert.hasText(principalClaimName, "principalClaimName cannot be empty");
this.principalClaimName = principalClaimName;
}
}

View File

@ -0,0 +1,96 @@
package run.halo.app.identity.authentication.verifier;
import java.util.Collection;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.util.Assert;
/**
* An {@link AuthenticationProvider} implementation of the {@link Jwt}-encoded
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>
* s for protecting server resources.
* <p>
* <p>
* This {@link AuthenticationProvider} is responsible for decoding and verifying a
* {@link Jwt}-encoded access token, returning its claims set as part of the
* {@link Authentication} statement.
* <p>
* <p>
* Scopes are translated into {@link GrantedAuthority}s according to the following
* algorithm:
* <p>
* 1. If there is a "scope" or "scp" attribute, then if a {@link String}, then split by
* spaces and return, or if a {@link Collection}, then simply return 2. Take the resulting
* {@link Collection} of {@link String}s and prepend the "SCOPE_" keyword, adding as
* {@link GrantedAuthority}s.
*
* @author guqing
* @see AuthenticationProvider
* @see JwtDecoder
* @since 2.0.0
*/
@Slf4j
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final JwtDecoder jwtDecoder;
private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter =
new JwtAuthenticationConverter();
public JwtAuthenticationProvider(JwtDecoder jwtDecoder) {
Assert.notNull(jwtDecoder, "jwtDecoder cannot be null");
this.jwtDecoder = jwtDecoder;
}
/**
* Decode and validate the
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>.
*
* @param authentication the authentication request object.
* @return A successful authentication
* @throws AuthenticationException if authentication failed for some reason
*/
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication;
Jwt jwt = getJwt(bearer);
AbstractAuthenticationToken token = this.jwtAuthenticationConverter.convert(jwt);
if (token != null) {
token.setDetails(bearer.getDetails());
}
log.debug("Authenticated token");
return token;
}
private Jwt getJwt(BearerTokenAuthenticationToken bearer) {
try {
return this.jwtDecoder.decode(bearer.getToken());
} catch (BadJwtException failed) {
log.debug("Failed to authenticate since the JWT was invalid");
throw new InvalidBearerTokenException(failed.getMessage(), failed);
} catch (JwtException failed) {
throw new AuthenticationServiceException(failed.getMessage(), failed);
}
}
@Override
public boolean supports(Class<?> authentication) {
return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
public void setJwtAuthenticationConverter(
Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) {
Assert.notNull(jwtAuthenticationConverter, "jwtAuthenticationConverter cannot be null");
this.jwtAuthenticationConverter = jwtAuthenticationConverter;
}
}

View File

@ -0,0 +1,70 @@
package run.halo.app.identity.authentication.verifier;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.Transient;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* An implementation of an {@link AbstractOAuth2TokenAuthenticationToken} representing a
* {@link Jwt} {@code Authentication}.
*
* @author guqing
* @see AbstractOAuth2TokenAuthenticationToken
* @see Jwt
* @since 2.0.0
*/
@Transient
public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken<Jwt> {
private final String name;
/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
*
* @param jwt the JWT
*/
public JwtAuthenticationToken(Jwt jwt) {
super(jwt);
this.name = jwt.getSubject();
}
/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
*
* @param jwt the JWT
* @param authorities the authorities assigned to the JWT
*/
public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
super(jwt, authorities);
this.setAuthenticated(true);
this.name = jwt.getSubject();
}
/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
*
* @param jwt the JWT
* @param authorities the authorities assigned to the JWT
* @param name the principal name
*/
public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities,
String name) {
super(jwt, authorities);
this.setAuthenticated(true);
this.name = name;
}
@Override
public Map<String, Object> getTokenAttributes() {
return this.getToken().getClaims();
}
/**
* The principal name which is, by default, the {@link Jwt}'s subject
*/
@Override
public String getName() {
return this.name;
}
}

View File

@ -0,0 +1,115 @@
package run.halo.app.identity.authentication.verifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Extracts the {@link GrantedAuthority}s from scope attributes typically found in a
* {@link Jwt}.
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
public class JwtGrantedAuthoritiesConverter implements
Converter<Jwt, Collection<GrantedAuthority>> {
private static final String DEFAULT_AUTHORITY_PREFIX = "SCOPE_";
private static final Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES =
List.of("scope", "scp");
private String authorityPrefix = DEFAULT_AUTHORITY_PREFIX;
private String authoritiesClaimName;
/**
* Extract {@link GrantedAuthority}s from the given {@link Jwt}.
*
* @param jwt The {@link Jwt} token
* @return The {@link GrantedAuthority authorities} read from the token scopes
*/
@Override
public Collection<GrantedAuthority> convert(@NonNull Jwt jwt) {
Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
for (String authority : getAuthorities(jwt)) {
grantedAuthorities.add(new SimpleGrantedAuthority(this.authorityPrefix + authority));
}
return grantedAuthorities;
}
/**
* Sets the prefix to use for {@link GrantedAuthority authorities} mapped by this
* converter. Defaults to
* {@link JwtGrantedAuthoritiesConverter#DEFAULT_AUTHORITY_PREFIX}.
*
* @param authorityPrefix The authority prefix
* @since 5.2
*/
public void setAuthorityPrefix(String authorityPrefix) {
Assert.notNull(authorityPrefix, "authorityPrefix cannot be null");
this.authorityPrefix = authorityPrefix;
}
/**
* Sets the name of token claim to use for mapping {@link GrantedAuthority
* authorities} by this converter. Defaults to
* {@link JwtGrantedAuthoritiesConverter#WELL_KNOWN_AUTHORITIES_CLAIM_NAMES}.
*
* @param authoritiesClaimName The token claim name to map authorities
* @since 5.2
*/
public void setAuthoritiesClaimName(String authoritiesClaimName) {
Assert.hasText(authoritiesClaimName, "authoritiesClaimName cannot be empty");
this.authoritiesClaimName = authoritiesClaimName;
}
private String getAuthoritiesClaimName(Jwt jwt) {
if (this.authoritiesClaimName != null) {
return this.authoritiesClaimName;
}
for (String claimName : WELL_KNOWN_AUTHORITIES_CLAIM_NAMES) {
if (jwt.hasClaim(claimName)) {
return claimName;
}
}
return null;
}
private Collection<String> getAuthorities(Jwt jwt) {
String claimName = getAuthoritiesClaimName(jwt);
if (claimName == null) {
log.trace(
"Returning no authorities since could not find any claims that might contain "
+ "scopes");
return Collections.emptyList();
}
log.trace("Looking for scopes in claim [{}]", claimName);
Object authorities = jwt.getClaim(claimName);
if (authorities instanceof String) {
if (StringUtils.hasText((String) authorities)) {
return Arrays.asList(((String) authorities).split(" "));
}
return Collections.emptyList();
}
if (authorities instanceof Collection) {
return castAuthoritiesToCollection(authorities);
}
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
private Collection<String> castAuthoritiesToCollection(Object authorities) {
return (Collection<String>) authorities;
}
}

View File

@ -0,0 +1,130 @@
package run.halo.app.authentication.verifyer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import java.util.Objects;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationToken;
import run.halo.app.identity.authentication.verifier.BearerTokenErrorCodes;
import run.halo.app.identity.authentication.verifier.JwtAuthenticationProvider;
import run.halo.app.identity.authentication.verifier.JwtAuthenticationToken;
/**
* Tests for {@link JwtAuthenticationProvider}
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
public class JwtAuthenticationProviderTest {
@Mock
Converter<Jwt, JwtAuthenticationToken> jwtAuthenticationConverter;
@Mock
JwtDecoder jwtDecoder;
JwtAuthenticationProvider provider;
@BeforeEach
public void setup() {
this.provider = new JwtAuthenticationProvider(this.jwtDecoder);
this.provider.setJwtAuthenticationConverter(this.jwtAuthenticationConverter);
}
@Test
@DisplayName("authenticate when jwt decodes then authentication has attributes contained in "
+ "Jwt")
public void authenticateThenAttributesContainedInJwt() {
BearerTokenAuthenticationToken token = this.authentication();
Jwt jwt = TestJwts.jwt().claim("name", "value").build();
given(this.jwtDecoder.decode("token")).willReturn(jwt);
given(this.jwtAuthenticationConverter.convert(jwt)).willReturn(
new JwtAuthenticationToken(jwt));
JwtAuthenticationToken authentication =
(JwtAuthenticationToken) this.provider.authenticate(token);
assertThat(authentication.getTokenAttributes()).containsEntry("name", "value");
}
@Test
public void authenticateWhenJwtDecodeFailsThenRespondsWithInvalidToken() {
BearerTokenAuthenticationToken token = this.authentication();
given(this.jwtDecoder.decode("token")).willThrow(BadJwtException.class);
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> this.provider.authenticate(token))
.matches(errorCode(BearerTokenErrorCodes.INVALID_TOKEN));
}
@Test
@DisplayName("authenticate when decoder throws incompatible error message then wraps with "
+ "generic one")
public void authenticateWrapsWithGenericOne() {
BearerTokenAuthenticationToken token = this.authentication();
given(this.jwtDecoder.decode(token.getToken())).willThrow(
new BadJwtException("with \"invalid\" chars"));
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> this.provider.authenticate(token))
.satisfies((ex) -> assertThat(ex).hasFieldOrPropertyWithValue("error.description",
"Invalid token"));
}
@Test
public void authenticateWhenDecoderFailsGenericallyThenThrowsGenericException() {
BearerTokenAuthenticationToken token = this.authentication();
given(this.jwtDecoder.decode(token.getToken()))
.willThrow(new JwtException("no jwk set"));
assertThatExceptionOfType(AuthenticationException.class)
.isThrownBy(() -> this.provider.authenticate(token))
.isNotInstanceOf(OAuth2AuthenticationException.class);
}
@Test
@DisplayName("authenticate when converter returns authentication then provider propagates it")
public void authenticateProviderPropagatesIt() {
BearerTokenAuthenticationToken token = this.authentication();
Object details = mock(Object.class);
token.setDetails(details);
Jwt jwt = TestJwts.jwt().build();
JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwt);
given(this.jwtDecoder.decode(token.getToken())).willReturn(jwt);
given(this.jwtAuthenticationConverter.convert(jwt)).willReturn(authentication);
assertThat(this.provider.authenticate(token))
.isEqualTo(authentication).hasFieldOrPropertyWithValue("details",
details);
}
@Test
public void supportsWhenBearerTokenAuthenticationTokenThenReturnsTrue() {
assertThat(this.provider.supports(BearerTokenAuthenticationToken.class)).isTrue();
}
@Test
@DisplayName("unSupports when BearerTokenAuthenticationToken then returns true")
public void unSupportsJwtAuthenticationToken() {
assertThat(this.provider.supports(JwtAuthenticationToken.class)).isFalse();
}
private BearerTokenAuthenticationToken authentication() {
return new BearerTokenAuthenticationToken("token");
}
private Predicate<? super Throwable> errorCode(String errorCode) {
return (failed) -> Objects.equals(
((OAuth2AuthenticationException) failed).getError().getErrorCode(), errorCode);
}
}

View File

@ -0,0 +1,110 @@
package run.halo.app.authentication.verifyer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.util.Collection;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import run.halo.app.identity.authentication.verifier.JwtGrantedAuthoritiesConverter;
/**
* Tests for {@link JwtGrantedAuthoritiesConverter}
*
* @author guqing
* @since 2.0.0
*/
public class JwtGrantedAuthoritiesConverterTest {
@Test
public void setAuthorityPrefixWithNullThenException() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
assertThatIllegalArgumentException()
.isThrownBy(() -> jwtGrantedAuthoritiesConverter.setAuthorityPrefix(null));
}
@Test
public void convertWhenTokenHasScopeAttributeThenTranslatedToAuthorities() {
Jwt jwt = TestJwts.jwt()
.claim("scope", "message:read message:write")
.build();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("SCOPE_message:read"),
new SimpleGrantedAuthority("SCOPE_message:write"));
}
@Test
@DisplayName("convert with custom authority prefix when token has scope attribute then "
+ "translated to authorities")
public void convertWithCustomAuthorityPrefixThenTranslatedToAuthorities() {
Jwt jwt = TestJwts.jwt()
.claim("scope", "message:read message:write")
.build();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("ROLE_message:read"),
new SimpleGrantedAuthority("ROLE_message:write"));
}
@Test
@DisplayName("convert with blank as custom authority prefix when token has scope attribute "
+ "then translated to authorities")
public void convertWithBlankAsCustomAuthorityPrefixThenTranslatedToAuthorities() {
Jwt jwt = TestJwts.jwt()
.claim("scope", "message:read message:write")
.build();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("message:read"),
new SimpleGrantedAuthority("message:write"));
}
@Test
public void convertWhenTokenHasEmptyScopeAttributeThenTranslatedToNoAuthorities() {
Jwt jwt = TestJwts.jwt()
.claim("scope", "")
.build();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
assertThat(authorities).isEmpty();
}
@Test
public void convertWhenTokenHasScpAttributeThenTranslatedToAuthorities() {
Jwt jwt = TestJwts.jwt()
.claim("scp", List.of("message:read", "message:write"))
.build();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("SCOPE_message:read"),
new SimpleGrantedAuthority("SCOPE_message:write"));
}
@Test
@DisplayName("convert when token has both scope and scp then scope"
+ " attribute is translated to authorities")
public void convertWhenTokenHasBothScopeAndScp() {
Jwt jwt = TestJwts.jwt()
.claim("scp", List.of("message:read", "message:write"))
.claim("scope", "missive:read missive:write")
.build();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
assertThat(authorities).containsExactly(new SimpleGrantedAuthority("SCOPE_missive:read"),
new SimpleGrantedAuthority("SCOPE_missive:write"));
}
}

View File

@ -0,0 +1,33 @@
package run.halo.app.authentication.verifyer;
import java.time.Instant;
import java.util.List;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* @author guqing
* @since 2.0.0
*/
public final class TestJwts {
private TestJwts() {
}
public static Jwt.Builder jwt() {
return Jwt.withTokenValue("token")
.header("alg", "none")
.audience(List.of("https://audience.example.org"))
.expiresAt(Instant.MAX)
.issuedAt(Instant.MIN)
.issuer("https://issuer.example.org")
.jti("jti")
.notBefore(Instant.MIN)
.subject("mock-test-subject");
}
public static Jwt user() {
return jwt()
.claim("sub", "mock-test-subject")
.build();
}
}