feat: add personal access token authentication mechanism (#2116)

* feat: add personal access token authentication

* fix: merge conflicts

* refactor: remove base62 codec

* refactor: remove deprecated method

* feat: add base64 test

* chore: add todo for test only methods
pull/2119/head
guqing 2022-05-27 14:26:37 +08:00 committed by GitHub
parent b5d7f194ef
commit 3c856d04af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 927 additions and 32 deletions

View File

@ -46,6 +46,7 @@ ext {
commonsLang3 = "3.12.0"
jsonschemaGenerator = "4.24.3"
jsonschemaValidator = "1.0.69"
base62 = "0.1.3"
}
dependencies {
@ -66,6 +67,7 @@ dependencies {
implementation "com.github.victools:jsonschema-module-swagger-2:$jsonschemaGenerator"
implementation "com.networknt:json-schema-validator:$jsonschemaValidator"
implementation "org.apache.commons:commons-lang3:$commonsLang3"
implementation "io.seruco.encoding:base62:$base62"
compileOnly 'org.projectlombok:lombok'

View File

@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import javax.crypto.SecretKey;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
@ -38,6 +39,9 @@ import org.springframework.security.web.access.intercept.FilterSecurityIntercept
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.identity.apitoken.DefaultPersonalAccessTokenDecoder;
import run.halo.app.identity.apitoken.PersonalAccessTokenDecoder;
import run.halo.app.identity.apitoken.PersonalAccessTokenUtils;
import run.halo.app.identity.authentication.InMemoryOAuth2AuthorizationService;
import run.halo.app.identity.authentication.JwtGenerator;
import run.halo.app.identity.authentication.OAuth2AuthorizationService;
@ -48,7 +52,7 @@ import run.halo.app.identity.authentication.ProviderContextFilter;
import run.halo.app.identity.authentication.ProviderSettings;
import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationFilter;
import run.halo.app.identity.authentication.verifier.JwtAccessTokenNonBlockedValidator;
import run.halo.app.identity.authentication.verifier.JwtProvidedDecoderAuthenticationManagerResolver;
import run.halo.app.identity.authentication.verifier.TokenAuthenticationManagerResolver;
import run.halo.app.identity.authorization.DefaultRoleBindingLister;
import run.halo.app.identity.authorization.DefaultRoleGetter;
import run.halo.app.identity.authorization.RequestInfoAuthorizationManager;
@ -58,6 +62,7 @@ import run.halo.app.identity.entrypoint.JwtAccessDeniedHandler;
import run.halo.app.identity.entrypoint.JwtAuthenticationEntryPoint;
import run.halo.app.identity.entrypoint.Oauth2LogoutHandler;
import run.halo.app.infra.properties.JwtProperties;
import run.halo.app.infra.utils.HaloUtils;
/**
* @author guqing
@ -132,7 +137,8 @@ public class WebSecurityConfig {
}
AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver() {
return new JwtProvidedDecoderAuthenticationManagerResolver(jwtDecoder());
return new TokenAuthenticationManagerResolver(jwtDecoder(),
personalAccessTokenDecoder());
}
@Bean
@ -142,6 +148,13 @@ public class WebSecurityConfig {
return authenticationManagerBuilder.getOrBuild();
}
@Bean
PersonalAccessTokenDecoder personalAccessTokenDecoder() {
String salt = HaloUtils.readClassPathResourceAsString("apiToken.salt");
SecretKey secretKey = PersonalAccessTokenUtils.convertStringToSecretKey(salt);
return new DefaultPersonalAccessTokenDecoder(oauth2AuthorizationService(), secretKey);
}
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key).build();
@ -199,7 +212,7 @@ public class WebSecurityConfig {
// It'll be deleted next time
UserDetails user = User.withUsername("user")
.password(passwordEncoder().encode("123456"))
.roles("USER")
.authorities("readPostRole")
.build();
return new InMemoryUserDetailsManager(user);
}

View File

@ -0,0 +1,114 @@
package run.halo.app.identity.apitoken;
import java.util.Collection;
import javax.crypto.SecretKey;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.JwtValidationException;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import run.halo.app.identity.authentication.OAuth2Authorization;
import run.halo.app.identity.authentication.OAuth2AuthorizationService;
import run.halo.app.identity.authentication.OAuth2TokenType;
/**
* A default implementation of personal access token authentication.
*
* @author guqing
* @since 2.0.0
*/
public class DefaultPersonalAccessTokenDecoder implements PersonalAccessTokenDecoder {
private static final String DECODING_ERROR_MESSAGE_TEMPLATE =
"An error occurred while attempting to decode the personal access token: %s";
private OAuth2TokenValidator<PersonalAccessToken> personalAccessTokenValidator =
createDefault();
private final OAuth2AuthorizationService oauth2AuthorizationService;
private final SecretKey secretKey;
public DefaultPersonalAccessTokenDecoder(
OAuth2AuthorizationService oauth2AuthorizationService, SecretKey secretKey) {
this.oauth2AuthorizationService = oauth2AuthorizationService;
this.secretKey = secretKey;
}
/**
* Use this {@link PersonalAccessToken} Validator.
*
* @param personalAccessTokenValidator - the PersonalAccessToken Validator to use
*/
public void setTokenValidator(
OAuth2TokenValidator<PersonalAccessToken> personalAccessTokenValidator) {
Assert.notNull(personalAccessTokenValidator, "personalAccessTokenValidator cannot be null");
this.personalAccessTokenValidator = personalAccessTokenValidator;
}
@Override
public PersonalAccessToken decode(String token) throws PersonalAccessTokenException {
preValidate(token);
PersonalAccessToken personalAccessToken = createPersonalAccessToken(token);
return validate(personalAccessToken);
}
private void preValidate(String token) {
if (secretKey == null) {
return;
}
boolean matches = PersonalAccessTokenUtils.verifyChecksum(token, secretKey);
if (matches) {
return;
}
throw new PersonalAccessTokenException(String.format(DECODING_ERROR_MESSAGE_TEMPLATE,
"Failed to verify the personal access token"));
}
private PersonalAccessToken createPersonalAccessToken(String token) {
OAuth2Authorization oauth2Authorization = oauth2AuthorizationService.findByToken(token,
OAuth2TokenType.ACCESS_TOKEN);
if (oauth2Authorization == null) {
throw new PersonalAccessTokenException(String.format(DECODING_ERROR_MESSAGE_TEMPLATE,
"Failed to retrieve personal access token"));
}
OAuth2Authorization.Token<OAuth2AccessToken> accessTokenToken =
oauth2Authorization.getToken(OAuth2AccessToken.class);
if (accessTokenToken == null) {
throw new PersonalAccessTokenException(String.format(DECODING_ERROR_MESSAGE_TEMPLATE,
"Failed to retrieve personal access token"));
}
return createPersonalAccessToken(accessTokenToken.getToken());
}
private PersonalAccessToken createPersonalAccessToken(OAuth2AccessToken token) {
return new PersonalAccessToken(token.getTokenValue(), token.getIssuedAt(),
token.getExpiresAt(), token.getScopes());
}
private PersonalAccessToken validate(PersonalAccessToken token) {
OAuth2TokenValidatorResult result = this.personalAccessTokenValidator.validate(token);
if (result.hasErrors()) {
Collection<OAuth2Error> errors = result.getErrors();
String validationErrorString = getValidationExceptionMessage(errors);
throw new JwtValidationException(validationErrorString, errors);
}
return token;
}
private String getValidationExceptionMessage(Collection<OAuth2Error> errors) {
for (OAuth2Error oauth2Error : errors) {
if (!StringUtils.hasText(oauth2Error.getDescription())) {
return String.format(DECODING_ERROR_MESSAGE_TEMPLATE, oauth2Error.getDescription());
}
}
return "Unable to validate personal access token";
}
public static OAuth2TokenValidator<PersonalAccessToken> createDefault() {
return new DelegatingOAuth2TokenValidator<>(new PersonalAccessTokenTimestampValidator());
}
}

View File

@ -0,0 +1,60 @@
package run.halo.app.identity.apitoken;
import java.time.Instant;
import java.util.Collections;
import java.util.Set;
import org.springframework.security.oauth2.core.AbstractOAuth2Token;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
/**
* <p>An implementation of an {@link AbstractOAuth2Token} representing a personal access token.</p>
* <p>A personal-access-token is a credential that represents an authorization granted by the
* resource owner.</p>
* <p>It is primarily used by the client to access protected resources on either a resource
* server.</p>
* <p>All personal access tokens created by administrators of the {@code Halo} application are
* permanent tokens that cannot be regenerated.</p>
*
* @author guqing
* @since 2.0.0
*/
public class PersonalAccessToken extends OAuth2AccessToken {
public static final AuthorizationGrantType PERSONAL_ACCESS_TOKEN =
new AuthorizationGrantType("personal_access_token");
/**
* Constructs an {@code PersonalAccessToken} using the provided parameters.
*
* @param tokenValue the token value
* @param issuedAt the time at which the token was issued
*/
public PersonalAccessToken(String tokenValue, Instant issuedAt) {
this(tokenValue, issuedAt, null);
}
/**
* Constructs an {@code PersonalAccessToken} using the provided parameters.
*
* @param tokenValue the token value
* @param issuedAt the time at which the token was issued
* @param expiresAt the time at which the token expires
*/
public PersonalAccessToken(String tokenValue, Instant issuedAt, Instant expiresAt) {
this(tokenValue, issuedAt, expiresAt, Collections.emptySet());
}
/**
* Constructs an {@code PersonalAccessToken} using the provided parameters.
*
* @param tokenValue the token value
* @param issuedAt the time at which the token was issued
* @param expiresAt the time at which the token expires
* @param roles role names
*/
public PersonalAccessToken(String tokenValue, Instant issuedAt, Instant expiresAt,
Set<String> roles) {
super(TokenType.BEARER, tokenValue, issuedAt, expiresAt, roles);
}
}

View File

@ -0,0 +1,75 @@
package run.halo.app.identity.apitoken;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.Transient;
import run.halo.app.identity.authentication.verifier.AbstractOAuth2TokenAuthenticationToken;
/**
* @author guqing
* @since 2.0.0
*/
@Transient
public class PersonalAccessTokenAuthenticationToken extends
AbstractOAuth2TokenAuthenticationToken<PersonalAccessToken> {
private final String name;
/**
* Constructs a {@code PersonalAccessToken} using the provided parameters.
*
* @param personalAccessToken the PersonalAccessToken
*/
public PersonalAccessTokenAuthenticationToken(PersonalAccessToken personalAccessToken) {
super(personalAccessToken);
this.name = personalAccessToken.getTokenValue();
}
/**
* Constructs a {@code PersonalAccessTokenAuthenticationToken} using the provided parameters.
*
* @param personalAccessToken the PersonalAccessToken
* @param authorities the authorities assigned to the PersonalAccessToken
*/
public PersonalAccessTokenAuthenticationToken(PersonalAccessToken personalAccessToken,
Collection<? extends GrantedAuthority> authorities) {
super(personalAccessToken, authorities);
this.setAuthenticated(true);
this.name = personalAccessToken.getTokenValue();
}
/**
* Constructs a {@code PersonalAccessTokenAuthenticationToken} using the provided parameters.
*
* @param personalAccessToken the PersonalAccessToken
* @param authorities the authorities assigned to the PersonalAccessToken
* @param name the principal name
*/
public PersonalAccessTokenAuthenticationToken(PersonalAccessToken personalAccessToken,
Collection<? extends GrantedAuthority> authorities,
String name) {
super(personalAccessToken, authorities);
this.setAuthenticated(true);
this.name = name;
}
@Override
public Object getCredentials() {
return "";
}
@Override
public Map<String, Object> getTokenAttributes() {
return Map.of();
}
/**
* The principal name which is, by default, the {@link PersonalAccessToken}'s tokenValue.
*/
@Override
public String getName() {
return this.name;
}
}

View File

@ -0,0 +1,10 @@
package run.halo.app.identity.apitoken;
/**
* @author guqing
* @since 2.0.0
*/
public interface PersonalAccessTokenDecoder {
PersonalAccessToken decode(String token) throws PersonalAccessTokenException;
}

View File

@ -0,0 +1,18 @@
package run.halo.app.identity.apitoken;
/**
* Base exception for all personal access token related errors.
*
* @author guqing
* @since 2.0.0
*/
public class PersonalAccessTokenException extends RuntimeException {
public PersonalAccessTokenException(String message) {
super(message);
}
public PersonalAccessTokenException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,76 @@
package run.halo.app.identity.apitoken;
import java.util.Collection;
import java.util.stream.Collectors;
import javax.crypto.SecretKey;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationToken;
import run.halo.app.identity.authentication.verifier.InvalidBearerTokenException;
/**
* <p>An AuthenticationProvider implementation of the personal-access-token based
* <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
* {@link PersonalAccessTokenUtils#generate(PersonalAccessTokenType, SecretKey)}-generated access
* token.</p>
*
* <p>The composition format of personal-access-token is:
* <pre>{two letter type prefix}_{32-bit secure random value}{checksum}</pre>
* Token type prefix is an explicit way to make a token recognizable. such as {@code h}
* represents {@code halo} and {@code c} represents the content api.</p>
* <p>Make these prefixes legible in the token to improve readability. Therefore, a separator is
* added:{@code _} and when you double-click it, it reliably selects the entire token.</p>
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
public class PersonalAccessTokenProvider implements AuthenticationProvider {
private final PersonalAccessTokenDecoder personalAccessTokenDecoder;
public PersonalAccessTokenProvider(PersonalAccessTokenDecoder personalAccessTokenDecoder) {
this.personalAccessTokenDecoder = personalAccessTokenDecoder;
}
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
if (!(authentication instanceof BearerTokenAuthenticationToken bearer)) {
return null;
}
PersonalAccessToken accessToken = getPersonalAccessToken(bearer.getToken());
PersonalAccessTokenAuthenticationToken token = convert(accessToken);
token.setDetails(bearer.getDetails());
log.debug("Authenticated token");
return token;
}
private PersonalAccessTokenAuthenticationToken convert(PersonalAccessToken accessToken) {
Collection<GrantedAuthority> authorities = accessToken.getScopes()
.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new PersonalAccessTokenAuthenticationToken(accessToken, authorities);
}
private PersonalAccessToken getPersonalAccessToken(String tokenValue) {
try {
return this.personalAccessTokenDecoder.decode(tokenValue);
} catch (PersonalAccessTokenException failed) {
log.debug("Failed to authenticate since the personal-access-token was invalid");
throw new InvalidBearerTokenException(failed.getMessage(), failed);
}
}
@Override
public boolean supports(Class<?> authentication) {
return PersonalAccessToken.class.isAssignableFrom(authentication);
}
}

View File

@ -0,0 +1,86 @@
package run.halo.app.identity.apitoken;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.util.Assert;
/**
* <p>An implementation of {@link OAuth2TokenValidator} for verifying personal access token.</p>
* <p>Because clocks can differ between the personal-access-token source, say the Authorization
* Server, and its destination, say the Resource Server, there is a default clock leeway
* exercised when deciding if the current time is within the {@link PersonalAccessToken}'s
* specified operating window.</p>
*
* @author guqing
* @see OAuth2TokenValidator
* @see PersonalAccessToken
* @since 2.0.0
*/
@Slf4j
public class PersonalAccessTokenTimestampValidator implements
OAuth2TokenValidator<PersonalAccessToken> {
private static final Duration DEFAULT_MAX_CLOCK_SKEW = Duration.of(60, ChronoUnit.SECONDS);
private final Duration clockSkew;
private Clock clock = Clock.systemUTC();
/**
* A basic instance with no custom verification and the default max clock skew.
*/
public PersonalAccessTokenTimestampValidator() {
this(DEFAULT_MAX_CLOCK_SKEW);
}
public PersonalAccessTokenTimestampValidator(Duration clockSkew) {
Assert.notNull(clockSkew, "clockSkew cannot be null");
this.clockSkew = clockSkew;
}
@Override
public OAuth2TokenValidatorResult validate(PersonalAccessToken token) {
Assert.notNull(token, "personalAccessToken cannot be null");
Instant expiry = token.getExpiresAt();
if (expiry != null) {
if (Instant.now(this.clock).minus(this.clockSkew).isAfter(expiry)) {
OAuth2Error oauth2Error =
createOauth2Error(
String.format("personal-access-token expired at %s", token.getExpiresAt()));
return OAuth2TokenValidatorResult.failure(oauth2Error);
}
}
Instant notBefore = token.getIssuedAt();
if (notBefore != null) {
if (Instant.now(this.clock).plus(this.clockSkew).isBefore(notBefore)) {
OAuth2Error oauth2Error = createOauth2Error(
String.format("personal-access-token used before %s", token.getIssuedAt()));
return OAuth2TokenValidatorResult.failure(oauth2Error);
}
}
return OAuth2TokenValidatorResult.success();
}
private OAuth2Error createOauth2Error(String reason) {
log.debug(reason);
return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, reason,
"https://github.com/halo-dev/rfcs/blob/main/identity/003-encryption.md");
}
/**
* Use this {@link Clock} with {@link Instant#now()} for assessing timestamp validity.
*
* @param clock A clock providing access to the current instant
*/
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}
}

View File

@ -0,0 +1,24 @@
package run.halo.app.identity.apitoken;
import org.springframework.util.Assert;
/**
* <p>Personal access token type.</p>
*
* @author guqing
* @since 2.0.0
*/
public record PersonalAccessTokenType(String value) {
public static final PersonalAccessTokenType ADMIN_TOKEN = new PersonalAccessTokenType("ha");
public static final PersonalAccessTokenType CONTENT_TOKEN = new PersonalAccessTokenType("hc");
/**
* Constructs an {@code PersonalAccessTokenType} using the provided value.
*
* @param value the value of the token type
*/
public PersonalAccessTokenType {
Assert.hasText(value, "value cannot be empty");
}
}

View File

@ -0,0 +1,102 @@
package run.halo.app.identity.apitoken;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.zip.CRC32;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.KeyGenerators;
import run.halo.app.infra.utils.Base62Utils;
/**
* Tool class for generating and verifying personal access token.
*
* @author guqing
* @see
* <a href="https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/">githubs-new-authentication-token-formats</a>
* @since 2.0.0
*/
public class PersonalAccessTokenUtils {
/**
* <p>Generate personal access token through secretKey.</p>
* <p>The format is tokenValue + 8-bit checksum.</p>
*
* @param secretKey The secretKey is used to generate a salt
* @return personal access token
*/
public static String generate(PersonalAccessTokenType tokenType, SecretKey secretKey) {
// Generate 32-bit random API token.
String apiToken = new String(Hex.encode(KeyGenerators.secureRandom(16).generateKey()));
// crc32(apiToken + salt)
String salt = convertSecretKeyToString(secretKey);
String checksum = crc32((apiToken + salt).getBytes());
// Encode it as base62
String encodedValue = Base62Utils.encode(apiToken + checksum);
return String.format("%s_%s", tokenType.value(), encodedValue);
}
/**
* <p>Decoded the personalAccessToken through base62, the intercepted 8-bit checksum is
* compared with the result generated by the checksum rule.</p>
* <p>If it matches, it returns {@code true}, otherwise it returns {@code false}.</p>
*
* @param personalAccessToken personal access token to verify
* @param secretKey The secretKey is used to generate a salt
* @return {@code true} if the original checksum matches the generated checksum,otherwise
* it returns {@code false}
*/
public static boolean verifyChecksum(String personalAccessToken, SecretKey secretKey) {
String tokenValue = PersonalTokenTypeUtils.removeTypePrefix(personalAccessToken);
String decodedToken = Base62Utils.decodeToString(tokenValue);
int length = decodedToken.length();
// Gets api token and checksum from decodedToken.
String apiToken = decodedToken.substring(0, length - 8);
String originalChecksum = decodedToken.substring(length - 8);
String salt = convertSecretKeyToString(secretKey);
String checksum = crc32((apiToken + salt).getBytes());
return StringUtils.equals(originalChecksum, checksum);
}
public static String convertSecretKeyToString(SecretKey secretKey) {
byte[] rawData = secretKey.getEncoded();
return Base64.getEncoder().encodeToString(rawData);
}
/**
* <p>Generate 256 bit {@link SecretKey} through AES algorithm.</p>
*
* @return secret key
*/
public static SecretKey generateSecretKey() {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(256);
return keyGenerator.generateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
/**
* <p>Convert the encoded value of 256 bit string generated by AES algorithm to
* {@link SecretKey}.</p>
*
* @return secret key
*/
public static SecretKey convertStringToSecretKey(String encodedKey) {
byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
return new SecretKeySpec(decodedKey, 0, decodedKey.length, "AES");
}
private static String crc32(byte[] array) {
CRC32 crc32 = new CRC32();
crc32.update(array);
return Long.toHexString(crc32.getValue());
}
}

View File

@ -0,0 +1,66 @@
package run.halo.app.identity.apitoken;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.Assert;
/**
* An util for Personal access token type.
*
* @author guqing
* @since 2.0.0
*/
public class PersonalTokenTypeUtils {
private static final String TOKEN_TYPE_SEPARATOR = "_";
/**
* Remove the type prefix in the personal access token string.
*
* @param tokenValue personal access token
* @return token removed prefix
*/
public static String removeTypePrefix(String tokenValue) {
String adminType = PersonalAccessTokenType.ADMIN_TOKEN.value() + TOKEN_TYPE_SEPARATOR;
if (StringUtils.startsWith(tokenValue, adminType)) {
return StringUtils.substringAfter(tokenValue, adminType);
}
String contentType = PersonalAccessTokenType.CONTENT_TOKEN.value() + TOKEN_TYPE_SEPARATOR;
if (StringUtils.startsWith(tokenValue, contentType)) {
return StringUtils.substringAfter(tokenValue, contentType);
}
return tokenValue;
}
/**
* Judge whether it is a personal access token of admin type.
*
* @param tokenValue personal access token
* @return {@code true} if it is a token of admin type, otherwise {@code false}
*/
public static boolean isAdminToken(String tokenValue) {
Assert.notNull(tokenValue, "The tokenValue must not be null.");
String adminType = PersonalAccessTokenType.ADMIN_TOKEN.value() + TOKEN_TYPE_SEPARATOR;
return StringUtils.startsWith(tokenValue, adminType);
}
/**
* Judge whether it is a personal access token of content type.
*
* @param tokenValue personal access token
* @return {@code true} if it is a token of content type, otherwise {@code false}
*/
public static boolean isContentToken(String tokenValue) {
String contentType = PersonalAccessTokenType.CONTENT_TOKEN.value() + TOKEN_TYPE_SEPARATOR;
return StringUtils.startsWith(tokenValue, contentType);
}
/**
* Determine whether there is a personal access token type prefix.
*
* @param tokenValue personal access token
* @return {@code true} if it is starts with {@link PersonalAccessTokenType#ADMIN_TOKEN} or
* {@link PersonalAccessTokenType#CONTENT_TOKEN} prefix, otherwise {@code false}
*/
public static boolean isPersonalAccessToken(String tokenValue) {
return isAdminToken(tokenValue) || isContentToken(tokenValue);
}
}

View File

@ -1,25 +0,0 @@
package run.halo.app.identity.authentication.verifier;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.util.Assert;
/**
* A jwt resolver for {@link AuthenticationManager} use {@link JwtDecoder}.
*
* @author guqing
* @since 2.0.0
*/
public record JwtProvidedDecoderAuthenticationManagerResolver(JwtDecoder jwtDecoder)
implements AuthenticationManagerResolver<HttpServletRequest> {
public JwtProvidedDecoderAuthenticationManagerResolver {
Assert.notNull(jwtDecoder, "jwtDecoder cannot be null");
}
@Override
public AuthenticationManager resolve(HttpServletRequest request) {
return new JwtAuthenticationProvider(jwtDecoder)::authenticate;
}
}

View File

@ -0,0 +1,58 @@
package run.halo.app.identity.authentication.verifier;
import com.nimbusds.jwt.JWTParser;
import jakarta.servlet.http.HttpServletRequest;
import java.text.ParseException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.util.Assert;
import run.halo.app.identity.apitoken.PersonalAccessTokenDecoder;
import run.halo.app.identity.apitoken.PersonalAccessTokenProvider;
import run.halo.app.identity.apitoken.PersonalTokenTypeUtils;
/**
* A token resolver for {@link AuthenticationManager} use {@link JwtDecoder} and
* {@link PersonalAccessTokenDecoder}.
*
* @author guqing
* @since 2.0.0
*/
public record TokenAuthenticationManagerResolver(JwtDecoder jwtDecoder,
PersonalAccessTokenDecoder personalTokenDecoder)
implements AuthenticationManagerResolver<HttpServletRequest> {
private static final DefaultBearerTokenResolver defaultBearerTokenResolver =
new DefaultBearerTokenResolver();
public TokenAuthenticationManagerResolver {
Assert.notNull(jwtDecoder, "jwtDecoder cannot be null");
Assert.notNull(personalTokenDecoder, "personalAccessTokenDecoder cannot be null");
}
@Override
public AuthenticationManager resolve(HttpServletRequest request) {
String bearerToken = defaultBearerTokenResolver.resolve(request);
if (useJwt(bearerToken)) {
return new JwtAuthenticationProvider(jwtDecoder)::authenticate;
} else if (PersonalTokenTypeUtils.isPersonalAccessToken(bearerToken)) {
return new PersonalAccessTokenProvider(personalTokenDecoder)::authenticate;
}
return authentication -> {
throw new AuthenticationServiceException("Cannot authenticate " + authentication);
};
}
private boolean useJwt(String token) {
try {
JWTParser.parse(token);
return true;
} catch (ParseException e) {
return false;
}
}
}

View File

@ -76,7 +76,7 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
String roleBindingDescriber(RoleBinding roleBinding, Subject subject) {
String describeSubject = String.format("%s %s", subject.kind, subject.name);
return String.format("RoleBinding %s of %s %s to %s", roleBinding.metadata().getName(),
return String.format("RoleBinding %s of %s %s to %s", roleBinding.getMetadata().getName(),
roleBinding.roleRef.getKind(), roleBinding.roleRef.getName(), describeSubject);
}

View File

@ -1,10 +1,25 @@
package run.halo.app.infra;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Set;
import javax.crypto.SecretKey;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Schemes;
import run.halo.app.identity.apitoken.PersonalAccessToken;
import run.halo.app.identity.apitoken.PersonalAccessTokenType;
import run.halo.app.identity.apitoken.PersonalAccessTokenUtils;
import run.halo.app.identity.authentication.OAuth2Authorization;
import run.halo.app.identity.authentication.OAuth2AuthorizationService;
import run.halo.app.identity.authorization.PolicyRule;
import run.halo.app.identity.authorization.Role;
import run.halo.app.infra.utils.HaloUtils;
/**
* @author guqing
@ -12,8 +27,68 @@ import run.halo.app.identity.authorization.Role;
*/
@Component
public class SchemeInitializer implements ApplicationListener<ApplicationStartedEvent> {
private final OAuth2AuthorizationService oauth2AuthorizationService;
private final ExtensionClient extensionClient;
public SchemeInitializer(OAuth2AuthorizationService oauth2AuthorizationService,
ExtensionClient extensionClient) {
this.oauth2AuthorizationService = oauth2AuthorizationService;
this.extensionClient = extensionClient;
}
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
Schemes.INSTANCE.register(Role.class);
// TODO These test only methods will be removed in the future
initRoleForTesting();
initPersonalAccessTokenForTesting();
}
private void initRoleForTesting() {
Role role = new Role();
role.setApiVersion("v1alpha1");
role.setKind("Role");
Metadata metadata = new Metadata();
metadata.setName("readPostRole");
role.setMetadata(metadata);
List<PolicyRule> rules = List.of(
new PolicyRule.Builder().apiGroups("").resources("posts").verbs("list", "get")
.build(),
new PolicyRule.Builder().apiGroups("").resources("categories").verbs("*")
.build(),
new PolicyRule.Builder().nonResourceURLs("/healthy").verbs("get", "post", "head")
.build()
);
role.setRules(rules);
extensionClient.create(role);
}
private void initPersonalAccessTokenForTesting() {
String salt = HaloUtils.readClassPathResourceAsString("apiToken.salt");
SecretKey secretKey = PersonalAccessTokenUtils.convertStringToSecretKey(salt);
String tokenValue =
PersonalAccessTokenUtils.generate(PersonalAccessTokenType.ADMIN_TOKEN, secretKey);
Set<String> roles = Set.of("readPostRole");
OAuth2AccessToken personalAccessToken =
new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, Instant.now(),
Instant.now().plus(2, ChronoUnit.HOURS), roles);
System.out.println(
"Initializing a personal access token is only for development or testing: "
+ tokenValue);
OAuth2Authorization authorization = new OAuth2Authorization.Builder()
.id(HaloUtils.simpleUUID())
.token(personalAccessToken)
.principalName(tokenValue)
.authorizationGrantType(PersonalAccessToken.PERSONAL_ACCESS_TOKEN)
.build();
oauth2AuthorizationService.save(authorization);
}
}

View File

@ -0,0 +1,58 @@
package run.halo.app.infra.utils;
import io.seruco.encoding.base62.Base62;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils;
/**
* <p>Base62 tool class, which provides the encoding and decoding scheme of base62.</p>
*
* @author guqing
* @since 2.0.0
*/
public class Base62Utils {
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private static final Base62 INSTANCE = Base62.createInstance();
public static String encode(String source) {
return encode(source, DEFAULT_CHARSET);
}
/**
* Base62 encode.
*
* @param source the encoded base62 string
* @param charset the charset default is utf_8
* @return encoded string by base62
*/
public static String encode(String source, Charset charset) {
return encode(StringUtils.getBytes(source, charset));
}
public static String encode(byte[] source) {
return new String(INSTANCE.encode(source));
}
/**
* Base62 decode.
*
* @param base62Str the Base62 decoded string
* @return decoded bytes
*/
public static byte[] decode(String base62Str) {
return decode(StringUtils.getBytes(base62Str, DEFAULT_CHARSET));
}
public static byte[] decode(byte[] base62bytes) {
return INSTANCE.decode(base62bytes);
}
public static String decodeToString(String source) {
return decodeToString(source, DEFAULT_CHARSET);
}
public static String decodeToString(String source, Charset charset) {
return StringUtils.toEncodedString(decode(source), charset);
}
}

View File

@ -1,7 +1,12 @@
package run.halo.app.infra.utils;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StreamUtils;
/**
* @author guqing
@ -16,4 +21,21 @@ public class HaloUtils {
public static String simpleUUID() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* <p>Read the file under the classpath as a string.</p>
*
* @param location the file location relative to classpath
* @return file content
*/
public static String readClassPathResourceAsString(String location) {
ClassPathResource classPathResource = new ClassPathResource(location);
try (InputStream inputStream = classPathResource.getInputStream()) {
return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalArgumentException(
String.format("Failed to read class path file as string from location [%s]",
location), e);
}
}
}

View File

@ -0,0 +1 @@
bySwF9ZxJ2JpQYs830+eA3Fw6O7F/ULDvyYGEPaZKwY=

View File

@ -0,0 +1,47 @@
package run.halo.app.infra.utils;
import static org.assertj.core.api.Assertions.assertThat;
import io.seruco.encoding.base62.Base62;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link Base62}.
*
* @author guqing
* @since 2.0.0
*/
class Base62UtilsTest {
@Test
void encode() {
getNaiveTestSet().forEach(
(str, encoded) -> assertThat(Base62Utils.encode(str)).isEqualTo(encoded));
}
@Test
void decodeToString() {
getNaiveTestSet().forEach(
(str, encoded) -> assertThat(Base62Utils.decodeToString(encoded)).isEqualTo(str));
}
public static Map<String, String> getNaiveTestSet() {
Map<String, String> testSet = new HashMap<>();
testSet.put("", "");
testSet.put("a", "1Z");
testSet.put("Hello", "5TP3P3v");
testSet.put("Hello world!", "T8dgcjRGuYUueWht");
testSet.put("Just a test", "7G0iTmJjQFG2t6K");
testSet.put("!!!!!!!!!!!!!!!!!", "4A7f43EVXQoS6Am897ZKbAn");
testSet.put("0123456789", "18XU2xYejWO9d3");
testSet.put("The quick brown fox jumps over the lazy dog",
"83UM8dOjD4xrzASgmqLOXTgTagvV1jPegUJ39mcYnwHwTlzpdfKXvpp4RL");
testSet.put("Sphinx of black quartz, judge my vow",
"1Ul5yQGNM8YFBp3sz19dYj1kTp95OW7jI8pTcTP5JhYjIaFmx");
return testSet;
}
}

View File

@ -11,6 +11,7 @@ import java.io.IOException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.List;
import javax.crypto.SecretKey;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
@ -41,6 +42,9 @@ import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.test.context.TestPropertySource;
import run.halo.app.extension.Metadata;
import run.halo.app.identity.apitoken.DefaultPersonalAccessTokenDecoder;
import run.halo.app.identity.apitoken.PersonalAccessTokenDecoder;
import run.halo.app.identity.apitoken.PersonalAccessTokenUtils;
import run.halo.app.identity.authentication.InMemoryOAuth2AuthorizationService;
import run.halo.app.identity.authentication.JwtGenerator;
import run.halo.app.identity.authentication.OAuth2AuthorizationService;
@ -51,7 +55,7 @@ import run.halo.app.identity.authentication.ProviderContextFilter;
import run.halo.app.identity.authentication.ProviderSettings;
import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationFilter;
import run.halo.app.identity.authentication.verifier.JwtAccessTokenNonBlockedValidator;
import run.halo.app.identity.authentication.verifier.JwtProvidedDecoderAuthenticationManagerResolver;
import run.halo.app.identity.authentication.verifier.TokenAuthenticationManagerResolver;
import run.halo.app.identity.authorization.PolicyRule;
import run.halo.app.identity.authorization.RequestInfoAuthorizationManager;
import run.halo.app.identity.authorization.Role;
@ -60,6 +64,7 @@ import run.halo.app.identity.authorization.RoleRef;
import run.halo.app.identity.authorization.Subject;
import run.halo.app.identity.entrypoint.Oauth2LogoutHandler;
import run.halo.app.infra.properties.JwtProperties;
import run.halo.app.infra.utils.HaloUtils;
/**
* @author guqing
@ -160,11 +165,18 @@ public class TestWebSecurityConfig {
}
AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver() {
return new JwtProvidedDecoderAuthenticationManagerResolver(jwtDecoder());
return new TokenAuthenticationManagerResolver(jwtDecoder(), personalAccessTokenDecoder());
}
@Bean
AuthenticationManager authenticationManager() throws Exception {
PersonalAccessTokenDecoder personalAccessTokenDecoder() {
String salt = HaloUtils.readClassPathResourceAsString("apiToken.salt");
SecretKey secretKey = PersonalAccessTokenUtils.convertStringToSecretKey(salt);
return new DefaultPersonalAccessTokenDecoder(oauth2AuthorizationService(), secretKey);
}
@Bean
AuthenticationManager authenticationManager() {
authenticationManagerBuilder.authenticationProvider(passwordAuthenticationProvider())
.authenticationProvider(oauth2RefreshTokenAuthenticationProvider());
return authenticationManagerBuilder.getOrBuild();

View File

@ -0,0 +1 @@
bySwF9ZxJ2JpQYs830+eA3Fw6O7F/ULDvyYGEPaZKwY=