From 3c856d04af29afa2ca5b675dd5e3c66124ea6329 Mon Sep 17 00:00:00 2001
From: guqing <38999863+guqing@users.noreply.github.com>
Date: Fri, 27 May 2022 14:26:37 +0800
Subject: [PATCH] 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
---
build.gradle | 2 +
.../halo/app/config/WebSecurityConfig.java | 19 ++-
.../DefaultPersonalAccessTokenDecoder.java | 114 ++++++++++++++++++
.../apitoken/PersonalAccessToken.java | 60 +++++++++
...ersonalAccessTokenAuthenticationToken.java | 75 ++++++++++++
.../apitoken/PersonalAccessTokenDecoder.java | 10 ++
.../PersonalAccessTokenException.java | 18 +++
.../apitoken/PersonalAccessTokenProvider.java | 76 ++++++++++++
...PersonalAccessTokenTimestampValidator.java | 86 +++++++++++++
.../apitoken/PersonalAccessTokenType.java | 24 ++++
.../apitoken/PersonalAccessTokenUtils.java | 102 ++++++++++++++++
.../apitoken/PersonalTokenTypeUtils.java | 66 ++++++++++
...dDecoderAuthenticationManagerResolver.java | 25 ----
.../TokenAuthenticationManagerResolver.java | 58 +++++++++
.../authorization/DefaultRuleResolver.java | 2 +-
.../run/halo/app/infra/SchemeInitializer.java | 75 ++++++++++++
.../run/halo/app/infra/utils/Base62Utils.java | 58 +++++++++
.../run/halo/app/infra/utils/HaloUtils.java | 22 ++++
src/main/resources/apiToken.salt | 1 +
.../halo/app/infra/utils/Base62UtilsTest.java | 47 ++++++++
.../security/TestWebSecurityConfig.java | 18 ++-
src/test/resources/apiToken.salt | 1 +
22 files changed, 927 insertions(+), 32 deletions(-)
create mode 100644 src/main/java/run/halo/app/identity/apitoken/DefaultPersonalAccessTokenDecoder.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessToken.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenAuthenticationToken.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenDecoder.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenException.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenProvider.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenTimestampValidator.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenType.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenUtils.java
create mode 100644 src/main/java/run/halo/app/identity/apitoken/PersonalTokenTypeUtils.java
delete mode 100644 src/main/java/run/halo/app/identity/authentication/verifier/JwtProvidedDecoderAuthenticationManagerResolver.java
create mode 100644 src/main/java/run/halo/app/identity/authentication/verifier/TokenAuthenticationManagerResolver.java
create mode 100644 src/main/java/run/halo/app/infra/utils/Base62Utils.java
create mode 100644 src/main/resources/apiToken.salt
create mode 100644 src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java
create mode 100644 src/test/resources/apiToken.salt
diff --git a/build.gradle b/build.gradle
index 0d069bdeb..d37928842 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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'
diff --git a/src/main/java/run/halo/app/config/WebSecurityConfig.java b/src/main/java/run/halo/app/config/WebSecurityConfig.java
index e8ff7fd0f..8ab98272e 100644
--- a/src/main/java/run/halo/app/config/WebSecurityConfig.java
+++ b/src/main/java/run/halo/app/config/WebSecurityConfig.java
@@ -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 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);
}
diff --git a/src/main/java/run/halo/app/identity/apitoken/DefaultPersonalAccessTokenDecoder.java b/src/main/java/run/halo/app/identity/apitoken/DefaultPersonalAccessTokenDecoder.java
new file mode 100644
index 000000000..1550283e7
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/DefaultPersonalAccessTokenDecoder.java
@@ -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 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 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 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 errors = result.getErrors();
+ String validationErrorString = getValidationExceptionMessage(errors);
+ throw new JwtValidationException(validationErrorString, errors);
+ }
+ return token;
+ }
+
+ private String getValidationExceptionMessage(Collection 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 createDefault() {
+ return new DelegatingOAuth2TokenValidator<>(new PersonalAccessTokenTimestampValidator());
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessToken.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessToken.java
new file mode 100644
index 000000000..3020f8d75
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessToken.java
@@ -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;
+
+/**
+ * An implementation of an {@link AbstractOAuth2Token} representing a personal access token.
+ * A personal-access-token is a credential that represents an authorization granted by the
+ * resource owner.
+ * It is primarily used by the client to access protected resources on either a resource
+ * server.
+ * All personal access tokens created by administrators of the {@code Halo} application are
+ * permanent tokens that cannot be regenerated.
+ *
+ * @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 roles) {
+ super(TokenType.BEARER, tokenValue, issuedAt, expiresAt, roles);
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenAuthenticationToken.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenAuthenticationToken.java
new file mode 100644
index 000000000..70355af55
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenAuthenticationToken.java
@@ -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 {
+
+ 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 getTokenAttributes() {
+ return Map.of();
+ }
+
+ /**
+ * The principal name which is, by default, the {@link PersonalAccessToken}'s tokenValue.
+ */
+ @Override
+ public String getName() {
+ return this.name;
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenDecoder.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenDecoder.java
new file mode 100644
index 000000000..b2ad3e5be
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenDecoder.java
@@ -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;
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenException.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenException.java
new file mode 100644
index 000000000..4ad2e6eb9
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenException.java
@@ -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);
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenProvider.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenProvider.java
new file mode 100644
index 000000000..96638991f
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenProvider.java
@@ -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;
+
+/**
+ * An AuthenticationProvider implementation of the personal-access-token based
+ * Bearer Token
+ * s for protecting server resources.
+ * This {@link AuthenticationProvider} is responsible for decoding and verifying
+ * {@link PersonalAccessTokenUtils#generate(PersonalAccessTokenType, SecretKey)}-generated access
+ * token.
+ *
+ * The composition format of personal-access-token is:
+ *
{two letter type prefix}_{32-bit secure random value}{checksum}
+ * 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.
+ * 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.
+ *
+ * @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 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);
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenTimestampValidator.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenTimestampValidator.java
new file mode 100644
index 000000000..fb7e6fa1b
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenTimestampValidator.java
@@ -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;
+
+/**
+ * An implementation of {@link OAuth2TokenValidator} for verifying personal access token.
+ * 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.
+ *
+ * @author guqing
+ * @see OAuth2TokenValidator
+ * @see PersonalAccessToken
+ * @since 2.0.0
+ */
+@Slf4j
+public class PersonalAccessTokenTimestampValidator implements
+ OAuth2TokenValidator {
+
+ 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;
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenType.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenType.java
new file mode 100644
index 000000000..4dfc8fdf7
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenType.java
@@ -0,0 +1,24 @@
+package run.halo.app.identity.apitoken;
+
+import org.springframework.util.Assert;
+
+/**
+ * Personal access token type.
+ *
+ * @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");
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenUtils.java b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenUtils.java
new file mode 100644
index 000000000..d9df2a2f5
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalAccessTokenUtils.java
@@ -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
+ * githubs-new-authentication-token-formats
+ * @since 2.0.0
+ */
+public class PersonalAccessTokenUtils {
+
+ /**
+ * Generate personal access token through secretKey.
+ * The format is tokenValue + 8-bit checksum.
+ *
+ * @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);
+ }
+
+ /**
+ * Decoded the personalAccessToken through base62, the intercepted 8-bit checksum is
+ * compared with the result generated by the checksum rule.
+ * If it matches, it returns {@code true}, otherwise it returns {@code false}.
+ *
+ * @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);
+ }
+
+ /**
+ * Generate 256 bit {@link SecretKey} through AES algorithm.
+ *
+ * @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);
+ }
+ }
+
+ /**
+ * Convert the encoded value of 256 bit string generated by AES algorithm to
+ * {@link SecretKey}.
+ *
+ * @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());
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/apitoken/PersonalTokenTypeUtils.java b/src/main/java/run/halo/app/identity/apitoken/PersonalTokenTypeUtils.java
new file mode 100644
index 000000000..f6fb4122b
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/apitoken/PersonalTokenTypeUtils.java
@@ -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);
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/JwtProvidedDecoderAuthenticationManagerResolver.java b/src/main/java/run/halo/app/identity/authentication/verifier/JwtProvidedDecoderAuthenticationManagerResolver.java
deleted file mode 100644
index 82abdddc0..000000000
--- a/src/main/java/run/halo/app/identity/authentication/verifier/JwtProvidedDecoderAuthenticationManagerResolver.java
+++ /dev/null
@@ -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 {
- public JwtProvidedDecoderAuthenticationManagerResolver {
- Assert.notNull(jwtDecoder, "jwtDecoder cannot be null");
- }
-
- @Override
- public AuthenticationManager resolve(HttpServletRequest request) {
- return new JwtAuthenticationProvider(jwtDecoder)::authenticate;
- }
-}
diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/TokenAuthenticationManagerResolver.java b/src/main/java/run/halo/app/identity/authentication/verifier/TokenAuthenticationManagerResolver.java
new file mode 100644
index 000000000..db64160b1
--- /dev/null
+++ b/src/main/java/run/halo/app/identity/authentication/verifier/TokenAuthenticationManagerResolver.java
@@ -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 {
+
+ 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;
+ }
+ }
+}
diff --git a/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java b/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java
index c1ad05020..ca6d09650 100644
--- a/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java
+++ b/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java
@@ -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);
}
diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java
index a49822493..4e0553313 100644
--- a/src/main/java/run/halo/app/infra/SchemeInitializer.java
+++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java
@@ -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 {
+
+ 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 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 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);
}
}
diff --git a/src/main/java/run/halo/app/infra/utils/Base62Utils.java b/src/main/java/run/halo/app/infra/utils/Base62Utils.java
new file mode 100644
index 000000000..9eb130916
--- /dev/null
+++ b/src/main/java/run/halo/app/infra/utils/Base62Utils.java
@@ -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;
+
+/**
+ * Base62 tool class, which provides the encoding and decoding scheme of base62.
+ *
+ * @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);
+ }
+}
diff --git a/src/main/java/run/halo/app/infra/utils/HaloUtils.java b/src/main/java/run/halo/app/infra/utils/HaloUtils.java
index fb862cf4b..a8d9be4a1 100644
--- a/src/main/java/run/halo/app/infra/utils/HaloUtils.java
+++ b/src/main/java/run/halo/app/infra/utils/HaloUtils.java
@@ -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("-", "");
}
+
+ /**
+ * Read the file under the classpath as a string.
+ *
+ * @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);
+ }
+ }
}
diff --git a/src/main/resources/apiToken.salt b/src/main/resources/apiToken.salt
new file mode 100644
index 000000000..206ee9628
--- /dev/null
+++ b/src/main/resources/apiToken.salt
@@ -0,0 +1 @@
+bySwF9ZxJ2JpQYs830+eA3Fw6O7F/ULDvyYGEPaZKwY=
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java b/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java
new file mode 100644
index 000000000..77619a9f6
--- /dev/null
+++ b/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java
@@ -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 getNaiveTestSet() {
+ Map 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;
+ }
+}
diff --git a/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java b/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java
index cea9ab8f1..b48d40c97 100644
--- a/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java
+++ b/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java
@@ -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 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();
diff --git a/src/test/resources/apiToken.salt b/src/test/resources/apiToken.salt
new file mode 100644
index 000000000..206ee9628
--- /dev/null
+++ b/src/test/resources/apiToken.salt
@@ -0,0 +1 @@
+bySwF9ZxJ2JpQYs830+eA3Fw6O7F/ULDvyYGEPaZKwY=
\ No newline at end of file