feat: Add JwtGenerator for authentication (#1843)

* feat: Add JwtGenerator for authentication

* fix: code style

* refactor: change OAuth2TokenType type to record

* refactor: remove explicit declaration of equals and hashcode for records class
pull/1846/head
guqing 2022-04-15 10:47:57 +08:00 committed by GitHub
parent cec6897574
commit 20e6d4d1eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1089 additions and 4 deletions

View File

@ -50,7 +50,6 @@ public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.antMatchers("/api/**", "/apis/**").authenticated()
@ -63,20 +62,17 @@ public class WebSecurityConfig {
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler())
);
// @formatter:on
return http.build();
}
@Bean
UserDetailsService users() {
// @formatter:off
return new InMemoryUserDetailsManager(
User.withUsername("user")
.password("{noop}password")
.authorities("app")
.build()
);
// @formatter:on
}
@Bean

View File

@ -0,0 +1,45 @@
package run.halo.app.identity.authentication;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* A facility for holding information associated to a specific context.
*
* @author guqing
* @date 2022-04-14
*/
public interface Context {
/**
* Returns the value of the attribute associated to the key.
*
* @param key the key for the attribute
* @param <V> the type of the value for the attribute
* @return the value of the attribute associated to the key, or {@code null} if not available
*/
@Nullable
<V> V get(Object key);
/**
* Returns the value of the attribute associated to the key.
*
* @param key the key for the attribute
* @param <V> the type of the value for the attribute
* @return the value of the attribute associated to the key, or {@code null} if not available
* or not of the specified type
*/
@Nullable
default <V> V get(Class<V> key) {
Assert.notNull(key, "key cannot be null");
V value = get((Object) key);
return key.isInstance(value) ? value : null;
}
/**
* Returns {@code true} if an attribute associated to the key exists, {@code false} otherwise.
*
* @param key the key for the attribute
* @return {@code true} if an attribute associated to the key exists, {@code false} otherwise
*/
boolean hasKey(Object key);
}

View File

@ -0,0 +1,57 @@
package run.halo.app.identity.authentication;
import java.util.Map;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Default implementation of {@link OAuth2TokenContext}.
*
* @author guqing
* @date 2022-04-14
*/
public record DefaultOAuth2TokenContext(Map<Object, Object> context) implements OAuth2TokenContext {
public DefaultOAuth2TokenContext {
context = Map.copyOf(context);
}
@SuppressWarnings("unchecked")
@Nullable
@Override
public <V> V get(Object key) {
return hasKey(key) ? (V) this.context.get(key) : null;
}
@Override
public boolean hasKey(Object key) {
Assert.notNull(key, "key cannot be null");
return this.context.containsKey(key);
}
/**
* Returns a new {@link Builder}.
*
* @return the {@link Builder}
*/
public static Builder builder() {
return new Builder();
}
/**
* A builder for {@link DefaultOAuth2TokenContext}.
*/
public static final class Builder extends AbstractBuilder<DefaultOAuth2TokenContext, Builder> {
private Builder() {
}
/**
* Builds a new {@link DefaultOAuth2TokenContext}.
*
* @return the {@link DefaultOAuth2TokenContext}
*/
public DefaultOAuth2TokenContext build() {
return new DefaultOAuth2TokenContext(getContext());
}
}
}

View File

@ -0,0 +1,76 @@
package run.halo.app.identity.authentication;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.springframework.lang.Nullable;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import run.halo.app.infra.utils.HaloUtils;
/**
* An {@link OAuth2TokenGenerator} that generates a {@link Jwt}
* used for an {@link OAuth2AccessToken}.
*
* @author guqing
* @date 2022-04-14
* @see OAuth2TokenGenerator
* @see Jwt
* @see JwtEncoder
* @see OAuth2AccessToken
*/
public record JwtGenerator(JwtEncoder jwtEncoder) implements OAuth2TokenGenerator<Jwt> {
/**
* Constructs a {@code JwtGenerator} using the provided parameters.
*
* @param jwtEncoder the jwt encoder
*/
public JwtGenerator {
Assert.notNull(jwtEncoder, "jwtEncoder cannot be null");
}
@Nullable
@Override
public Jwt generate(OAuth2TokenContext context) {
if (context.getTokenType() == null
|| !OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
return null;
}
Instant issuedAt = Instant.now();
// TODO Allow configuration for ID Token time-to-live
Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES);
;
String issuer = null;
if (context.getProviderContext() != null) {
issuer = context.getProviderContext().getIssuer();
}
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder();
if (StringUtils.hasText(issuer)) {
claimsBuilder.issuer(issuer);
}
claimsBuilder
.subject(context.getPrincipal().getName())
.issuedAt(issuedAt)
.notBefore(issuedAt)
.id(HaloUtils.simpleUUID())
.expiresAt(expiresAt);
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
claimsBuilder.notBefore(issuedAt);
if (!CollectionUtils.isEmpty(context.getAuthorizedScopes())) {
claimsBuilder.claim(OAuth2ParameterNames.SCOPE, context.getAuthorizedScopes());
}
}
JwsHeader headers = JwsHeader.with(SignatureAlgorithm.RS256).build();
return this.jwtEncoder.encode(JwtEncoderParameters.from(headers, claimsBuilder.build()));
}
}

View File

@ -0,0 +1,162 @@
package run.halo.app.identity.authentication;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import org.springframework.security.core.Authentication;
import org.springframework.util.Assert;
/**
* @author guqing
* @date 2022-04-14
*/
public interface OAuth2TokenContext extends Context {
/**
* Returns the {@link Authentication} representing the {@code Principal} resource owner (or
* client).
*
* @param <T> the type of the {@code Authentication}
* @return the {@link Authentication} representing the {@code Principal} resource owner (or
* client)
*/
default <T extends Authentication> T getPrincipal() {
return get(AbstractBuilder.PRINCIPAL_AUTHENTICATION_KEY);
}
/**
* Returns the {@link ProviderContext provider context}.
*
* @return the {@link ProviderContext}
* @since 0.2.3
*/
default ProviderContext getProviderContext() {
return get(ProviderContext.class);
}
/**
* Returns the authorized scope(s).
*
* @return the authorized scope(s)
*/
default Set<String> getAuthorizedScopes() {
return hasKey(AbstractBuilder.AUTHORIZATION_SCOPE_AUTHENTICATION_KEY)
? get(AbstractBuilder.AUTHORIZATION_SCOPE_AUTHENTICATION_KEY) :
Collections.emptySet();
}
/**
* Returns the {@link OAuth2TokenType token type}.
*
* @return the {@link OAuth2TokenType}
*/
default OAuth2TokenType getTokenType() {
return get(OAuth2TokenType.class);
}
/**
* Base builder for implementations of {@link OAuth2TokenContext}.
*
* @param <T> the type of the context
* @param <B> the type of the builder
*/
abstract class AbstractBuilder<T extends OAuth2TokenContext, B extends AbstractBuilder<T, B>> {
private static final String PRINCIPAL_AUTHENTICATION_KEY =
Authentication.class.getName().concat(".PRINCIPAL");
private static final String AUTHORIZATION_SCOPE_AUTHENTICATION_KEY =
Authentication.class.getName().concat(".AUTHORIZATION_SCOPE");
private final Map<Object, Object> context = new HashMap<>();
/**
* Sets the {@link Authentication} representing the {@code Principal} resource owner (or
* client).
*
* @param principal the {@link Authentication} representing the {@code Principal}
* resource owner (or client)
* @return the {@link AbstractBuilder} for further configuration
*/
public B principal(Authentication principal) {
return put(PRINCIPAL_AUTHENTICATION_KEY, principal);
}
/**
* Sets the {@link ProviderContext provider context}.
*
* @param providerContext the {@link ProviderContext}
* @return the {@link AbstractBuilder} for further configuration
* @since 0.2.3
*/
public B providerContext(ProviderContext providerContext) {
return put(ProviderContext.class, providerContext);
}
/**
* Sets the authorized scope(s).
*
* @param authorizedScopes the authorized scope(s)
* @return the {@link AbstractBuilder} for further configuration
*/
public B authorizedScopes(Set<String> authorizedScopes) {
return put(AUTHORIZATION_SCOPE_AUTHENTICATION_KEY, authorizedScopes);
}
/**
* Sets the {@link OAuth2TokenType token type}.
*
* @param tokenType the {@link OAuth2TokenType}
* @return the {@link AbstractBuilder} for further configuration
*/
public B tokenType(OAuth2TokenType tokenType) {
return put(OAuth2TokenType.class, tokenType);
}
/**
* Associates an attribute.
*
* @param key the key for the attribute
* @param value the value of the attribute
* @return the {@link AbstractBuilder} for further configuration
*/
public B put(Object key, Object value) {
Assert.notNull(key, "key cannot be null");
Assert.notNull(value, "value cannot be null");
this.context.put(key, value);
return getThis();
}
/**
* A {@code Consumer} of the attributes {@code Map}
* allowing the ability to add, replace, or remove.
*
* @param contextConsumer a {@link Consumer} of the attributes {@code Map}
* @return the {@link AbstractBuilder} for further configuration
*/
public B context(Consumer<Map<Object, Object>> contextConsumer) {
contextConsumer.accept(this.context);
return getThis();
}
@SuppressWarnings("unchecked")
protected <V> V get(Object key) {
return (V) this.context.get(key);
}
protected Map<Object, Object> getContext() {
return this.context;
}
@SuppressWarnings("unchecked")
protected final B getThis() {
return (B) this;
}
/**
* Builds a new {@link OAuth2TokenContext}.
*
* @return the {@link OAuth2TokenContext}
*/
public abstract T build();
}
}

View File

@ -0,0 +1,35 @@
package run.halo.app.identity.authentication;
import org.springframework.lang.Nullable;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.OAuth2Token;
/**
* Implementations of this interface are responsible for generating an {@link OAuth2Token}
* using the attributes contained in the {@link OAuth2TokenContext}.
*
* @param <T> the type of the OAuth 2.0 Token
* @author guqing
* @date 2022-04-14
* @see OAuth2Token
* @see OAuth2TokenContext
* @see ClaimAccessor
*/
@FunctionalInterface
public interface OAuth2TokenGenerator<T extends OAuth2Token> {
/**
* Generate an OAuth 2.0 Token using the attributes contained in the {@link OAuth2TokenContext},
* or return {@code null} if the {@link OAuth2TokenContext#getTokenType()} is not supported.
*
* <p>
* If the returned {@link OAuth2Token} has a set of claims, it should implement
* {@link ClaimAccessor}
* in order for it to be stored with the {@link OAuth2Authorization}.
*
* @param context the context containing the OAuth 2.0 Token attributes
* @return an {@link OAuth2Token} or {@code null} if the
* {@link OAuth2TokenContext#getTokenType()} is not supported
*/
@Nullable
T generate(OAuth2TokenContext context);
}

View File

@ -0,0 +1,22 @@
package run.halo.app.identity.authentication;
import java.io.Serializable;
import org.springframework.util.Assert;
/**
* @author guqing
* @date 2022-04-14
*/
public record OAuth2TokenType(String value) implements Serializable {
public static final OAuth2TokenType ACCESS_TOKEN = new OAuth2TokenType("access_token");
public static final OAuth2TokenType REFRESH_TOKEN = new OAuth2TokenType("refresh_token");
/**
* Constructs an {@code OAuth2TokenType} using the provided value.
*
* @param value the value of the token type
*/
public OAuth2TokenType {
Assert.hasText(value, "value cannot be empty");
}
}

View File

@ -0,0 +1,49 @@
package run.halo.app.identity.authentication;
import java.util.function.Supplier;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* A context that holds information of the Provider.
*
* @author guqing
* @date 2022-04-14
*/
public record ProviderContext(ProviderSettings providerSettings,
@Nullable Supplier<String> issuerSupplier) {
/**
* Constructs a {@code ProviderContext} using the provided parameters.
*
* @param providerSettings the provider settings
* @param issuerSupplier a {@code Supplier} for the {@code URL} of the Provider's issuer
* identifier
*/
public ProviderContext {
Assert.notNull(providerSettings, "providerSettings cannot be null");
}
/**
* Returns the {@link ProviderSettings}.
*
* @return the {@link ProviderSettings}
*/
@Override
public ProviderSettings providerSettings() {
return this.providerSettings;
}
/**
* Returns the {@code URL} of the Provider's issuer identifier.
* The issuer identifier is resolved from the constructor parameter {@code Supplier<String>}
* or if not provided then defaults to {@link ProviderSettings#getIssuer()}.
*
* @return the {@code URL} of the Provider's issuer identifier
*/
public String getIssuer() {
return this.issuerSupplier != null
? this.issuerSupplier.get() :
providerSettings().getIssuer();
}
}

View File

@ -0,0 +1,139 @@
package run.halo.app.identity.authentication;
import java.util.Map;
import org.springframework.util.Assert;
import run.halo.app.infra.config.AbstractSettings;
import run.halo.app.infra.config.ConfigurationSettingNames;
/**
* A facility for provider configuration settings.
*
* @author guqing
* @date 2022-04-14
*/
public final class ProviderSettings extends AbstractSettings {
private ProviderSettings(Map<String, Object> settings) {
super(settings);
}
/**
* Returns the URL of the Provider's Issuer Identifier
*
* @return the URL of the Provider's Issuer Identifier
*/
public String getIssuer() {
return getSetting(ConfigurationSettingNames.Provider.ISSUER);
}
/**
* Returns the Provider's OAuth 2.0 Authorization endpoint. The default is {@code /oauth2
* /authorize}.
*
* @return the Authorization endpoint
*/
public String getAuthorizationEndpoint() {
return getSetting(ConfigurationSettingNames.Provider.AUTHORIZATION_ENDPOINT);
}
/**
* Returns the Provider's OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}.
*
* @return the Token endpoint
*/
public String getTokenEndpoint() {
return getSetting(ConfigurationSettingNames.Provider.TOKEN_ENDPOINT);
}
/**
* Returns the Provider's JWK Set endpoint. The default is {@code /oauth2/jwks}.
*
* @return the JWK Set endpoint
*/
public String getJwkSetEndpoint() {
return getSetting(ConfigurationSettingNames.Provider.JWK_SET_ENDPOINT);
}
/**
* Constructs a new {@link Builder} with the default settings.
*
* @return the {@link Builder}
*/
public static Builder builder() {
return new Builder()
.authorizationEndpoint("/oauth2/authorize")
.tokenEndpoint("/oauth2/token")
.jwkSetEndpoint("/oauth2/jwks");
}
/**
* Constructs a new {@link Builder} with the provided settings.
*
* @param settings the settings to initialize the builder
* @return the {@link Builder}
*/
public static Builder withSettings(Map<String, Object> settings) {
Assert.notEmpty(settings, "settings cannot be empty");
return new Builder()
.settings(s -> s.putAll(settings));
}
/**
* A builder for {@link ProviderSettings}.
*/
public static class Builder extends AbstractBuilder<ProviderSettings, Builder> {
private Builder() {
}
/**
* Sets the URL the Provider uses as its Issuer Identifier.
*
* @param issuer the URL the Provider uses as its Issuer Identifier.
* @return the {@link Builder} for further configuration
*/
public Builder issuer(String issuer) {
return setting(ConfigurationSettingNames.Provider.ISSUER, issuer);
}
/**
* Sets the Provider's OAuth 2.0 Authorization endpoint.
*
* @param authorizationEndpoint the Authorization endpoint
* @return the {@link Builder} for further configuration
*/
public Builder authorizationEndpoint(String authorizationEndpoint) {
return setting(ConfigurationSettingNames.Provider.AUTHORIZATION_ENDPOINT,
authorizationEndpoint);
}
/**
* Sets the Provider's OAuth 2.0 Token endpoint.
*
* @param tokenEndpoint the Token endpoint
* @return the {@link Builder} for further configuration
*/
public Builder tokenEndpoint(String tokenEndpoint) {
return setting(ConfigurationSettingNames.Provider.TOKEN_ENDPOINT, tokenEndpoint);
}
/**
* Sets the Provider's JWK Set endpoint.
*
* @param jwkSetEndpoint the JWK Set endpoint
* @return the {@link Builder} for further configuration
*/
public Builder jwkSetEndpoint(String jwkSetEndpoint) {
return setting(ConfigurationSettingNames.Provider.JWK_SET_ENDPOINT, jwkSetEndpoint);
}
/**
* Builds the {@link ProviderSettings}.
*
* @return the {@link ProviderSettings}
*/
@Override
public ProviderSettings build() {
return new ProviderSettings(getSettings());
}
}
}

View File

@ -0,0 +1,116 @@
package run.halo.app.infra.config;
import java.io.Serializable;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import org.springframework.util.Assert;
/**
* Base implementation for configuration settings.
*
* @author guqing
* @date 2022-04-14
*/
public abstract class AbstractSettings implements Serializable {
private final Map<String, Object> settings;
protected AbstractSettings(Map<String, Object> settings) {
Assert.notEmpty(settings, "settings cannot be empty");
this.settings = Collections.unmodifiableMap(new HashMap<>(settings));
}
/**
* Returns a configuration setting.
*
* @param name the name of the setting
* @param <T> the type of the setting
* @return the value of the setting, or {@code null} if not available
*/
@SuppressWarnings("unchecked")
public <T> T getSetting(String name) {
Assert.hasText(name, "name cannot be empty");
return (T) getSettings().get(name);
}
/**
* Returns a {@code Map} of the configuration settings.
*
* @return a {@code Map} of the configuration settings
*/
public Map<String, Object> getSettings() {
return this.settings;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
AbstractSettings that = (AbstractSettings) obj;
return this.settings.equals(that.settings);
}
@Override
public int hashCode() {
return Objects.hash(this.settings);
}
@Override
public String toString() {
return "AbstractSettings {" + "settings=" + this.settings + '}';
}
/**
* A builder for subclasses of {@link AbstractSettings}.
*/
protected abstract static class AbstractBuilder<T extends AbstractSettings,
B extends AbstractBuilder<T, B>> {
private final Map<String, Object> settings = new HashMap<>();
protected AbstractBuilder() {
}
/**
* Sets a configuration setting.
*
* @param name the name of the setting
* @param value the value of the setting
* @return the {@link AbstractBuilder} for further configuration
*/
public B setting(String name, Object value) {
Assert.hasText(name, "name cannot be empty");
Assert.notNull(value, "value cannot be null");
getSettings().put(name, value);
return getThis();
}
/**
* A {@code Consumer} of the configuration settings {@code Map}
* allowing the ability to add, replace, or remove.
*
* @param settingsConsumer a {@link Consumer} of the configuration settings {@code Map}
* @return the {@link AbstractBuilder} for further configuration
*/
public B settings(Consumer<Map<String, Object>> settingsConsumer) {
settingsConsumer.accept(getSettings());
return getThis();
}
public abstract T build();
protected final Map<String, Object> getSettings() {
return this.settings;
}
@SuppressWarnings("unchecked")
protected final B getThis() {
return (B) this;
}
}
}

View File

@ -0,0 +1,88 @@
package run.halo.app.infra.config;
/**
* The names for all the configuration settings.
*
* @author guqing
* @date 2022-04-14
*/
public class ConfigurationSettingNames {
private static final String SETTINGS_NAMESPACE = "settings.";
private ConfigurationSettingNames() {
}
/**
* The names for provider configuration settings.
*/
public static final class Provider {
private static final String PROVIDER_SETTINGS_NAMESPACE =
SETTINGS_NAMESPACE.concat("provider.");
/**
* Set the URL the Provider uses as its Issuer Identifier.
*/
public static final String ISSUER = PROVIDER_SETTINGS_NAMESPACE.concat("issuer");
/**
* Set the Provider's OAuth 2.0 Authorization endpoint.
*/
public static final String AUTHORIZATION_ENDPOINT =
PROVIDER_SETTINGS_NAMESPACE.concat("authorization-endpoint");
/**
* Set the Provider's OAuth 2.0 Token endpoint.
*/
public static final String TOKEN_ENDPOINT =
PROVIDER_SETTINGS_NAMESPACE.concat("token-endpoint");
/**
* Set the Provider's JWK Set endpoint.
*/
public static final String JWK_SET_ENDPOINT =
PROVIDER_SETTINGS_NAMESPACE.concat("jwk-set-endpoint");
private Provider() {
}
}
/**
* The names for token configuration settings.
*/
public static final class Token {
private static final String TOKEN_SETTINGS_NAMESPACE = SETTINGS_NAMESPACE.concat("token.");
/**
* Set the time-to-live for an access token.
*/
public static final String ACCESS_TOKEN_TIME_TO_LIVE =
TOKEN_SETTINGS_NAMESPACE.concat("access-token-time-to-live");
/**
* Set the {@link OAuth2TokenFormat token format} for an access token.
*
* @since 0.2.3
*/
public static final String ACCESS_TOKEN_FORMAT =
TOKEN_SETTINGS_NAMESPACE.concat("access-token-format");
/**
* Set to {@code true} if refresh tokens are reused when returning the access token
* response,
* or {@code false} if a new refresh token is issued.
*/
public static final String REUSE_REFRESH_TOKENS =
TOKEN_SETTINGS_NAMESPACE.concat("reuse-refresh-tokens");
/**
* Set the time-to-live for a refresh token.
*/
public static final String REFRESH_TOKEN_TIME_TO_LIVE =
TOKEN_SETTINGS_NAMESPACE.concat("refresh-token-time-to-live");
private Token() {
}
}
}

View File

@ -0,0 +1,97 @@
package run.halo.app.authentication;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
/**
* Tests for {@link JwtClaimsSet}.
*
* @author guqing
* @date 2022-04-14
*/
public class JwtClaimsSetTest {
public static JwtClaimsSet.Builder jwtClaimsSet() {
String issuer = "https://provider.com";
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
return JwtClaimsSet.builder()
.issuer(issuer)
.subject("subject")
.audience(Collections.singletonList("client-1"))
.issuedAt(issuedAt)
.notBefore(issuedAt)
.expiresAt(expiresAt)
.id("jti")
.claim("custom-claim-name", "custom-claim-value");
}
@Test
public void buildWhenClaimsEmptyThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> JwtClaimsSet.builder().build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("claims cannot be empty");
}
@Test
public void buildWhenAllClaimsProvidedThenAllClaimsAreSet() {
JwtClaimsSet expectedJwtClaimsSet = jwtClaimsSet().build();
JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder()
.issuer(expectedJwtClaimsSet.getIssuer().toExternalForm())
.subject(expectedJwtClaimsSet.getSubject())
.audience(expectedJwtClaimsSet.getAudience())
.issuedAt(expectedJwtClaimsSet.getIssuedAt())
.notBefore(expectedJwtClaimsSet.getNotBefore())
.expiresAt(expectedJwtClaimsSet.getExpiresAt())
.id(expectedJwtClaimsSet.getId())
.claims(claims -> claims.put("custom-claim-name", "custom-claim-value"))
.build();
assertThat(jwtClaimsSet.getIssuer()).isEqualTo(expectedJwtClaimsSet.getIssuer());
assertThat(jwtClaimsSet.getSubject()).isEqualTo(expectedJwtClaimsSet.getSubject());
assertThat(jwtClaimsSet.getAudience()).isEqualTo(expectedJwtClaimsSet.getAudience());
assertThat(jwtClaimsSet.getIssuedAt()).isEqualTo(expectedJwtClaimsSet.getIssuedAt());
assertThat(jwtClaimsSet.getNotBefore()).isEqualTo(expectedJwtClaimsSet.getNotBefore());
assertThat(jwtClaimsSet.getExpiresAt()).isEqualTo(expectedJwtClaimsSet.getExpiresAt());
assertThat(jwtClaimsSet.getId()).isEqualTo(expectedJwtClaimsSet.getId());
assertThat(jwtClaimsSet.<String>getClaim("custom-claim-name")).isEqualTo(
"custom-claim-value");
assertThat(jwtClaimsSet.getClaims()).isEqualTo(expectedJwtClaimsSet.getClaims());
}
@Test
public void fromWhenNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> JwtClaimsSet.from(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("claims cannot be null");
}
@Test
public void fromWhenClaimsProvidedThenCopied() {
JwtClaimsSet expectedJwtClaimsSet = jwtClaimsSet().build();
JwtClaimsSet jwtClaimsSet = JwtClaimsSet.from(expectedJwtClaimsSet).build();
assertThat(jwtClaimsSet.getClaims()).isEqualTo(expectedJwtClaimsSet.getClaims());
}
@Test
public void claimWhenNameNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> JwtClaimsSet.builder().claim(null, "value"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("name cannot be empty");
}
@Test
public void claimWhenValueNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> JwtClaimsSet.builder().claim("name", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("value cannot be null");
}
}

View File

@ -0,0 +1,117 @@
package run.halo.app.authentication;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import run.halo.app.identity.authentication.DefaultOAuth2TokenContext;
import run.halo.app.identity.authentication.JwtGenerator;
import run.halo.app.identity.authentication.OAuth2TokenContext;
import run.halo.app.identity.authentication.OAuth2TokenType;
import run.halo.app.identity.authentication.ProviderContext;
import run.halo.app.identity.authentication.ProviderSettings;
/**
* Tests for {@link JwtGenerator}.
*
* @author guqing
* @date 2022-04-14
*/
public class JwtGeneratorTest {
private JwtEncoder jwtEncoder;
private JwtGenerator jwtGenerator;
private ProviderContext providerContext;
@BeforeEach
public void setUp() {
this.jwtEncoder = mock(JwtEncoder.class);
this.jwtGenerator = new JwtGenerator(this.jwtEncoder);
ProviderSettings
providerSettings = ProviderSettings.builder().issuer("https://provider.com").build();
this.providerContext = new ProviderContext(providerSettings, null);
}
@Test
public void constructorWhenJwtEncoderNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> new JwtGenerator(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("jwtEncoder cannot be null");
}
@Test
public void generateWhenUnsupportedTokenTypeThenReturnNull() {
OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
.tokenType(new OAuth2TokenType("unsupported_token_type"))
.build();
assertThat(this.jwtGenerator.generate(tokenContext)).isNull();
}
@Test
public void generateWhenAccessTokenTypeThenReturnJwt() {
TestingAuthenticationToken authentication =
new TestingAuthenticationToken("userPrincipal", "123456", "ROLE_USER");
Set<String> scope = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
.principal(authentication)
.providerContext(this.providerContext)
.authorizedScopes(scope)
.tokenType(OAuth2TokenType.ACCESS_TOKEN)
.build();
assertGeneratedTokenType(tokenContext);
}
private void assertGeneratedTokenType(OAuth2TokenContext tokenContext) {
this.jwtGenerator.generate(tokenContext);
ArgumentCaptor<JwtEncoderParameters> jwtEncoderParametersArgumentCaptor =
ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(this.jwtEncoder).encode(jwtEncoderParametersArgumentCaptor.capture());
JwtEncoderParameters encoderParameters = jwtEncoderParametersArgumentCaptor.getValue();
JwsHeader jwsHeader = encoderParameters.getJwsHeader();
assertThat(jwsHeader).isNotNull();
assertThat(jwsHeader.getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
JwtClaimsSet jwtClaimsSet = encoderParameters.getClaims();
assertThat(jwtClaimsSet.getIssuer().toExternalForm()).isEqualTo(
tokenContext.getProviderContext().getIssuer());
assertThat(jwtClaimsSet.getSubject()).isEqualTo(
tokenContext.getPrincipal().getName());
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES);
assertThat(jwtClaimsSet.getIssuedAt()).isBetween(issuedAt.minusSeconds(1),
issuedAt.plusSeconds(1));
assertThat(jwtClaimsSet.getExpiresAt()).isBetween(expiresAt.minusSeconds(1),
expiresAt.plusSeconds(1));
if (tokenContext.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
assertThat(jwtClaimsSet.getNotBefore()).isBetween(issuedAt.minusSeconds(1),
issuedAt.plusSeconds(1));
Set<String> scopes = jwtClaimsSet.getClaim(OAuth2ParameterNames.SCOPE);
assertThat(scopes).isEqualTo(tokenContext.getAuthorizedScopes());
}
}
}

View File

@ -0,0 +1,86 @@
package run.halo.app.authentication;
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.ProviderSettings;
/**
* Tests for {@link ProviderSettings}.
*
* @author guqing
* @date 2022-04-14
*/
public class ProviderSettingsTest {
@Test
public void buildWhenDefaultThenDefaultsAreSet() {
ProviderSettings providerSettings = ProviderSettings.builder().build();
assertThat(providerSettings.getIssuer()).isNull();
assertThat(providerSettings.getAuthorizationEndpoint()).isEqualTo("/oauth2/authorize");
assertThat(providerSettings.getTokenEndpoint()).isEqualTo("/oauth2/token");
assertThat(providerSettings.getJwkSetEndpoint()).isEqualTo("/oauth2/jwks");
}
@Test
public void buildWhenSettingsProvidedThenSet() {
String authorizationEndpoint = "/oauth2/v1/authorize";
String tokenEndpoint = "/oauth2/v1/token";
String jwkSetEndpoint = "/oauth2/v1/jwks";
String issuer = "https://example.com:9000";
ProviderSettings providerSettings = ProviderSettings.builder()
.issuer(issuer)
.authorizationEndpoint(authorizationEndpoint)
.tokenEndpoint(tokenEndpoint)
.jwkSetEndpoint(jwkSetEndpoint)
.build();
assertThat(providerSettings.getIssuer()).isEqualTo(issuer);
assertThat(providerSettings.getAuthorizationEndpoint()).isEqualTo(authorizationEndpoint);
assertThat(providerSettings.getTokenEndpoint()).isEqualTo(tokenEndpoint);
assertThat(providerSettings.getJwkSetEndpoint()).isEqualTo(jwkSetEndpoint);
}
@Test
public void settingWhenCustomThenSet() {
ProviderSettings providerSettings = ProviderSettings.builder()
.setting("name1", "value1")
.settings(settings -> settings.put("name2", "value2"))
.build();
assertThat(providerSettings.getSettings()).hasSize(5);
assertThat(providerSettings.<String>getSetting("name1")).isEqualTo("value1");
assertThat(providerSettings.<String>getSetting("name2")).isEqualTo("value2");
}
@Test
public void issuerWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> ProviderSettings.builder().issuer(null))
.withMessage("value cannot be null");
}
@Test
public void authorizationEndpointWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> ProviderSettings.builder().authorizationEndpoint(null))
.withMessage("value cannot be null");
}
@Test
public void tokenEndpointWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> ProviderSettings.builder().tokenEndpoint(null))
.withMessage("value cannot be null");
}
@Test
public void jwksEndpointWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> ProviderSettings.builder().jwkSetEndpoint(null))
.withMessage("value cannot be null");
}
}