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 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 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