mirror of https://github.com/halo-dev/halo
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 classpull/1846/head
parent
cec6897574
commit
20e6d4d1eb
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()));
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue