From 1f9610686b2ee45ecdfe3a23f60e4b643fbd1401 Mon Sep 17 00:00:00 2001 From: JohnNiang Date: Sat, 28 Sep 2024 17:37:36 +0800 Subject: [PATCH 1/2] Add backend support for customizing login and logout pages Signed-off-by: JohnNiang --- .../halo/app/core/extension/AuthProvider.java | 15 + .../run/halo/app/extension/Unstructured.java | 8 +- .../halo/app/infra/ExternalLinkProcessor.java | 18 ++ .../run/halo/app/infra/SystemSetting.java | 4 +- application/build.gradle | 6 +- .../endpoint/theme/PublicUserEndpoint.java | 285 ------------------ .../service/EmailPasswordRecoveryService.java | 8 +- .../service/InMemoryResetTokenRepository.java | 54 ++++ .../service/InvalidResetTokenException.java | 17 ++ .../app/core/user/service/ResetToken.java | 15 + .../user/service/ResetTokenRepository.java | 38 +++ .../app/core/user/service/SignUpData.java | 57 ++++ .../app/core/user/service/UserService.java | 2 +- .../core/user/service/UserServiceImpl.java | 77 +++-- .../EmailPasswordRecoveryServiceImpl.java | 181 +++++------ .../impl/EmailVerificationServiceImpl.java | 15 +- .../app/extension/JSONExtensionConverter.java | 10 +- .../infra/DefaultExternalLinkProcessor.java | 34 +++ .../halo/app/infra/actuator/GlobalInfo.java | 62 ++++ .../infra/actuator/GlobalInfoEndpoint.java | 163 +--------- .../app/infra/actuator/GlobalInfoService.java | 20 ++ .../infra/actuator/GlobalInfoServiceImpl.java | 147 +++++++++ .../infra/config/WebServerSecurityConfig.java | 40 ++- .../RequestBodyValidationException.java | 2 + .../app/security/AuthProviderService.java | 4 + .../app/security/AuthProviderServiceImpl.java | 36 ++- ...DefaultServerAuthenticationEntryPoint.java | 39 ++- .../security/LoginHandlerEnhancerImpl.java | 8 +- .../security/LogoutSecurityConfigurer.java | 26 +- .../exception/TooManyRequestsException.java | 21 ++ .../exception/TwoFactorAuthException.java | 15 + .../login/LoginAuthenticationConverter.java | 9 +- .../login/LoginSecurityConfigurer.java | 21 +- ...sswordDelegatingAuthenticationManager.java | 12 +- .../login/UsernamePasswordHandler.java | 40 ++- .../rememberme/RememberMeRequestCache.java | 39 +++ .../TokenBasedRememberMeServices.java | 34 +-- .../WebSessionRememberMeRequestCache.java | 71 +++++ .../DefaultTwoFactorAuthResponseHandler.java | 56 ---- .../TotpAuthenticationSuccessHandler.java | 29 ++ .../TwoFactorAuthResponseHandler.java | 10 - .../TwoFactorAuthSecurityConfigurer.java | 39 ++- .../twofactor/TwoFactorAuthentication.java | 4 +- .../TwoFactorAuthorizationManager.java | 22 +- .../totp/TotpAuthenticationFilter.java | 137 --------- .../totp/TotpAuthenticationManager.java | 69 +++++ .../totp/TotpCodeAuthenticationConverter.java | 52 ++++ .../RequestInfoAuthorizationManager.java | 29 +- .../preauth/PreAuthLoginEndpoint.java | 94 ++++++ .../preauth/PreAuthPasswordResetEndpoint.java | 186 ++++++++++++ .../preauth/PreAuthSignUpEndpoint.java | 157 ++++++++++ .../preauth/PreAuthTwoFactorEndpoint.java | 27 ++ .../app/theme/finders/vo/SiteSettingVo.java | 2 +- .../message/ThemeMessageResolutionUtils.java | 116 ------- .../theme/message/ThemeMessageResolver.java | 9 - .../src/main/resources/application.yaml | 8 +- .../resources/extensions/authproviders.yaml | 6 +- .../theme/PublicUserEndpointTest.java | 92 ------ .../user/service/UserServiceImplTest.java | 45 ++- .../EmailPasswordRecoveryServiceImplTest.java | 69 ----- .../DefaultExternalLinkProcessorTest.java | 76 ++++- .../security/AuthProviderServiceImplTest.java | 8 +- ...ultServerAuthenticationEntryPointTest.java | 17 +- .../security/SuperAdminInitializerTest.java | 62 ---- .../LoginAuthenticationConverterTest.java | 4 +- .../authorization/AuthorizationTest.java | 28 +- .../ThemeMessageResolutionUtilsTest.java | 8 - 67 files changed, 1782 insertions(+), 1332 deletions(-) delete mode 100644 application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java create mode 100644 application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java create mode 100644 application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java create mode 100644 application/src/main/java/run/halo/app/core/user/service/ResetToken.java create mode 100644 application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java create mode 100644 application/src/main/java/run/halo/app/core/user/service/SignUpData.java create mode 100644 application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java create mode 100644 application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java create mode 100644 application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java delete mode 100644 application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java delete mode 100644 application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java delete mode 100644 application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java delete mode 100644 application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java delete mode 100644 application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java diff --git a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java index 5f496c687f..256072b78a 100644 --- a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java +++ b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java @@ -48,6 +48,15 @@ public static class AuthProviderSpec { @Schema(requiredMode = REQUIRED, description = "Authentication url of the auth provider") private String authenticationUrl; + private String method = "GET"; + + private boolean rememberMeSupport = false; + + /** + * Auth type: form or oauth2. + */ + private AuthType authType; + private String bindingUrl; private String unbindUrl; @@ -77,4 +86,10 @@ public static class ConfigMapRef { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; } + + public enum AuthType { + FORM, + OAUTH2, + ; + } } diff --git a/api/src/main/java/run/halo/app/extension/Unstructured.java b/api/src/main/java/run/halo/app/extension/Unstructured.java index 2ea80b5c5a..6335fd198c 100644 --- a/api/src/main/java/run/halo/app/extension/Unstructured.java +++ b/api/src/main/java/run/halo/app/extension/Unstructured.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -35,7 +36,12 @@ @SuppressWarnings("rawtypes") public class Unstructured implements Extension { - public static final ObjectMapper OBJECT_MAPPER = Json.mapper(); + @SuppressWarnings("deprecation") + public static final ObjectMapper OBJECT_MAPPER = Json.mapper() + // We don't want to change the default mapper + // so we copy a new one and configure it + .copy() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); private final Map data; diff --git a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java index ea653c709b..99dae5bcbd 100644 --- a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java +++ b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java @@ -1,5 +1,9 @@ package run.halo.app.infra; +import java.net.URI; +import org.springframework.http.HttpRequest; +import reactor.core.publisher.Mono; + /** * {@link ExternalLinkProcessor} to process an in-site link to an external link. * @@ -17,4 +21,18 @@ public interface ExternalLinkProcessor { * @return processed link or original link */ String processLink(String link); + + /** + * Process the URI to an external URL. + *

+ * If the URI is an in-site link, then process it to an external link with + * {@link ExternalUrlSupplier#getRaw()} or {@link ExternalUrlSupplier#getURL(HttpRequest)}, + * otherwise return the original URI. + *

+ * + * @param uri uri to process + * @return processed URI or original URI + */ + Mono processLink(URI uri); + } diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java index c548486249..9beabb6c4f 100644 --- a/api/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java @@ -67,8 +67,8 @@ public static class Basic { @Data public static class User { public static final String GROUP = "user"; - Boolean allowRegistration; - Boolean mustVerifyEmailOnRegistration; + boolean allowRegistration; + boolean mustVerifyEmailOnRegistration; String defaultRole; String avatarPolicy; } diff --git a/application/build.gradle b/application/build.gradle index f0cc3488bf..cffbdfd6a9 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -80,6 +80,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' + + // webjars + runtimeOnly 'org.webjars.npm:jsencrypt:3.3.2' + runtimeOnly 'org.webjars.npm:normalize.css:8.0.1' } tasks.register('createChecksums', Checksum) { @@ -164,4 +168,4 @@ tasks.named('generateOpenApiDocs') { outputs.upToDateWhen { false } -} +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java deleted file mode 100644 index 862aad3343..0000000000 --- a/application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java +++ /dev/null @@ -1,285 +0,0 @@ -package run.halo.app.core.endpoint.theme; - -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; -import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; -import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; - -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import io.github.resilience4j.ratelimiter.RequestNotPermitted; -import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; -import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextImpl; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.ServerWebInputException; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.User; -import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.core.user.service.EmailPasswordRecoveryService; -import run.halo.app.core.user.service.EmailVerificationService; -import run.halo.app.core.user.service.UserService; -import run.halo.app.extension.GroupVersion; -import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; -import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.ValidationUtils; -import run.halo.app.infra.exception.AccessDeniedException; -import run.halo.app.infra.exception.EmailVerificationFailed; -import run.halo.app.infra.exception.RateLimitExceededException; -import run.halo.app.infra.utils.IpAddressUtils; - -/** - * User endpoint for unauthenticated user. - * - * @author guqing - * @since 2.4.0 - */ -@Component -@RequiredArgsConstructor -public class PublicUserEndpoint implements CustomEndpoint { - private final UserService userService; - private final ServerSecurityContextRepository securityContextRepository; - private final ReactiveUserDetailsService reactiveUserDetailsService; - private final EmailPasswordRecoveryService emailPasswordRecoveryService; - private final RateLimiterRegistry rateLimiterRegistry; - private final SystemConfigurableEnvironmentFetcher environmentFetcher; - private final EmailVerificationService emailVerificationService; - - @Override - public RouterFunction endpoint() { - var tag = "UserV1alpha1Public"; - return SpringdocRouteBuilder.route() - .POST("/users/-/signup", this::signUp, - builder -> builder.operationId("SignUp") - .description("Sign up a new user") - .tag(tag) - .requestBody(requestBodyBuilder().required(true) - .implementation(SignUpRequest.class) - ) - .response(responseBuilder().implementation(User.class)) - ) - .POST("/users/-/send-register-verify-email", this::sendRegisterVerifyEmail, - builder -> builder.operationId("SendRegisterVerifyEmail") - .description( - "Send registration verification email, which can be called when " - + "mustVerifyEmailOnRegistration in user settings is true" - ) - .tag(tag) - .requestBody(requestBodyBuilder() - .required(true) - .implementation(RegisterVerifyEmailRequest.class) - ) - .response(responseBuilder() - .responseCode(HttpStatus.NO_CONTENT.toString()) - .implementation(Void.class) - ) - ) - .POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail, - builder -> builder.operationId("SendPasswordResetEmail") - .description("Send password reset email when forgot password") - .tag(tag) - .requestBody(requestBodyBuilder() - .required(true) - .implementation(PasswordResetEmailRequest.class) - ) - .response(responseBuilder() - .responseCode(HttpStatus.NO_CONTENT.toString()) - .implementation(Void.class)) - ) - .PUT("/users/{name}/reset-password", this::resetPasswordByToken, - builder -> builder.operationId("ResetPasswordByToken") - .description("Reset password by token") - .tag(tag) - .parameter(parameterBuilder() - .name("name") - .description("The name of the user") - .required(true) - .in(ParameterIn.PATH) - ) - .requestBody(requestBodyBuilder() - .required(true) - .implementation(ResetPasswordRequest.class) - ) - .response(responseBuilder() - .responseCode(HttpStatus.NO_CONTENT.toString()) - .implementation(Void.class) - ) - ) - .build(); - } - - private Mono resetPasswordByToken(ServerRequest request) { - var username = request.pathVariable("name"); - return request.bodyToMono(ResetPasswordRequest.class) - .doOnNext(resetReq -> { - if (StringUtils.isBlank(resetReq.token())) { - throw new ServerWebInputException("Token must not be blank"); - } - if (StringUtils.isBlank(resetReq.newPassword())) { - throw new ServerWebInputException("New password must not be blank"); - } - }) - .switchIfEmpty( - Mono.error(() -> new ServerWebInputException("Request body must not be empty")) - ) - .flatMap(resetReq -> { - var token = resetReq.token(); - var newPassword = resetReq.newPassword(); - return emailPasswordRecoveryService.changePassword(username, newPassword, token); - }) - .then(ServerResponse.noContent().build()); - } - - record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username, - @Schema(requiredMode = REQUIRED) String email) { - } - - record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword, - @Schema(requiredMode = REQUIRED) String token) { - } - - record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) { - } - - private Mono sendPasswordResetEmail(ServerRequest request) { - return request.bodyToMono(PasswordResetEmailRequest.class) - .flatMap(passwordResetRequest -> { - var username = passwordResetRequest.username(); - var email = passwordResetRequest.email(); - return Mono.just(passwordResetRequest) - .transformDeferred(sendResetPasswordEmailRateLimiter(username, email)) - .flatMap( - r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email)) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); - }) - .then(ServerResponse.noContent().build()); - } - - RateLimiterOperator sendResetPasswordEmailRateLimiter(String username, String email) { - String rateLimiterKey = "send-reset-password-email-" + username + ":" + email; - var rateLimiter = - rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email"); - return RateLimiterOperator.of(rateLimiter); - } - - @Override - public GroupVersion groupVersion() { - return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); - } - - private Mono signUp(ServerRequest request) { - return request.bodyToMono(SignUpRequest.class) - .doOnNext(signUpRequest -> signUpRequest.user().getSpec().setEmailVerified(false)) - .flatMap(signUpRequest -> environmentFetcher.fetch(SystemSetting.User.GROUP, - SystemSetting.User.class) - .map(user -> BooleanUtils.isTrue(user.getMustVerifyEmailOnRegistration())) - .defaultIfEmpty(false) - .flatMap(mustVerifyEmailOnRegistration -> { - if (!mustVerifyEmailOnRegistration) { - return Mono.just(signUpRequest); - } - if (!StringUtils.isNumeric(signUpRequest.verifyCode)) { - return Mono.error(new EmailVerificationFailed()); - } - return emailVerificationService.verifyRegisterVerificationCode( - signUpRequest.user().getSpec().getEmail(), - signUpRequest.verifyCode) - .flatMap(verified -> { - if (BooleanUtils.isNotTrue(verified)) { - return Mono.error(new EmailVerificationFailed()); - } - signUpRequest.user().getSpec().setEmailVerified(true); - return Mono.just(signUpRequest); - }); - }) - ) - .flatMap(signUpRequest -> - userService.signUp(signUpRequest.user(), signUpRequest.password()) - ) - .flatMap(user -> authenticate(user.getMetadata().getName(), request.exchange()) - .thenReturn(user) - ) - .flatMap(user -> ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(user) - ) - .transformDeferred(getRateLimiterForSignUp(request.exchange())) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); - } - - private Mono sendRegisterVerifyEmail(ServerRequest request) { - return request.bodyToMono(RegisterVerifyEmailRequest.class) - .switchIfEmpty(Mono.error( - () -> new ServerWebInputException("Required request body is missing.")) - ) - .map(emailReq -> { - var email = emailReq.email(); - if (!ValidationUtils.isValidEmail(email)) { - throw new ServerWebInputException("Invalid email address."); - } - return email; - }) - .flatMap(email -> environmentFetcher.fetch(SystemSetting.User.GROUP, - SystemSetting.User.class) - .map(config -> BooleanUtils.isTrue(config.getMustVerifyEmailOnRegistration())) - .defaultIfEmpty(false) - .doOnNext(mustVerifyEmailOnRegistration -> { - if (!mustVerifyEmailOnRegistration) { - throw new AccessDeniedException("Email verification is not required."); - } - }) - .transformDeferred(sendRegisterEmailVerificationCodeRateLimiter(email)) - .flatMap(s -> emailVerificationService.sendRegisterVerificationCode(email) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new) - ) - .then(ServerResponse.ok().build()); - } - - private RateLimiterOperator getRateLimiterForSignUp(ServerWebExchange exchange) { - var clientIp = IpAddressUtils.getClientIp(exchange.getRequest()); - var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp, - "signup"); - return RateLimiterOperator.of(rateLimiter); - } - - private Mono authenticate(String username, ServerWebExchange exchange) { - return reactiveUserDetailsService.findByUsername(username) - .flatMap(userDetails -> { - SecurityContextImpl securityContext = new SecurityContextImpl(); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails.getUsername(), - userDetails.getPassword(), userDetails.getAuthorities()); - securityContext.setAuthentication(authentication); - return securityContextRepository.save(exchange, securityContext); - }); - } - - private RateLimiterOperator sendRegisterEmailVerificationCodeRateLimiter(String email) { - String rateLimiterKey = "send-register-verify-email:" + email; - var rateLimiter = - rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); - return RateLimiterOperator.of(rateLimiter); - } - - record SignUpRequest(@Schema(requiredMode = REQUIRED) User user, - @Schema(requiredMode = REQUIRED, minLength = 6) String password, - @Schema(requiredMode = NOT_REQUIRED, minLength = 6, maxLength = 6) - String verifyCode - ) { - } -} diff --git a/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java b/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java index a586589f05..2770cec886 100644 --- a/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java +++ b/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java @@ -22,17 +22,21 @@ public interface EmailPasswordRecoveryService { */ Mono sendPasswordResetEmail(String username, String email); + Mono sendPasswordResetEmail(String email); + /** *

Reset password by token.

* if the token is invalid, it will return {@link Mono#error(Throwable)}} * if the token is valid, but the username is not the same, it will return * {@link Mono#error(Throwable)} * - * @param username username to reset password * @param newPassword new password * @param token token to validate the user * @return {@link Mono#empty()} if the token is invalid or the username is not the same. * @throws AccessDeniedException if the token is invalid */ - Mono changePassword(String username, String newPassword, String token); + Mono changePassword(String newPassword, String token); + + Mono getValidResetToken(String token); + } diff --git a/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java b/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java new file mode 100644 index 0000000000..1c8b3a2ac9 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java @@ -0,0 +1,54 @@ +package run.halo.app.core.user.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.util.Objects; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +/** + * In-memory reset token repository. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +public class InMemoryResetTokenRepository implements ResetTokenRepository { + + /** + * Key: Token Hash. + */ + private final Cache tokens; + + public InMemoryResetTokenRepository() { + this.tokens = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofDays(1)) + .maximumSize(10000) + .build(); + } + + @Override + public Mono save(ResetToken resetToken) { + return Mono.defer(() -> { + var savedResetToken = tokens.get(resetToken.tokenHash(), k -> resetToken); + if (Objects.equals(savedResetToken, resetToken)) { + return Mono.empty(); + } + // should never happen + return Mono.error(new DuplicateKeyException("Reset token already exists")); + }); + } + + @Override + public Mono findByTokenHash(String tokenHash) { + return Mono.fromSupplier(() -> tokens.getIfPresent(tokenHash)); + } + + @Override + public Mono removeByTokenHash(String tokenHash) { + return Mono.fromRunnable(() -> tokens.invalidate(tokenHash)); + } + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java b/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java new file mode 100644 index 0000000000..597c90af66 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java @@ -0,0 +1,17 @@ +package run.halo.app.core.user.service; + +import org.springframework.web.server.ServerWebInputException; + +/** + * Invalid reset token exception. + * + * @author johnniang + * @since 2.20.0 + */ +public class InvalidResetTokenException extends ServerWebInputException { + + public InvalidResetTokenException() { + super("Invalid reset token"); + } + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/ResetToken.java b/application/src/main/java/run/halo/app/core/user/service/ResetToken.java new file mode 100644 index 0000000000..31a4161059 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/ResetToken.java @@ -0,0 +1,15 @@ +package run.halo.app.core.user.service; + +import java.time.Instant; + +/** + * Reset token data. + * + * @param tokenHash The token hash + * @param username The username + * @param expiresAt The expires at + * @author johnniang + * @since 2.20.0 + */ +public record ResetToken(String tokenHash, String username, Instant expiresAt) { +} diff --git a/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java b/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java new file mode 100644 index 0000000000..0dcc67b3e6 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java @@ -0,0 +1,38 @@ +package run.halo.app.core.user.service; + +import reactor.core.publisher.Mono; + +/** + * Reset token repository. + * + * @author johnniang + * @since 2.20.0 + */ +public interface ResetTokenRepository { + + /** + * Save reset token. + * + * @param resetToken reset token + * @return empty mono if saved successfully. + * @throws org.springframework.dao.DuplicateKeyException if token already exists. + */ + Mono save(ResetToken resetToken); + + /** + * Find reset token by token hash. + * + * @param tokenHash token hash + * @return reset token if found, or empty mono. + */ + Mono findByTokenHash(String tokenHash); + + /** + * Remove reset token by token hash. + * + * @param tokenHash token hash + * @return empty mono if removed successfully. + */ + Mono removeByTokenHash(String tokenHash); + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/SignUpData.java b/application/src/main/java/run/halo/app/core/user/service/SignUpData.java new file mode 100644 index 0000000000..d609fa0cc8 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/SignUpData.java @@ -0,0 +1,57 @@ +package run.halo.app.core.user.service; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import java.util.Optional; +import lombok.Data; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Sign up data. + * + * @author johnniang + * @since 2.20.0 + */ +@Data +public class SignUpData { + + @NotBlank + private String username; + + @NotBlank + private String displayName; + + @Email + private String email; + + private String emailCode; + + @NotBlank + private String password; + + public static SignUpData of(MultiValueMap formData) { + var form = new SignUpData(); + Optional.ofNullable(formData.getFirst("username")) + .filter(StringUtils::hasText) + .ifPresent(form::setUsername); + + Optional.ofNullable(formData.getFirst("displayName")) + .filter(StringUtils::hasText) + .ifPresent(form::setDisplayName); + + Optional.ofNullable(formData.getFirst("email")) + .filter(StringUtils::hasText) + .ifPresent(form::setEmail); + + Optional.ofNullable(formData.getFirst("password")) + .filter(StringUtils::hasText) + .ifPresent(form::setPassword); + + Optional.ofNullable(formData.getFirst("emailCode")) + .filter(StringUtils::hasText) + .ifPresent(form::setEmailCode); + + return form; + } +} diff --git a/application/src/main/java/run/halo/app/core/user/service/UserService.java b/application/src/main/java/run/halo/app/core/user/service/UserService.java index 5a2b8135a7..58039c2f21 100644 --- a/application/src/main/java/run/halo/app/core/user/service/UserService.java +++ b/application/src/main/java/run/halo/app/core/user/service/UserService.java @@ -17,7 +17,7 @@ public interface UserService { Mono grantRoles(String username, Set roles); - Mono signUp(User user, String password); + Mono signUp(SignUpData signUpData); Mono createUser(User user, Set roles); diff --git a/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java index 9290b3ec48..982765cfb4 100644 --- a/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java @@ -1,7 +1,6 @@ package run.halo.app.core.user.service; -import static org.springframework.data.domain.Sort.Order.asc; -import static org.springframework.data.domain.Sort.Order.desc; +import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.index.query.QueryFactory.equal; import java.time.Clock; @@ -12,10 +11,8 @@ import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.BooleanUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.util.Assert; @@ -30,18 +27,20 @@ import run.halo.app.core.extension.User; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.ListOptions; +import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.infra.exception.UserNotFoundException; @Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { + public static final String GHOST_USER_NAME = "ghost"; private final ReactiveExtensionClient client; @@ -54,6 +53,8 @@ public class UserServiceImpl implements UserService { private final RoleService roleService; + private final EmailVerificationService emailVerificationService; + private Clock clock = Clock.systemUTC(); void setClock(Clock clock) { @@ -147,29 +148,49 @@ public Mono grantRoles(String username, Set roles) { } @Override - public Mono signUp(User user, String password) { - if (!StringUtils.hasText(password)) { - throw new IllegalArgumentException("Password must not be blank"); - } + public Mono signUp(SignUpData signUpData) { return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) - .switchIfEmpty(Mono.error(new IllegalStateException("User setting is not configured"))) - .flatMap(userSetting -> { - Boolean allowRegistration = userSetting.getAllowRegistration(); - if (BooleanUtils.isFalse(allowRegistration)) { - return Mono.error(new AccessDeniedException("Registration is not allowed", - "problemDetail.user.signUpFailed.disallowed", - null)); - } - String defaultRole = userSetting.getDefaultRole(); - if (!StringUtils.hasText(defaultRole)) { - return Mono.error(new AccessDeniedException( - "Default registration role is not configured by admin", - "problemDetail.user.signUpFailed.disallowed", - null)); + .filter(SystemSetting.User::isAllowRegistration) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The registration is not allowed by the administrator." + ))) + .filter(setting -> StringUtils.hasText(setting.getDefaultRole())) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The default role is not configured by the administrator." + ))) + .flatMap(setting -> { + var user = new User(); + user.setMetadata(new Metadata()); + var metadata = user.getMetadata(); + metadata.setName(signUpData.getUsername()); + user.setSpec(new User.UserSpec()); + var spec = user.getSpec(); + spec.setPassword(passwordEncoder.encode(signUpData.getPassword())); + spec.setEmailVerified(false); + spec.setRegisteredAt(clock.instant()); + spec.setEmail(signUpData.getEmail()); + spec.setDisplayName(signUpData.getDisplayName()); + Mono verifyEmail = Mono.empty(); + if (setting.isMustVerifyEmailOnRegistration()) { + if (!StringUtils.hasText(signUpData.getEmailCode())) { + return Mono.error( + new EmailVerificationFailed("Email captcha is required", null) + ); + } + verifyEmail = emailVerificationService.verifyRegisterVerificationCode( + signUpData.getEmail(), signUpData.getEmailCode() + ) + .filter(Boolean::booleanValue) + .switchIfEmpty(Mono.error(() -> + new EmailVerificationFailed("Invalid email captcha.", null) + )) + .doOnNext(spec::setEmailVerified) + .then(); } - String encodedPassword = passwordEncoder.encode(password); - user.getSpec().setPassword(encodedPassword); - return createUser(user, Set.of(defaultRole)); + return verifyEmail.then(Mono.defer(() -> { + var defaultRole = setting.getDefaultRole(); + return createUser(user, Set.of(defaultRole)); + })); }); } @@ -225,9 +246,7 @@ public Mono confirmPassword(String username, String rawPassword) { public Flux listByEmail(String email) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(equal("spec.email", email))); - return client.listAll(User.class, listOptions, Sort.by(desc("metadata.creationTimestamp"), - asc("metadata.name")) - ); + return client.listAll(User.class, listOptions, defaultSort()); } @Override diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java index ac66225ee6..290acb0850 100644 --- a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java @@ -1,30 +1,28 @@ package run.halo.app.core.user.service.impl; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; +import java.time.Clock; import java.time.Duration; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.Data; import lombok.RequiredArgsConstructor; -import lombok.experimental.Accessors; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.security.core.token.Sha512DigestUtils; import org.springframework.stereotype.Component; import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.user.service.EmailPasswordRecoveryService; +import run.halo.app.core.user.service.InvalidResetTokenException; +import run.halo.app.core.user.service.ResetToken; +import run.halo.app.core.user.service.ResetTokenRepository; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalLinkProcessor; -import run.halo.app.infra.exception.AccessDeniedException; -import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.notification.UserIdentity; @@ -38,17 +36,21 @@ @Component @RequiredArgsConstructor public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService { + public static final int MAX_ATTEMPTS = 5; public static final long LINK_EXPIRATION_MINUTES = 30; + private static final Duration RESET_TOKEN_LIFE_TIME = + Duration.ofMinutes(LINK_EXPIRATION_MINUTES); static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email"; - private final ResetPasswordVerificationManager resetPasswordVerificationManager = - new ResetPasswordVerificationManager(); private final ExternalLinkProcessor externalLinkProcessor; private final ReactiveExtensionClient client; private final NotificationReasonEmitter reasonEmitter; private final NotificationCenter notificationCenter; private final UserService userService; + private final ResetTokenRepository resetTokenRepository; + + private Clock clock = Clock.systemDefaultZone(); @Override public Mono sendPasswordResetEmail(String username, String email) { @@ -66,22 +68,35 @@ public Mono sendPasswordResetEmail(String username, String email) { } @Override - public Mono changePassword(String username, String newPassword, String token) { - Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); + public Mono sendPasswordResetEmail(String email) { + if (StringUtils.isBlank(email)) { + return Mono.empty(); + } + return userService.listByEmail(email) + .filter(user -> user.getSpec().isEmailVerified()) + .next() + .flatMap(user -> sendResetPasswordNotification(user.getMetadata().getName(), email)); + } + + @Override + public Mono changePassword(String newPassword, String token) { Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank"); Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank"); - var verified = resetPasswordVerificationManager.verifyToken(username, token); - if (!verified) { - return Mono.error(AccessDeniedException::new); - } - return userService.updateWithRawPassword(username, newPassword) - .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) - .filter(OptimisticLockingFailureException.class::isInstance)) - .flatMap(user -> { - resetPasswordVerificationManager.removeToken(username); - return unSubscribeResetPasswordEmailNotification(user.getSpec().getEmail()); - }) - .then(); + var tokenHash = hashToken(token); + return getValidResetToken(token).flatMap(resetToken -> + userService.updateWithRawPassword(resetToken.username(), newPassword) + .flatMap(user -> unSubscribeResetPasswordEmailNotification( + user.getSpec().getEmail()) + ) + .then(resetTokenRepository.removeByTokenHash(tokenHash)) + ); + } + + @Override + public Mono getValidResetToken(String token) { + return resetTokenRepository.findByTokenHash(hashToken(token)) + .filter(resetToken -> clock.instant().isBefore(resetToken.expiresAt())) + .switchIfEmpty(Mono.error(InvalidResetTokenException::new)); } Mono unSubscribeResetPasswordEmailNotification(String email) { @@ -95,26 +110,33 @@ Mono unSubscribeResetPasswordEmailNotification(String email) { .filter(OptimisticLockingFailureException.class::isInstance)); } - Mono sendResetPasswordNotification(String username, String email) { - var token = resetPasswordVerificationManager.generateToken(username); - var link = getResetPasswordLink(username, token); - - var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email); - var interestReasonSubject = createInterestReason(email).getSubject(); - var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE, - builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES) - .attribute("username", username) - .attribute("link", link) - .author(UserIdentity.of(username)) - .subject(Reason.Subject.builder() - .apiVersion(interestReasonSubject.getApiVersion()) - .kind(interestReasonSubject.getKind()) - .name(interestReasonSubject.getName()) - .title("使用邮箱地址重置密码:" + email) - .build() - ) - ); - return Mono.when(subscribeNotification).then(emitReasonMono); + private Mono sendResetPasswordNotification(String username, String email) { + var token = generateToken(); + var tokenHash = hashToken(token); + var expiresAt = clock.instant().plus(RESET_TOKEN_LIFE_TIME); + var uri = UriComponentsBuilder.fromUriString("/") + .pathSegment("password-reset", token) + .build(true) + .toUri(); + var resetToken = new ResetToken(tokenHash, username, expiresAt); + return resetTokenRepository.save(resetToken) + .then(externalLinkProcessor.processLink(uri).flatMap(link -> { + var interestReasonSubject = createInterestReason(email).getSubject(); + var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE, + builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES) + .attribute("username", username) + .attribute("link", link) + .author(UserIdentity.of(username)) + .subject(Reason.Subject.builder() + .apiVersion(interestReasonSubject.getApiVersion()) + .kind(interestReasonSubject.getKind()) + .name(interestReasonSubject.getName()) + .title("使用邮箱地址重置密码:" + email) + .build() + ) + ); + return autoSubscribeResetPasswordEmailNotification(email).then(emitReasonMono); + })); } Mono autoSubscribeResetPasswordEmailNotification(String email) { @@ -136,73 +158,12 @@ Subscription.InterestReason createInterestReason(String email) { return interestReason; } - private String getResetPasswordLink(String username, String token) { - return externalLinkProcessor.processLink( - "/uc/reset-password/" + username + "?reset_password_token=" + token); + private static String hashToken(String token) { + return Sha512DigestUtils.shaHex(token); } - static class ResetPasswordVerificationManager { - private final Cache userTokenCache = - CacheBuilder.newBuilder() - .expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES) - .maximumSize(10000) - .build(); - - private final Cache - blackListCache = CacheBuilder.newBuilder() - .expireAfterWrite(Duration.ofHours(2)) - .maximumSize(1000) - .build(); - - public boolean verifyToken(String username, String token) { - var verification = userTokenCache.getIfPresent(username); - if (verification == null) { - // expired or not generated - return false; - } - if (blackListCache.getIfPresent(username) != null) { - // in blacklist - throw new RateLimitExceededException(null); - } - synchronized (verification) { - if (verification.getAttempts().get() >= MAX_ATTEMPTS) { - // add to blacklist to prevent brute force attack - blackListCache.put(username, true); - return false; - } - if (!verification.getToken().equals(token)) { - verification.getAttempts().incrementAndGet(); - return false; - } - } - return true; - } - - public void removeToken(String username) { - userTokenCache.invalidate(username); - } - - public String generateToken(String username) { - Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); - var verification = new Verification(); - verification.setToken(RandomStringUtils.randomAlphanumeric(20)); - verification.setAttempts(new AtomicInteger(0)); - userTokenCache.put(username, verification); - return verification.getToken(); - } - - /** - * Only for test. - */ - boolean contains(String username) { - return userTokenCache.getIfPresent(username) != null; - } - - @Data - @Accessors(chain = true) - static class Verification { - private String token; - private AtomicInteger attempts; - } + private static String generateToken() { + return RandomStringUtils.secure().nextAlphanumeric(64); } + } diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java index b362a96c81..01dfeff7cd 100644 --- a/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java @@ -15,15 +15,18 @@ import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.user.service.EmailVerificationService; -import run.halo.app.core.user.service.UserService; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; @@ -47,7 +50,6 @@ public class EmailVerificationServiceImpl implements EmailVerificationService { private final ReactiveExtensionClient client; private final NotificationReasonEmitter reasonEmitter; private final NotificationCenter notificationCenter; - private final UserService userService; @Override public Mono sendVerificationCode(String username, String email) { @@ -121,7 +123,10 @@ private Mono verifyUserEmail(User user, String code) { } Mono isEmailInUse(String username, String emailToVerify) { - return userService.listByEmail(emailToVerify) + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.equal("spec.email", emailToVerify)) + .build(); + return client.listAll(User.class, listOptions, ExtensionUtil.defaultSort()) .filter(user -> user.getSpec().isEmailVerified()) .filter(user -> !user.getMetadata().getName().equals(username)) .hasElements(); @@ -137,7 +142,9 @@ public Mono sendRegisterVerificationCode(String email) { public Mono verifyRegisterVerificationCode(String email, String code) { Assert.state(StringUtils.isNotBlank(email), "Username must not be blank"); Assert.state(StringUtils.isNotBlank(code), "Code must not be blank"); - return Mono.just(emailVerificationManager.verifyCode(email, email, code)); + return Mono.fromSupplier(() -> emailVerificationManager.verifyCode(email, email, code)) + // Why use boundedElastic? Because the verification uses synchronized block. + .subscribeOn(Schedulers.boundedElastic()); } Mono sendVerificationNotification(String username, String email) { diff --git a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java index 8aa2304357..67238b9827 100644 --- a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java +++ b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -3,14 +3,15 @@ import static org.openapi4j.core.validation.ValidationSeverity.ERROR; import static org.springframework.util.StringUtils.arrayToCommaDelimitedString; import static run.halo.app.extension.ExtensionStoreUtil.buildStoreName; +import static run.halo.app.extension.Unstructured.OBJECT_MAPPER; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import io.swagger.v3.core.util.Json; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Optional; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.openapi4j.core.exception.ResolutionException; import org.openapi4j.core.model.v3.OAI3; @@ -36,17 +37,14 @@ @Component public class JSONExtensionConverter implements ExtensionConverter { + @Getter public final ObjectMapper objectMapper; private final SchemeManager schemeManager; public JSONExtensionConverter(SchemeManager schemeManager) { this.schemeManager = schemeManager; - this.objectMapper = Json.mapper(); - } - - public ObjectMapper getObjectMapper() { - return objectMapper; + this.objectMapper = OBJECT_MAPPER; } @Override diff --git a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java index 50f7bc7d0e..c1c0bd963b 100644 --- a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java +++ b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java @@ -1,8 +1,14 @@ package run.halo.app.infra; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; +import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Mono; import run.halo.app.infra.utils.PathUtils; /** @@ -25,6 +31,34 @@ public String processLink(String link) { return append(externalLink.toString(), link); } + @Override + public Mono processLink(URI uri) { + if (uri.isAbsolute()) { + return Mono.just(uri); + } + return Mono.deferContextual(contextView -> Mono.fromSupplier( + () -> ServerWebExchangeContextFilter.getExchange(contextView) + .map(exchange -> externalUrlSupplier.getURL(exchange.getRequest())) + .or(() -> Optional.ofNullable(externalUrlSupplier.getRaw())) + .map(externalUrl -> { + try { + var uriComponents = UriComponentsBuilder.fromUriString(uri.toASCIIString()) + .build(true); + return UriComponentsBuilder.fromUri(externalUrl.toURI()) + .pathSegment(uriComponents.getPathSegments().toArray(new String[0])) + .queryParams(uriComponents.getQueryParams()) + .fragment(uriComponents.getFragment()) + .build(true) + .toUri(); + } catch (URISyntaxException e) { + // should never happen + return uri; + } + }) + .orElse(uri) + )); + } + String append(String externalLink, String link) { return StringUtils.removeEnd(externalLink, "/") + StringUtils.prependIfMissing(link, "/"); diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java new file mode 100644 index 0000000000..30c5c36feb --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java @@ -0,0 +1,62 @@ +package run.halo.app.infra.actuator; + +import java.net.URL; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import lombok.Data; + +/** + * Global info. + * + * @author johnniang + * @since 2.20.0 + */ +@Data +public class GlobalInfo { + + private URL externalUrl; + + private boolean useAbsolutePermalink; + + private TimeZone timeZone; + + private Locale locale; + + private boolean allowComments; + + private boolean allowAnonymousComments; + + private boolean allowRegistration; + + private String favicon; + + private boolean userInitialized; + + private boolean dataInitialized; + + private String postSlugGenerationStrategy; + + private List socialAuthProviders; + + private Boolean mustVerifyEmailOnRegistration; + + private String siteTitle; + + @Data + public static class SocialAuthProvider { + private String name; + + private String displayName; + + private String description; + + private String logo; + + private String website; + + private String authenticationUrl; + + private String bindingUrl; + } +} diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java index 9d53a2d525..dec60c0cc6 100644 --- a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java @@ -1,171 +1,26 @@ package run.halo.app.infra.actuator; -import static org.apache.commons.lang3.BooleanUtils.isTrue; - -import java.net.URL; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.ObjectProvider; +import java.time.Duration; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; import org.springframework.stereotype.Component; -import run.halo.app.extension.ConfigMap; -import run.halo.app.infra.InitializationStateGetter; -import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; -import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.SystemSetting.Basic; -import run.halo.app.infra.SystemSetting.Comment; -import run.halo.app.infra.SystemSetting.User; -import run.halo.app.infra.properties.HaloProperties; -import run.halo.app.security.AuthProviderService; +/** + * Global info endpoint. + */ @WebEndpoint(id = "globalinfo") @Component -@RequiredArgsConstructor public class GlobalInfoEndpoint { - private final ObjectProvider systemConfigFetcher; - - private final HaloProperties haloProperties; - - private final AuthProviderService authProviderService; + private final GlobalInfoService globalInfoService; - private final InitializationStateGetter initializationStateGetter; + public GlobalInfoEndpoint(GlobalInfoService globalInfoService) { + this.globalInfoService = globalInfoService; + } @ReadOperation public GlobalInfo globalInfo() { - final var info = new GlobalInfo(); - info.setExternalUrl(haloProperties.getExternalUrl()); - info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink()); - info.setLocale(Locale.getDefault()); - info.setTimeZone(TimeZone.getDefault()); - info.setUserInitialized(initializationStateGetter.userInitialized() - .blockOptional().orElse(false)); - info.setDataInitialized(initializationStateGetter.dataInitialized() - .blockOptional().orElse(false)); - handleSocialAuthProvider(info); - systemConfigFetcher.ifAvailable(fetcher -> fetcher.getConfigMapBlocking() - .ifPresent(configMap -> { - handleCommentSetting(info, configMap); - handleUserSetting(info, configMap); - handleBasicSetting(info, configMap); - handlePostSlugGenerationStrategy(info, configMap); - })); - return info; - } - - @Data - public static class GlobalInfo { - private URL externalUrl; - - private boolean useAbsolutePermalink; - - private TimeZone timeZone; - - private Locale locale; - - private boolean allowComments; - - private boolean allowAnonymousComments; - - private boolean allowRegistration; - - private String favicon; - - private boolean userInitialized; - - private boolean dataInitialized; - - private String postSlugGenerationStrategy; - - private List socialAuthProviders; - - private Boolean mustVerifyEmailOnRegistration; - - private String siteTitle; - } - - @Data - public static class SocialAuthProvider { - private String name; - - private String displayName; - - private String description; - - private String logo; - - private String website; - - private String authenticationUrl; - - private String bindingUrl; - } - - private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) { - var comment = SystemSetting.get(configMap, Comment.GROUP, Comment.class); - if (comment == null) { - info.setAllowComments(true); - info.setAllowAnonymousComments(true); - } else { - info.setAllowComments(comment.getEnable() != null && comment.getEnable()); - info.setAllowAnonymousComments( - comment.getSystemUserOnly() == null || !comment.getSystemUserOnly()); - } - } - - private void handleUserSetting(GlobalInfo info, ConfigMap configMap) { - var userSetting = SystemSetting.get(configMap, User.GROUP, User.class); - if (userSetting == null) { - info.setAllowRegistration(false); - info.setMustVerifyEmailOnRegistration(false); - } else { - info.setAllowRegistration( - userSetting.getAllowRegistration() != null && userSetting.getAllowRegistration()); - info.setMustVerifyEmailOnRegistration(userSetting.getMustVerifyEmailOnRegistration()); - } - } - - private void handlePostSlugGenerationStrategy(GlobalInfo info, ConfigMap configMap) { - var post = SystemSetting.get(configMap, SystemSetting.Post.GROUP, SystemSetting.Post.class); - if (post != null) { - info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy()); - } - } - - private void handleBasicSetting(GlobalInfo info, ConfigMap configMap) { - var basic = SystemSetting.get(configMap, Basic.GROUP, Basic.class); - if (basic != null) { - info.setFavicon(basic.getFavicon()); - info.setSiteTitle(basic.getTitle()); - } - } - - private void handleSocialAuthProvider(GlobalInfo info) { - List providers = authProviderService.listAll() - .map(listedAuthProviders -> listedAuthProviders.stream() - .filter(provider -> isTrue(provider.getEnabled())) - .filter(provider -> StringUtils.isNotBlank(provider.getBindingUrl())) - .map(provider -> { - SocialAuthProvider socialAuthProvider = new SocialAuthProvider(); - socialAuthProvider.setName(provider.getName()); - socialAuthProvider.setDisplayName(provider.getDisplayName()); - socialAuthProvider.setDescription(provider.getDescription()); - socialAuthProvider.setLogo(provider.getLogo()); - socialAuthProvider.setWebsite(provider.getWebsite()); - socialAuthProvider.setAuthenticationUrl(provider.getAuthenticationUrl()); - socialAuthProvider.setBindingUrl(provider.getBindingUrl()); - return socialAuthProvider; - }) - .toList() - ) - .block(); - - info.setSocialAuthProviders(providers); + return globalInfoService.getGlobalInfo().block(Duration.ofMinutes(1)); } } diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java new file mode 100644 index 0000000000..7f5ef15d1a --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java @@ -0,0 +1,20 @@ +package run.halo.app.infra.actuator; + +import reactor.core.publisher.Mono; + +/** + * Global info service. + * + * @author johnniang + * @since 2.20.0 + */ +public interface GlobalInfoService { + + /** + * Get global info. + * + * @return global info + */ + Mono getGlobalInfo(); + +} diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java new file mode 100644 index 0000000000..f671f0c975 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java @@ -0,0 +1,147 @@ +package run.halo.app.infra.actuator; + +import static org.apache.commons.lang3.BooleanUtils.isTrue; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.Optional; +import java.util.TimeZone; +import org.apache.commons.lang3.StringUtils; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import run.halo.app.extension.ConfigMap; +import run.halo.app.infra.InitializationStateGetter; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.security.AuthProviderService; + +/** + * Global info service implementation. + * + * @author johnniang + * @since 2.20.0 + */ +@Service +public class GlobalInfoServiceImpl implements GlobalInfoService { + + private final HaloProperties haloProperties; + + private final AuthProviderService authProviderService; + + private final InitializationStateGetter initializationStateGetter; + + private final ObjectProvider + systemConfigFetcher; + + public GlobalInfoServiceImpl(HaloProperties haloProperties, + AuthProviderService authProviderService, + InitializationStateGetter initializationStateGetter, + ObjectProvider systemConfigFetcher) { + this.haloProperties = haloProperties; + this.authProviderService = authProviderService; + this.initializationStateGetter = initializationStateGetter; + this.systemConfigFetcher = systemConfigFetcher; + } + + @Override + public Mono getGlobalInfo() { + final var info = new GlobalInfo(); + info.setExternalUrl(haloProperties.getExternalUrl()); + info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink()); + info.setLocale(Locale.getDefault()); + info.setTimeZone(TimeZone.getDefault()); + + var publishers = new ArrayList>(4); + publishers.add( + initializationStateGetter.userInitialized().doOnNext(info::setUserInitialized) + ); + publishers.add( + initializationStateGetter.dataInitialized().doOnNext(info::setDataInitialized) + ); + publishers.add(handleSocialAuthProvider(info)); + publishers.add(handleSettings(info)); + return Mono.when(publishers).then(Mono.just(info)); + } + + private Mono handleSettings(GlobalInfo info) { + return Optional.ofNullable(systemConfigFetcher.getIfUnique()) + .map(fetcher -> fetcher.getConfigMap() + .doOnNext(configMap -> { + handleCommentSetting(info, configMap); + handleUserSetting(info, configMap); + handleBasicSetting(info, configMap); + handlePostSlugGenerationStrategy(info, configMap); + }) + .then() + ) + .orElseGet(Mono::empty); + } + + private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) { + var comment = + SystemSetting.get(configMap, SystemSetting.Comment.GROUP, SystemSetting.Comment.class); + if (comment == null) { + info.setAllowComments(true); + info.setAllowAnonymousComments(true); + } else { + info.setAllowComments(comment.getEnable() != null && comment.getEnable()); + info.setAllowAnonymousComments( + comment.getSystemUserOnly() == null || !comment.getSystemUserOnly()); + } + } + + private void handleUserSetting(GlobalInfo info, ConfigMap configMap) { + var userSetting = + SystemSetting.get(configMap, SystemSetting.User.GROUP, SystemSetting.User.class); + if (userSetting == null) { + info.setAllowRegistration(false); + info.setMustVerifyEmailOnRegistration(false); + } else { + info.setAllowRegistration(userSetting.isAllowRegistration()); + info.setMustVerifyEmailOnRegistration(userSetting.isMustVerifyEmailOnRegistration()); + } + } + + private void handlePostSlugGenerationStrategy(GlobalInfo info, + ConfigMap configMap) { + var post = SystemSetting.get(configMap, SystemSetting.Post.GROUP, SystemSetting.Post.class); + if (post != null) { + info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy()); + } + } + + private void handleBasicSetting(GlobalInfo info, ConfigMap configMap) { + var basic = + SystemSetting.get(configMap, SystemSetting.Basic.GROUP, SystemSetting.Basic.class); + if (basic != null) { + info.setFavicon(basic.getFavicon()); + info.setSiteTitle(basic.getTitle()); + } + } + + private Mono handleSocialAuthProvider(GlobalInfo info) { + return authProviderService.listAll() + .map(listedAuthProviders -> listedAuthProviders.stream() + .filter(provider -> isTrue(provider.getEnabled())) + .filter(provider -> StringUtils.isNotBlank(provider.getBindingUrl())) + .map(provider -> { + GlobalInfo.SocialAuthProvider socialAuthProvider = + new GlobalInfo.SocialAuthProvider(); + socialAuthProvider.setName(provider.getName()); + socialAuthProvider.setDisplayName(provider.getDisplayName()); + socialAuthProvider.setDescription(provider.getDescription()); + socialAuthProvider.setLogo(provider.getLogo()); + socialAuthProvider.setWebsite(provider.getWebsite()); + socialAuthProvider.setAuthenticationUrl(provider.getAuthenticationUrl()); + socialAuthProvider.setBindingUrl(provider.getBindingUrl()); + return socialAuthProvider; + }) + .toList() + ) + .doOnNext(info::setSocialAuthProviders) + .then(); + } +} diff --git a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java index 79f3c014e8..9de837d9c0 100644 --- a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java @@ -11,6 +11,7 @@ import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.crypto.factory.PasswordEncoderFactories; @@ -18,10 +19,10 @@ import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.session.MapSession; import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ReactiveExtensionClient; @@ -31,7 +32,6 @@ import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.impl.RsaKeyService; -import run.halo.app.security.authentication.login.PublicKeyRouteBuilder; import run.halo.app.security.authentication.pat.PatAuthenticationManager; import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher; import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager; @@ -59,20 +59,33 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, CryptoService cryptoService, HaloProperties haloProperties) { - http.securityMatcher(pathMatchers("/**")) + var pathMatcher = pathMatchers("/**"); + var staticResourcesMatcher = pathMatchers(HttpMethod.GET, + "/themes/{themeName}/assets/{*resourcePaths}", + "/plugins/{pluginName}/assets/**", + "/console/**", + "/uc/**", + "/upload/**", + "/webjars/**", + "/js/**", + "/styles/**", + "/halo-tracker.js", + "/images/**" + ); + + var securityMatcher = new AndServerWebExchangeMatcher(pathMatcher, + new NegatedServerWebExchangeMatcher(staticResourcesMatcher)); + + http.securityMatcher(securityMatcher) .authorizeExchange(spec -> spec.pathMatchers( "/api/**", "/apis/**", "/oauth2/**", - "/login/**", - "/logout", "/actuator/**" ) - .access( - new TwoFactorAuthorizationManager( - new RequestInfoAuthorizationManager(roleService) - ) - ) + .access(new RequestInfoAuthorizationManager(roleService)) + .pathMatchers("/challenges/two-factor/**") + .access(new TwoFactorAuthorizationManager()) .anyExchange().permitAll()) .anonymous(spec -> { spec.authorities(AnonymousUserConst.Role); @@ -140,11 +153,6 @@ PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } - @Bean - RouterFunction publicKeyRoute(CryptoService cryptoService) { - return new PublicKeyRouteBuilder(cryptoService).build(); - } - @Bean CryptoService cryptoService(HaloProperties haloProperties) { return new RsaKeyService(haloProperties.getWorkDir().resolve("keys")); diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java index 353f2fb997..4bcdbefce7 100644 --- a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java +++ b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import lombok.Getter; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceResolvable; import org.springframework.http.ProblemDetail; @@ -11,6 +12,7 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.util.BindErrorUtils; +@Getter public class RequestBodyValidationException extends ServerWebInputException { private final Errors errors; diff --git a/application/src/main/java/run/halo/app/security/AuthProviderService.java b/application/src/main/java/run/halo/app/security/AuthProviderService.java index c996db4d30..6bd8093dd9 100644 --- a/application/src/main/java/run/halo/app/security/AuthProviderService.java +++ b/application/src/main/java/run/halo/app/security/AuthProviderService.java @@ -1,6 +1,7 @@ package run.halo.app.security; import java.util.List; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AuthProvider; @@ -17,4 +18,7 @@ public interface AuthProviderService { Mono disable(String name); Mono> listAll(); + + Flux getEnabledProviders(); + } diff --git a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java index 4b318ed56c..d6838bd575 100644 --- a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java +++ b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java @@ -19,9 +19,12 @@ import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.UserConnection; import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; @@ -56,10 +59,10 @@ public Mono disable(String name) { @Override public Mono> listAll() { - return client.list(AuthProvider.class, provider -> - provider.getMetadata().getDeletionTimestamp() == null, - defaultComparator() - ) + var listOptions = ListOptions.builder() + .andQuery(ExtensionUtil.notDeleting()) + .build(); + return client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort()) .map(this::convertTo) .collectList() .flatMap(providers -> listMyConnections() @@ -86,6 +89,17 @@ public Mono> listAll() { ); } + @Override + public Flux getEnabledProviders() { + return fetchEnabledAuthProviders().flatMapMany(enabledNames -> { + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.in("metadata.name", enabledNames)) + .andQuery(ExtensionUtil.notDeleting()) + .build(); + return client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort()); + }); + } + private Mono> fetchEnabledAuthProviders() { return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) .map(configMap -> { @@ -97,12 +111,14 @@ private Mono> fetchEnabledAuthProviders() { Flux listMyConnections() { return ReactiveSecurityContextHolder.getContext() .map(securityContext -> securityContext.getAuthentication().getName()) - .flatMapMany(username -> client.list(UserConnection.class, - persisted -> persisted.getSpec().getUsername().equals(username), - Comparator.comparing(item -> item.getMetadata() - .getCreationTimestamp()) - ) - ); + .flatMapMany(username -> { + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.equal("spec.username", username)) + .andQuery(ExtensionUtil.notDeleting()) + .build(); + return client.listAll(UserConnection.class, listOptions, + ExtensionUtil.defaultSort()); + }); } private static Comparator defaultComparator() { diff --git a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java index 9e0af48e5f..869e5fa428 100644 --- a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java +++ b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java @@ -4,6 +4,9 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @@ -17,15 +20,37 @@ */ public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { + private final ServerWebExchangeMatcher xhrMatcher = exchange -> { + if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With") + .contains("XMLHttpRequest")) { + return MatchResult.match(); + } + return MatchResult.notMatch(); + }; + + private final RedirectServerAuthenticationEntryPoint redirectEntryPoint; + + public DefaultServerAuthenticationEntryPoint() { + this.redirectEntryPoint = + new RedirectServerAuthenticationEntryPoint("/login?authentication_required"); + } + @Override public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { - return Mono.defer(() -> { - var response = exchange.getResponse(); - var wwwAuthenticate = "FormLogin realm=\"console\""; - response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); - response.setStatusCode(HttpStatus.UNAUTHORIZED); - return response.setComplete(); - }); + return xhrMatcher.matches(exchange) + .filter(MatchResult::isMatch) + .switchIfEmpty( + Mono.defer(() -> this.redirectEntryPoint.commence(exchange, ex)).then(Mono.empty()) + ) + .flatMap(match -> Mono.defer( + () -> { + var response = exchange.getResponse(); + var wwwAuthenticate = "FormLogin realm=\"console\""; + response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return response.setComplete(); + }).then(Mono.empty()) + ); } } diff --git a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java index bb72c7ca0e..c4497927fe 100644 --- a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java +++ b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java @@ -6,7 +6,9 @@ import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; import run.halo.app.security.authentication.rememberme.RememberMeServices; +import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; import run.halo.app.security.device.DeviceService; /** @@ -24,11 +26,15 @@ public class LoginHandlerEnhancerImpl implements LoginHandlerEnhancer { private final DeviceService deviceService; + private final RememberMeRequestCache rememberMeRequestCache = + new WebSessionRememberMeRequestCache(); + @Override public Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { return rememberMeServices.loginSuccess(exchange, successfulAuthentication) - .then(deviceService.loginSuccess(exchange, successfulAuthentication)); + .then(deviceService.loginSuccess(exchange, successfulAuthentication)) + .then(rememberMeRequestCache.removeRememberMe(exchange)); } @Override diff --git a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java index 79c3974d60..1cf8effe5b 100644 --- a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java @@ -1,11 +1,12 @@ package run.halo.app.security; -import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; import java.net.URI; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.config.web.server.ServerHttpSecurity; @@ -15,8 +16,10 @@ import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; -import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter; import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.rememberme.RememberMeServices; @@ -31,8 +34,8 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer { public void configure(ServerHttpSecurity http) { var serverLogoutHandlers = getLogoutHandlers(); http.logout( - logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers))); - http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING); + logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers)) + ); } private class LogoutSuccessHandler implements ServerLogoutSuccessHandler { @@ -42,7 +45,7 @@ private class LogoutSuccessHandler implements ServerLogoutSuccessHandler { public LogoutSuccessHandler(ServerLogoutHandler... logoutHandler) { var defaultHandler = new RedirectServerLogoutSuccessHandler(); - defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout")); + defaultHandler.setLogoutSuccessUrl(URI.create("/login?logout")); this.defaultHandler = defaultHandler; if (logoutHandler.length == 1) { this.logoutHandler = logoutHandler[0]; @@ -51,6 +54,19 @@ public LogoutSuccessHandler(ServerLogoutHandler... logoutHandler) { } } + @Bean + RouterFunction logoutPage() { + return RouterFunctions.route() + .GET("/logout", request -> { + var exchange = request.exchange(); + var contextPath = exchange.getRequest().getPath().contextPath().value(); + return ServerResponse.ok().render("logout", Map.of( + "action", contextPath + "/logout" + )); + }) + .build(); + } + @Override public Mono onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) { diff --git a/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java b/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java new file mode 100644 index 0000000000..9e0f4382e1 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java @@ -0,0 +1,21 @@ +package run.halo.app.security.authentication.exception; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.AuthenticationException; +import run.halo.app.infra.exception.RateLimitExceededException; + +/** + * Too many requests exception while authenticating. Because + * {@link RateLimitExceededException} is not a subclass of + * {@link AuthenticationException}, we need to create a new exception class to map it. + * + * @author johnniang + * @since 2.20.0 + */ +public class TooManyRequestsException extends AuthenticationException { + + public TooManyRequestsException(@Nullable Throwable throwable) { + super("Too many requests.", throwable); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java b/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java new file mode 100644 index 0000000000..fb37664b1f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java @@ -0,0 +1,15 @@ +package run.halo.app.security.authentication.exception; + +import org.springframework.security.core.AuthenticationException; + +public class TwoFactorAuthException extends AuthenticationException { + + public TwoFactorAuthException(String msg, Throwable cause) { + super(msg, cause); + } + + public TwoFactorAuthException(String msg) { + super(msg); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java index 9a3bfb7e12..26df92cbf0 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java @@ -13,9 +13,9 @@ import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.utils.IpAddressUtils; import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.exception.TooManyRequestsException; @Slf4j public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter { @@ -35,6 +35,9 @@ public Mono convert(ServerWebExchange exchange) { return super.convert(exchange) // validate the password .flatMap(token -> { + if (token.getCredentials() == null) { + return Mono.error(new BadCredentialsException("Empty credentials.")); + } var credentials = (String) token.getCredentials(); byte[] credentialsBytes; try { @@ -51,7 +54,9 @@ public Mono convert(ServerWebExchange exchange) { new String(decryptedCredentials, UTF_8))); }) .transformDeferred(createIpBasedRateLimiter(exchange)) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + // We have to remap the exception to an AuthenticationException + // for using in failure handler + .onErrorMap(RequestNotPermitted.class, TooManyRequestsException::new); } private RateLimiterOperator createIpBasedRateLimiter(ServerWebExchange exchange) { diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java index 8fff496451..fb49212d88 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java @@ -9,18 +9,23 @@ import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.security.HaloUserDetails; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Component public class LoginSecurityConfigurer implements SecurityConfigurer { @@ -66,10 +71,20 @@ public LoginSecurityConfigurer(ObservationRegistry observationRegistry, @Override public void configure(ServerHttpSecurity http) { - var filter = new AuthenticationWebFilter(authenticationManager()); + var filter = new AuthenticationWebFilter(authenticationManager()) { + @Override + protected Mono onAuthenticationSuccess(Authentication authentication, + WebFilterExchange webFilterExchange) { + // check if 2FA is enabled after authenticating successfully. + if (authentication.getPrincipal() instanceof HaloUserDetails userDetails + && userDetails.isTwoFactorAuthEnabled()) { + authentication = new TwoFactorAuthentication(authentication); + } + return super.onAuthenticationSuccess(authentication, webFilterExchange); + } + }; var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login"); - var handler = - new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); + var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry); filter.setRequiresAuthenticationMatcher(requiresMatcher); filter.setAuthenticationFailureHandler(handler); diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java index 912d9a5267..5ddf5e78c5 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java @@ -6,8 +6,6 @@ import org.springframework.security.core.AuthenticationException; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; -import run.halo.app.security.HaloUserDetails; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Slf4j public class UsernamePasswordDelegatingAuthenticationManager @@ -40,14 +38,6 @@ public Mono authenticate(Authentication authentication) { ) .switchIfEmpty( Mono.defer(() -> defaultAuthenticationManager.authenticate(authentication)) - ) - // check if MFA is enabled after authenticated - .map(a -> { - if (a.getPrincipal() instanceof HaloUserDetails user - && user.isTwoFactorAuthEnabled()) { - a = new TwoFactorAuthentication(a); - } - return a; - }); + ); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java index 33ce5b04db..a04a90208a 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java @@ -5,13 +5,17 @@ import static run.halo.app.infra.exception.Exceptions.createErrorResponse; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; +import java.net.URI; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; -import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; @@ -21,6 +25,9 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.security.LoginHandlerEnhancer; +import run.halo.app.security.authentication.exception.TooManyRequestsException; +import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; +import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Slf4j @@ -33,8 +40,10 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl private final LoginHandlerEnhancer loginHandlerEnhancer; - private final ServerAuthenticationFailureHandler defaultFailureHandler = - new RedirectServerAuthenticationFailureHandler("/console?error#/login"); + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + @Setter + private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); private final ServerAuthenticationSuccessHandler defaultSuccessHandler = new RedirectServerAuthenticationSuccessHandler("/console/"); @@ -54,10 +63,17 @@ public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, .then(ignoringMediaTypeAll(APPLICATION_JSON) .matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) - .switchIfEmpty( - defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception) - // Skip the handleAuthenticationException. - .then(Mono.empty()) + .switchIfEmpty(Mono.defer( + () -> { + URI location = URI.create("/login?error"); + if (exception instanceof BadCredentialsException) { + location = URI.create("/login?error=invalid-credential"); + } + if (exception instanceof TooManyRequestsException) { + location = URI.create("/login?error=rate-limit-exceeded"); + } + return redirectStrategy.sendRedirect(exchange, location); + }).then(Mono.empty()) ) .flatMap(matchResult -> handleAuthenticationException(exception, exchange))); } @@ -66,10 +82,12 @@ public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { if (authentication instanceof TwoFactorAuthentication) { - // continue filtering for authorization - return loginHandlerEnhancer.onLoginSuccess(webFilterExchange.getExchange(), - authentication) - .then(webFilterExchange.getChain().filter(webFilterExchange.getExchange())); + return rememberMeRequestCache.saveRememberMe(webFilterExchange.getExchange()) + // Do not use RedirectServerAuthenticationSuccessHandler to redirect + // because it will use request cache to redirect + .then(redirectStrategy.sendRedirect(webFilterExchange.getExchange(), + URI.create("/challenges/two-factor/totp")) + ); } if (authentication instanceof CredentialsContainer container) { diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java new file mode 100644 index 0000000000..846afb8215 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java @@ -0,0 +1,39 @@ +package run.halo.app.security.authentication.rememberme; + +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * An interface for caching remember-me parameter in request for further handling. Especially + * useful for two-factor authentication. + * + * @author johnniang + * @since 2.20.0 + */ +public interface RememberMeRequestCache { + + /** + * Save remember-me parameter or form into cache. + * + * @param exchange exchange + * @return empty to return + */ + Mono saveRememberMe(ServerWebExchange exchange); + + /** + * Check if remember-me parameter exists in cache. + * + * @param exchange exchange + * @return true if remember-me exists, false otherwise + */ + Mono isRememberMe(ServerWebExchange exchange); + + /** + * Remove remember-me parameter from cache. + * + * @param exchange exchange + * @return empty to return + */ + Mono removeRememberMe(ServerWebExchange exchange); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java index 17e67308f0..4f1e9c0dac 100644 --- a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java @@ -1,8 +1,5 @@ package run.halo.app.security.authentication.rememberme; -import static org.apache.commons.lang3.BooleanUtils.isTrue; -import static org.apache.commons.lang3.BooleanUtils.toBoolean; - import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -58,17 +55,13 @@ @RequiredArgsConstructor public class TokenBasedRememberMeServices implements ServerLogoutHandler, RememberMeServices { - public static final int TWO_WEEKS_S = 1209600; - - public static final String DEFAULT_PARAMETER = "remember-me"; - public static final String DEFAULT_ALGORITHM = "SHA-256"; private static final String DELIMITER = ":"; protected final CookieSignatureKeyResolver cookieSignatureKeyResolver; - protected final ReactiveUserDetailsService userDetailsService; + private final ReactiveUserDetailsService userDetailsService; protected final RememberMeCookieResolver rememberMeCookieResolver; @@ -76,6 +69,8 @@ public class TokenBasedRememberMeServices implements ServerLogoutHandler, Rememb private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); + private static boolean equals(String expected, String actual) { byte[] expectedBytes = bytesUtf8(expected); byte[] actualBytes = bytesUtf8(actual); @@ -214,11 +209,12 @@ public Mono loginFail(ServerWebExchange exchange) { @Override public Mono loginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { - if (!rememberMeRequested(exchange)) { - log.debug("Remember-me login not requested."); - return Mono.empty(); - } - return onLoginSuccess(exchange, successfulAuthentication); + return rememberMeRequestCache.isRememberMe(exchange) + .filter(Boolean::booleanValue) + .switchIfEmpty(Mono.fromRunnable(() -> { + log.debug("Remember-me login not requested."); + })) + .flatMap(rememberMe -> onLoginSuccess(exchange, successfulAuthentication)); } protected Mono onLoginSuccess(ServerWebExchange exchange, @@ -282,18 +278,6 @@ protected long calculateExpireTime(ServerWebExchange exchange, return Instant.now().plusSeconds(tokenLifetime).toEpochMilli(); } - protected boolean rememberMeRequested(ServerWebExchange exchange) { - String rememberMe = exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER); - if (isTrue(toBoolean(rememberMe))) { - return true; - } - if (log.isDebugEnabled()) { - log.debug("Did not send remember-me cookie (principal did not set parameter '{}')", - DEFAULT_PARAMETER); - } - return false; - } - protected String[] decodeCookie(String cookieValue) throws InvalidCookieException { int paddingCount = 4 - (cookieValue.length() % 4); if (paddingCount < 4) { diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java new file mode 100644 index 0000000000..5c6f629a02 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java @@ -0,0 +1,71 @@ +package run.halo.app.security.authentication.rememberme; + +import static java.lang.Boolean.parseBoolean; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; +import reactor.core.publisher.Mono; + +/** + * An implementation of {@link RememberMeRequestCache} that stores remember-me parameter in + * {@link WebSession}. + * + * @author johnniang + * @since 2.20.0 + */ +public class WebSessionRememberMeRequestCache implements RememberMeRequestCache { + + private static final String SESSION_ATTRIBUTE_NAME = + RememberMeRequestCache.class + ".REMEMBER_ME"; + + private static final String DEFAULT_PARAMETER = "remember-me"; + + @Override + public Mono saveRememberMe(ServerWebExchange exchange) { + return resolveFromQuery(exchange) + .filter(Boolean::booleanValue) + .switchIfEmpty(resolveFromForm(exchange)) + .filter(Boolean::booleanValue) + .flatMap(rememberMe -> exchange.getSession().doOnNext( + session -> session.getAttributes().put(SESSION_ATTRIBUTE_NAME, rememberMe)) + ) + .then(); + } + + @Override + public Mono isRememberMe(ServerWebExchange exchange) { + return resolveFromQuery(exchange) + .filter(Boolean::booleanValue) + .switchIfEmpty(resolveFromForm(exchange)) + .filter(Boolean::booleanValue) + .switchIfEmpty(resolveFromSession(exchange)) + .defaultIfEmpty(false); + } + + @Override + public Mono removeRememberMe(ServerWebExchange exchange) { + return exchange.getSession() + .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_NAME)) + .then(); + } + + private Mono resolveFromQuery(ServerWebExchange exchange) { + return Mono.just( + parseBoolean(exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER)) + ); + } + + private Mono resolveFromForm(ServerWebExchange exchange) { + return exchange.getFormData() + .map(form -> parseBoolean(form.getFirst(DEFAULT_PARAMETER))) + .filter(Boolean::booleanValue); + } + + private Mono resolveFromSession(ServerWebExchange exchange) { + return exchange.getSession() + .map(session -> { + var rememberMeObject = session.getAttribute(SESSION_ATTRIBUTE_NAME); + return rememberMeObject instanceof Boolean rememberMe ? rememberMe : false; + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java deleted file mode 100644 index 9de2be00fe..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java +++ /dev/null @@ -1,56 +0,0 @@ -package run.halo.app.security.authentication.twofactor; - -import java.net.URI; -import org.springframework.context.MessageSource; -import org.springframework.security.web.server.DefaultServerRedirectStrategy; -import org.springframework.security.web.server.ServerRedirectStrategy; -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import run.halo.app.infra.exception.Exceptions; - -@Component -public class DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthResponseHandler { - - private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); - - private static final String REDIRECT_LOCATION = "/console/login/mfa"; - - private final MessageSource messageSource; - - private final ServerResponse.Context context; - - private static final ServerWebExchangeMatcher XHR_MATCHER = exchange -> { - if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With") - .contains("XMLHttpRequest")) { - return ServerWebExchangeMatcher.MatchResult.match(); - } - return ServerWebExchangeMatcher.MatchResult.notMatch(); - }; - - public DefaultTwoFactorAuthResponseHandler(MessageSource messageSource, - ServerResponse.Context context) { - this.messageSource = messageSource; - this.context = context; - } - - @Override - public Mono handle(ServerWebExchange exchange) { - return XHR_MATCHER.matches(exchange) - .filter(ServerWebExchangeMatcher.MatchResult::isMatch) - .switchIfEmpty(Mono.defer( - () -> redirectStrategy.sendRedirect(exchange, URI.create(REDIRECT_LOCATION)) - .then(Mono.empty()))) - .flatMap(isXhr -> { - var errorResponse = Exceptions.createErrorResponse( - new TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)), - null, exchange, messageSource); - return ServerResponse.status(errorResponse.getStatusCode()) - .bodyValue(errorResponse.getBody()) - .flatMap(response -> response.writeTo(exchange, context)); - }); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..391bf16e9c --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java @@ -0,0 +1,29 @@ +package run.halo.app.security.authentication.twofactor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import reactor.core.publisher.Mono; +import run.halo.app.security.LoginHandlerEnhancer; + +@Slf4j +public class TotpAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + + private final LoginHandlerEnhancer loginEnhancer; + + private final ServerAuthenticationSuccessHandler successHandler = + new RedirectServerAuthenticationSuccessHandler("/uc"); + + public TotpAuthenticationSuccessHandler(LoginHandlerEnhancer loginEnhancer) { + this.loginEnhancer = loginEnhancer; + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, + Authentication authentication) { + return loginEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication) + .then(successHandler.onAuthenticationSuccess(webFilterExchange, authentication)); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java deleted file mode 100644 index a4216a4831..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java +++ /dev/null @@ -1,10 +0,0 @@ -package run.halo.app.security.authentication.twofactor; - -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -public interface TwoFactorAuthResponseHandler { - - Mono handle(ServerWebExchange exchange); - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java index d8dd6a770a..1799e12da9 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java @@ -1,15 +1,19 @@ package run.halo.app.security.authentication.twofactor; -import org.springframework.context.MessageSource; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import org.springframework.http.HttpMethod; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.twofactor.totp.TotpAuthService; -import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFilter; +import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationManager; +import run.halo.app.security.authentication.twofactor.totp.TotpCodeAuthenticationConverter; @Component public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer { @@ -18,30 +22,33 @@ public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer { private final TotpAuthService totpAuthService; - private final ServerResponse.Context context; - - private final MessageSource messageSource; - private final LoginHandlerEnhancer loginHandlerEnhancer; public TwoFactorAuthSecurityConfigurer( ServerSecurityContextRepository securityContextRepository, - TotpAuthService totpAuthService, - ServerResponse.Context context, - MessageSource messageSource, - LoginHandlerEnhancer loginHandlerEnhancer + TotpAuthService totpAuthService, LoginHandlerEnhancer loginHandlerEnhancer ) { this.securityContextRepository = securityContextRepository; this.totpAuthService = totpAuthService; - this.context = context; - this.messageSource = messageSource; this.loginHandlerEnhancer = loginHandlerEnhancer; } @Override public void configure(ServerHttpSecurity http) { - var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService, - context, messageSource, loginHandlerEnhancer); - http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION); + var authManager = new TotpAuthenticationManager(totpAuthService); + var filter = new AuthenticationWebFilter(authManager); + filter.setRequiresAuthenticationMatcher( + pathMatchers(HttpMethod.POST, "/challenges/two-factor/totp") + ); + filter.setSecurityContextRepository(securityContextRepository); + filter.setServerAuthenticationConverter(new TotpCodeAuthenticationConverter()); + filter.setAuthenticationSuccessHandler( + new TotpAuthenticationSuccessHandler(loginHandlerEnhancer) + ); + filter.setAuthenticationFailureHandler( + new RedirectServerAuthenticationFailureHandler("/challenges/two-factor/totp?error") + ); + http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); } + } diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java index 45a27e66b0..b9da3183ea 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java @@ -38,8 +38,8 @@ public Object getPrincipal() { @Override public boolean isAuthenticated() { - // return true for accessing anonymous resources - return true; + // for further authentication + return false; } public Authentication getPrevious() { diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java index f716717a46..f61cb9390b 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java @@ -1,6 +1,5 @@ package run.halo.app.security.authentication.twofactor; -import java.net.URI; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; @@ -10,27 +9,12 @@ public class TwoFactorAuthorizationManager implements ReactiveAuthorizationManager { - private final ReactiveAuthorizationManager delegate; - - private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp"); - - public TwoFactorAuthorizationManager( - ReactiveAuthorizationManager delegate) { - this.delegate = delegate; - } - @Override public Mono check(Mono authentication, AuthorizationContext context) { - return authentication.flatMap(a -> { - Mono checked = delegate.check(Mono.just(a), context); - if (a instanceof TwoFactorAuthentication) { - checked = checked.filter(AuthorizationDecision::isGranted) - .switchIfEmpty( - Mono.error(() -> new TwoFactorAuthRequiredException(REDIRECT_LOCATION))); - } - return checked; - }); + return authentication.map(TwoFactorAuthentication.class::isInstance) + .defaultIfEmpty(false) + .map(AuthorizationDecision::new); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java deleted file mode 100644 index b2140007dd..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java +++ /dev/null @@ -1,137 +0,0 @@ -package run.halo.app.security.authentication.twofactor.totp; - -import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; - -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.context.MessageSource; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.ReactiveAuthenticationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.CredentialsContainer; -import org.springframework.security.core.context.ReactiveSecurityContextHolder; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.web.server.authentication.AuthenticationWebFilter; -import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import run.halo.app.security.HaloUserDetails; -import run.halo.app.security.LoginHandlerEnhancer; -import run.halo.app.security.authentication.login.UsernamePasswordHandler; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; - -@Slf4j -public class TotpAuthenticationFilter extends AuthenticationWebFilter { - - public TotpAuthenticationFilter( - ServerSecurityContextRepository securityContextRepository, - TotpAuthService totpAuthService, - ServerResponse.Context context, - MessageSource messageSource, - LoginHandlerEnhancer loginHandlerEnhancer - ) { - super(new TwoFactorAuthManager(totpAuthService)); - - setSecurityContextRepository(securityContextRepository); - setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp")); - setServerAuthenticationConverter(new TotpCodeAuthenticationConverter()); - - var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); - setAuthenticationSuccessHandler(handler); - setAuthenticationFailureHandler(handler); - } - - private static class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter { - - private final String codeParameter = "code"; - - @Override - public Mono convert(ServerWebExchange exchange) { - // Check the request is authenticated before. - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .filter(TwoFactorAuthentication.class::isInstance) - .switchIfEmpty(Mono.error( - () -> new TwoFactorAuthException("MFA Authentication required."))) - .flatMap(authentication -> exchange.getFormData()) - .handle((formData, sink) -> { - var codeStr = formData.getFirst(codeParameter); - if (StringUtils.isBlank(codeStr)) { - sink.error(new TwoFactorAuthException("Empty code parameter.")); - return; - } - try { - var code = Integer.parseInt(codeStr); - sink.next(new TotpAuthenticationToken(code)); - } catch (NumberFormatException e) { - sink.error( - new TwoFactorAuthException("Invalid code parameter " + codeStr + '.')); - } - }); - } - } - - private static class TwoFactorAuthException extends AuthenticationException { - - public TwoFactorAuthException(String msg, Throwable cause) { - super(msg, cause); - } - - public TwoFactorAuthException(String msg) { - super(msg); - } - - } - - private static class TwoFactorAuthManager implements ReactiveAuthenticationManager { - - private final TotpAuthService totpAuthService; - - private TwoFactorAuthManager(TotpAuthService totpAuthService) { - this.totpAuthService = totpAuthService; - } - - @Override - public Mono authenticate(Authentication authentication) { - // it should be TotpAuthenticationToken - var code = (Integer) authentication.getCredentials(); - log.debug("Got TOTP code {}", code); - - // get user details - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .cast(TwoFactorAuthentication.class) - .map(TwoFactorAuthentication::getPrevious) - .flatMap(previousAuth -> { - var principal = previousAuth.getPrincipal(); - if (!(principal instanceof HaloUserDetails user)) { - return Mono.error( - new TwoFactorAuthException("Invalid authentication principal.") - ); - } - var totpEncryptedSecret = user.getTotpEncryptedSecret(); - if (StringUtils.isBlank(totpEncryptedSecret)) { - return Mono.error( - new TwoFactorAuthException("TOTP secret not configured.") - ); - } - var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret); - var validated = totpAuthService.validateTotp(rawSecret, code); - if (!validated) { - return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code)); - } - if (log.isDebugEnabled()) { - log.debug("TOTP authentication for {} with code {} successfully.", - previousAuth.getName(), code); - } - if (previousAuth instanceof CredentialsContainer container) { - container.eraseCredentials(); - } - return Mono.just(previousAuth); - }); - } - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java new file mode 100644 index 0000000000..e9fffb96f8 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java @@ -0,0 +1,69 @@ +package run.halo.app.security.authentication.twofactor.totp; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import reactor.core.publisher.Mono; +import run.halo.app.security.HaloUserDetails; +import run.halo.app.security.authentication.exception.TwoFactorAuthException; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +/** + * TOTP authentication manager. + * + * @author johnniang + */ +@Slf4j +public class TotpAuthenticationManager implements ReactiveAuthenticationManager { + + private final TotpAuthService totpAuthService; + + public TotpAuthenticationManager(TotpAuthService totpAuthService) { + this.totpAuthService = totpAuthService; + } + + @Override + public Mono authenticate(Authentication authentication) { + // it should be TotpAuthenticationToken + var code = (Integer) authentication.getCredentials(); + log.debug("Got TOTP code {}", code); + + // get user details + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .cast(TwoFactorAuthentication.class) + .map(TwoFactorAuthentication::getPrevious) + .flatMap(previousAuth -> { + var principal = previousAuth.getPrincipal(); + if (!(principal instanceof HaloUserDetails user)) { + return Mono.error( + new TwoFactorAuthException("Invalid authentication principal.") + ); + } + var totpEncryptedSecret = user.getTotpEncryptedSecret(); + if (StringUtils.isBlank(totpEncryptedSecret)) { + return Mono.error( + new TwoFactorAuthException("TOTP secret not configured.") + ); + } + var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret); + var validated = totpAuthService.validateTotp(rawSecret, code); + if (!validated) { + return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code)); + } + if (log.isDebugEnabled()) { + log.debug( + "TOTP authentication for {} with code {} successfully.", + previousAuth.getName(), code); + } + if (previousAuth instanceof CredentialsContainer container) { + container.eraseCredentials(); + } + return Mono.just(previousAuth); + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java new file mode 100644 index 0000000000..9adc9061f7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java @@ -0,0 +1,52 @@ +package run.halo.app.security.authentication.twofactor.totp; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.exception.TwoFactorAuthException; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +/** + * TOTP code authentication converter. + * + * @author johnniang + */ +public class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter { + + private final String codeParameter = "code"; + + @Override + public Mono convert(ServerWebExchange exchange) { + // Check the request is authenticated before. + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(TwoFactorAuthentication.class::isInstance) + .switchIfEmpty(Mono.error( + () -> new TwoFactorAuthException( + "MFA Authentication required." + )) + ) + .flatMap(authentication -> exchange.getFormData()) + .handle((formData, sink) -> { + var codeStr = formData.getFirst(codeParameter); + if (StringUtils.isBlank(codeStr)) { + sink.error(new TwoFactorAuthException( + "Empty code parameter." + )); + return; + } + try { + var code = Integer.parseInt(codeStr); + sink.next(new TotpAuthenticationToken(code)); + } catch (NumberFormatException e) { + sink.error(new TwoFactorAuthException( + "Invalid code parameter " + codeStr + '.') + ); + } + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java index d01fef7da9..d55fafe445 100644 --- a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java @@ -2,7 +2,6 @@ import java.util.List; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; @@ -12,7 +11,7 @@ @Slf4j public class RequestInfoAuthorizationManager - implements ReactiveAuthorizationManager { + implements ReactiveAuthorizationManager { private final AuthorizationRuleResolver ruleResolver; @@ -22,19 +21,19 @@ public RequestInfoAuthorizationManager(RoleService roleService) { @Override public Mono check(Mono authentication, - AuthorizationContext context) { - ServerHttpRequest request = context.getExchange().getRequest(); - RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); - - return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo) - .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) - .filter(AuthorizingVisitor::isAllowed) - .map(visitor -> new AuthorizationDecision(isGranted(auth))) - .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false)))); - } - - private boolean isGranted(Authentication authentication) { - return authentication != null && authentication.isAuthenticated(); + AuthorizationContext context) { + var request = context.getExchange().getRequest(); + var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + + // We allow anonymous user to access some resources + // so we don't invoke AuthenticationTrustResolver.isAuthenticated + // to check if the user is authenticated + return authentication.filter(Authentication::isAuthenticated) + .flatMap(auth -> ruleResolver.visitRules(auth, requestInfo)) + .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) + .map(AuthorizingVisitor::isAllowed) + .defaultIfEmpty(false) + .map(AuthorizationDecision::new); } private void showErrorMessage(List errors) { diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java new file mode 100644 index 0000000000..aaa5e8e764 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java @@ -0,0 +1,94 @@ +package run.halo.app.security.preauth; + +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.AuthProvider; +import run.halo.app.infra.actuator.GlobalInfoService; +import run.halo.app.plugin.PluginConst; +import run.halo.app.security.AuthProviderService; +import run.halo.app.security.authentication.CryptoService; + +/** + * Pre-auth login endpoints. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthLoginEndpoint { + + private final CryptoService cryptoService; + + private final GlobalInfoService globalInfoService; + + private final AuthProviderService authProviderService; + + PreAuthLoginEndpoint(CryptoService cryptoService, GlobalInfoService globalInfoService, + AuthProviderService authProviderService) { + this.cryptoService = cryptoService; + this.globalInfoService = globalInfoService; + this.authProviderService = authProviderService; + } + + @Bean + RouterFunction preAuthLoginEndpoints() { + return RouterFunctions.nest(path("/login"), RouterFunctions.route() + .GET("", request -> { + var exchange = request.exchange(); + var contextPath = exchange.getRequest().getPath().contextPath().value(); + var publicKey = cryptoService.readPublicKey() + .map(key -> Base64.getEncoder().encodeToString(key)); + var globalInfo = globalInfoService.getGlobalInfo().cache(); + var loginMethod = request.queryParam("method").orElse("local"); + var authProviders = authProviderService.getEnabledProviders().cache(); + var authProvider = authProviders + .filter(ap -> Objects.equals(loginMethod, ap.getMetadata().getName())) + .next() + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "Invalid login method " + loginMethod) + )) + .cache(); + + var fragmentTemplateName = authProvider.map(ap -> { + var templateName = "login_" + ap.getMetadata().getName(); + return Optional.ofNullable(ap.getMetadata().getLabels()) + .map(labels -> labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME)) + .filter(StringUtils::isNotBlank) + .map(pluginName -> String.join(":", "plugin", pluginName, templateName)) + .orElse(templateName); + }); + + var socialAuthProviders = authProviders + .filter(ap -> !AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) + .cache(); + var formAuthProviders = authProviders + .filter(ap -> AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) + .filter(ap -> !Objects.equals(loginMethod, ap.getMetadata().getName())) + .cache(); + + return ServerResponse.ok().render("login", Map.of( + "action", contextPath + "/login", + "publicKey", publicKey, + "globalInfo", globalInfo, + "authProvider", authProvider, + "fragmentTemplateName", fragmentTemplateName, + "socialAuthProviders", socialAuthProviders, + "formAuthProviders", formAuthProviders + // TODO Add more models here + )); + }) + .build()); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java new file mode 100644 index 0000000000..dc49b05c90 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java @@ -0,0 +1,186 @@ +package run.halo.app.security.preauth; + +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +import java.net.URI; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.user.service.EmailPasswordRecoveryService; +import run.halo.app.core.user.service.InvalidResetTokenException; +import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.infra.utils.IpAddressUtils; + +/** + * Pre-auth password reset endpoint. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthPasswordResetEndpoint { + + private final EmailPasswordRecoveryService emailPasswordRecoveryService; + + private final MessageSource messageSource; + + private final RateLimiterRegistry rateLimiterRegistry; + + public PreAuthPasswordResetEndpoint(EmailPasswordRecoveryService emailPasswordRecoveryService, + MessageSource messageSource, + RateLimiterRegistry rateLimiterRegistry + ) { + this.emailPasswordRecoveryService = emailPasswordRecoveryService; + this.messageSource = messageSource; + this.rateLimiterRegistry = rateLimiterRegistry; + } + + @Bean + RouterFunction preAuthPasswordResetEndpoints() { + return RouterFunctions.nest(path("/password-reset"), RouterFunctions.route() + .GET("", request -> ServerResponse.ok().render("password-reset")) + .GET("/{resetToken}", + request -> { + var token = request.pathVariable("resetToken"); + return emailPasswordRecoveryService.getValidResetToken(token) + .flatMap(resetToken -> { + // TODO Check the 2FA of the user + return ServerResponse.ok().render("password-reset-link", Map.of( + "username", resetToken.username() + )); + }) + .onErrorResume(InvalidResetTokenException.class, + e -> ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/password-reset")) + .build() + .transformDeferred(rateLimiterForPasswordResetVerification( + request.exchange().getRequest() + )) + .onErrorMap( + RequestNotPermitted.class, RateLimitExceededException::new + ) + ); + } + ) + .POST("/{resetToken}", request -> { + var token = request.pathVariable("resetToken"); + return request.formData() + .flatMap(formData -> { + var locale = Optional.ofNullable( + request.exchange().getLocaleContext().getLocale() + ) + .orElseGet(Locale::getDefault); + var password = formData.getFirst("password"); + var confirmPassword = formData.getFirst("confirmPassword"); + if (StringUtils.isBlank(password)) { + var error = messageSource.getMessage( + "passwordReset.password.blank", + null, + "Password can't be blank", + locale + ); + return ServerResponse.ok().render("password-reset-link", Map.of( + "error", error + )); + } + if (!Objects.equals(password, confirmPassword)) { + var error = messageSource.getMessage( + "passwordReset.confirmPassword.mismatch", + null, + "Password and confirm password mismatch", + locale + ); + return ServerResponse.ok().render("password-reset-link", Map.of( + "error", error + )); + } + return emailPasswordRecoveryService.changePassword(password, token) + .then(ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/login?passwordReset")) + .build() + ) + .onErrorResume(InvalidResetTokenException.class, e -> { + var error = messageSource.getMessage( + "passwordReset.resetToken.invalid", + null, + "Invalid reset token", + locale + ); + return ServerResponse.ok().render("password-reset-link", Map.of( + "error", error + )).transformDeferred(rateLimiterForPasswordResetVerification( + request.exchange().getRequest() + )).onErrorMap( + RequestNotPermitted.class, RateLimitExceededException::new + ); + }); + }); + }) + .POST("", contentType(MediaType.APPLICATION_FORM_URLENCODED), + request -> { + // get username and email + return request.formData() + .flatMap(formData -> { + var locale = Optional.ofNullable( + request.exchange().getLocaleContext().getLocale() + ) + .orElseGet(Locale::getDefault); + var email = formData.getFirst("email"); + if (StringUtils.isBlank(email)) { + var error = messageSource.getMessage( + "passwordReset.email.blank", + null, + "Email can't be blank", + locale + ); + return ServerResponse.ok().render("password-reset", Map.of( + "error", error + )); + } + return emailPasswordRecoveryService.sendPasswordResetEmail(email) + .then(ServerResponse.ok().render("password-reset", Map.of( + "sent", true + ))) + .transformDeferred(rateLimiterForSendPasswordResetEmail( + request.exchange().getRequest() + )) + .onErrorMap( + RequestNotPermitted.class, RateLimitExceededException::new + ); + }); + }) + .build()); + } + + + RateLimiterOperator rateLimiterForSendPasswordResetEmail(ServerHttpRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + var rateLimiterKey = "send-password-reset-email-from-" + clientIp; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-password-reset-email"); + return RateLimiterOperator.of(rateLimiter); + } + + RateLimiterOperator rateLimiterForPasswordResetVerification(ServerHttpRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + var rateLimiterKey = "password-reset-email-verify-from-" + clientIp; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "password-reset-verification"); + return RateLimiterOperator.of(rateLimiter); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java new file mode 100644 index 0000000000..39c0bfc432 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java @@ -0,0 +1,157 @@ +package run.halo.app.security.preauth; + +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import java.net.URI; +import lombok.Data; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.EmailVerificationService; +import run.halo.app.core.user.service.SignUpData; +import run.halo.app.core.user.service.UserService; +import run.halo.app.infra.actuator.GlobalInfoService; +import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.EmailVerificationFailed; +import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.infra.exception.RequestBodyValidationException; +import run.halo.app.infra.utils.IpAddressUtils; + +/** + * Pre-auth sign up endpoint. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthSignUpEndpoint { + + private final GlobalInfoService globalInfoService; + + private final Validator validator; + + private final UserService userService; + + private final EmailVerificationService emailVerificationService; + + private final RateLimiterRegistry rateLimiterRegistry; + + PreAuthSignUpEndpoint(GlobalInfoService globalInfoService, + Validator validator, + UserService userService, + EmailVerificationService emailVerificationService, + RateLimiterRegistry rateLimiterRegistry) { + this.globalInfoService = globalInfoService; + this.validator = validator; + this.userService = userService; + this.emailVerificationService = emailVerificationService; + this.rateLimiterRegistry = rateLimiterRegistry; + } + + @Bean + RouterFunction preAuthSignUpEndpoints() { + return RouterFunctions.nest(path("/signup"), RouterFunctions.route() + .GET("", request -> { + var signUpData = new SignUpData(); + var bindingResult = new BeanPropertyBindingResult(signUpData, "form"); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + return ServerResponse.ok().render("signup", model); + }) + .POST( + "", + contentType(APPLICATION_FORM_URLENCODED), + request -> request.formData() + .map(SignUpData::of) + .flatMap(signUpData -> { + // sign up + var bindingResult = new BeanPropertyBindingResult(signUpData, "form"); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + validator.validate(signUpData, bindingResult); + if (bindingResult.hasErrors()) { + return ServerResponse.ok().render("signup", model); + } + return userService.signUp(signUpData) + .flatMap(user -> ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/login?signup")) + .build() + ) + .doOnError(t -> { + model.put("error", "unknown"); + model.put("errorMessage", t.getMessage()); + }) + .doOnError(EmailVerificationFailed.class, + e -> { + bindingResult.addError(new FieldError("form", + "emailCode", + signUpData.getEmailCode(), + true, + // TODO Refine i18n + new String[] {"signup.error.email-captcha.invalid"}, + null, + "Invalid Email Code")); + } + ) + .doOnError(RateLimitExceededException.class, + e -> model.put("error", "rate-limit-exceeded") + ) + .doOnError(DuplicateNameException.class, + e -> model.put("error", "duplicate-username") + ) + .onErrorResume(e -> ServerResponse.ok().render("signup", model)); + }) + ) + .POST("/send-email-code", contentType(APPLICATION_JSON), + request -> request.bodyToMono(SendEmailCodeBody.class) + .flatMap(body -> { + var bindingResult = new BeanPropertyBindingResult(body, "body"); + validator.validate(body, bindingResult); + if (bindingResult.hasErrors()) { + return Mono.error(new RequestBodyValidationException(bindingResult)); + } + var email = body.getEmail(); + return emailVerificationService.sendRegisterVerificationCode(email) + .transformDeferred( + rateLimiterForSendingEmailCode(request.exchange().getRequest()) + ) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + }) + .then(ServerResponse.accepted().build())) + .build()); + } + + private RateLimiterOperator rateLimiterForSendingEmailCode(ServerHttpRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + var rateLimiterKey = "send-email-code-for-signing-up-from-" + clientIp; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); + return RateLimiterOperator.of(rateLimiter); + } + + + @Data + public static class SendEmailCodeBody { + + @Email + @NotBlank + String email; + + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java new file mode 100644 index 0000000000..8fea7bdd14 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java @@ -0,0 +1,27 @@ +package run.halo.app.security.preauth; + +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Pre-auth two-factor endpoints. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthTwoFactorEndpoint { + + @Bean + RouterFunction preAuthTwoFactorEndpoints() { + return RouterFunctions.route() + .GET("/challenges/two-factor/totp", + request -> ServerResponse.ok().render("challenges/two-factor/totp") + ) + .build(); + } + +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java index 558dbb3c50..de56c2b430 100644 --- a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java +++ b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java @@ -73,7 +73,7 @@ public static SiteSettingVo from(ConfigMap configMap) { .subtitle(basicSetting.getSubtitle()) .logo(basicSetting.getLogo()) .favicon(basicSetting.getFavicon()) - .allowRegistration(userSetting.getAllowRegistration()) + .allowRegistration(userSetting.isAllowRegistration()) .post(PostSetting.builder() .postPageSize(postSetting.getPostPageSize()) .archivePageSize(postSetting.getArchivePageSize()) diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java index d6943dfb9d..db89e286bc 100644 --- a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java +++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java @@ -9,7 +9,6 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; -import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -32,7 +31,6 @@ public class ThemeMessageResolutionUtils { private static final Map EMPTY_MESSAGES = Collections.emptyMap(); private static final String PROPERTIES_FILE_EXTENSION = ".properties"; private static final String LOCATION = "i18n"; - private static final Object[] EMPTY_MESSAGE_PARAMETERS = new Object[0]; @Nullable private static Reader messageReader(String messageResourceName, ThemeContext theme) @@ -96,91 +94,6 @@ public static Map resolveMessagesForTemplate(final Locale locale return Collections.unmodifiableMap(combinedMessages); } - public static Map resolveMessagesForOrigin(final Class origin, - final Locale locale) { - - final Map combinedMessages = new HashMap<>(20); - - Class currentClass = origin; - combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale)); - - while (!currentClass.getSuperclass().equals(Object.class)) { - - currentClass = currentClass.getSuperclass(); - final Map messagesForCurrentClass = - resolveMessagesForSpecificClass(currentClass, locale); - for (final String messageKey : messagesForCurrentClass.keySet()) { - if (!combinedMessages.containsKey(messageKey)) { - combinedMessages.put(messageKey, messagesForCurrentClass.get(messageKey)); - } - } - } - - return Collections.unmodifiableMap(combinedMessages); - - } - - - private static Map resolveMessagesForSpecificClass( - final Class originClass, final Locale locale) { - - - final ClassLoader originClassLoader = originClass.getClassLoader(); - - // Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES - // .properties, _gl.properties... - // The order here is important: as we will let values from more specific files - // overwrite those in less specific, - // (e.g. a value for gl_ES will have more precedence than a value for gl). So we will - // iterate these resource - // names from less specific to more specific. - final List messageResourceNames = - computeMessageResourceNamesFromBase(locale); - - // Build the combined messages - Map combinedMessages = null; - for (final String messageResourceName : messageResourceNames) { - - final InputStream inputStream = - originClassLoader.getResourceAsStream(messageResourceName); - if (inputStream != null) { - - // At this point we cannot be specified a character encoding (that's only for - // template resolution), - // so we will use the standard character encoding for .properties files, - // which is ISO-8859-1 - // (see Properties#load(InputStream) javadoc). - final InputStreamReader messageResourceReader = - new InputStreamReader(inputStream); - - final Properties messageProperties = - readMessagesResource(messageResourceReader); - if (messageProperties != null && !messageProperties.isEmpty()) { - - if (combinedMessages == null) { - combinedMessages = new HashMap<>(20); - } - - for (final Map.Entry propertyEntry : - messageProperties.entrySet()) { - combinedMessages.put((String) propertyEntry.getKey(), - (String) propertyEntry.getValue()); - } - - } - - } - - } - - if (combinedMessages == null) { - return EMPTY_MESSAGES; - } - - return Collections.unmodifiableMap(combinedMessages); - } - - private static List computeMessageResourceNamesFromBase(final Locale locale) { final List resourceNames = new ArrayList<>(5); @@ -229,33 +142,4 @@ private static Properties readMessagesResource(final Reader propertiesReader) { return properties; } - public static String formatMessage(final Locale locale, final String message, - final Object[] messageParameters) { - if (message == null) { - return null; - } - if (!isFormatCandidate(message)) { - // trying to avoid creating MessageFormat if not needed - return message; - } - final MessageFormat messageFormat = new MessageFormat(message, locale); - return messageFormat.format( - (messageParameters != null ? messageParameters : EMPTY_MESSAGE_PARAMETERS)); - } - - /* - * This will allow us to determine whether a message might actually contain parameter - * placeholders. - */ - private static boolean isFormatCandidate(final String message) { - char c; - int n = message.length(); - while (n-- != 0) { - c = message.charAt(n); - if (c == '}' || c == '\'') { - return true; - } - } - return false; - } } diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java index 18c9819fc6..17f141108e 100644 --- a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java +++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java @@ -33,13 +33,4 @@ protected Map resolveMessagesForTemplate(String template, return Collections.unmodifiableMap(properties); } - @Override - protected Map resolveMessagesForOrigin(Class origin, Locale locale) { - return ThemeMessageResolutionUtils.resolveMessagesForOrigin(origin, locale); - } - - @Override - protected String formatMessage(Locale locale, String message, Object[] messageParameters) { - return ThemeMessageResolutionUtils.formatMessage(locale, message, messageParameters); - } } diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 681a94adc3..b2869a3e11 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -96,7 +96,11 @@ resilience4j.ratelimiter: limitForPeriod: 3 limitRefreshPeriod: 1h timeoutDuration: 0s - send-reset-password-email: - limitForPeriod: 2 + send-password-reset-email: + limitForPeriod: 10 + limitRefreshPeriod: 1m + timeoutDuration: 0s + password-reset-verification: + limitForPeriod: 10 limitRefreshPeriod: 1m timeoutDuration: 0s diff --git a/application/src/main/resources/extensions/authproviders.yaml b/application/src/main/resources/extensions/authproviders.yaml index 58e3369c6f..87cad00e27 100644 --- a/application/src/main/resources/extensions/authproviders.yaml +++ b/application/src/main/resources/extensions/authproviders.yaml @@ -9,8 +9,10 @@ metadata: - system-protection spec: displayName: Local - enabled: true description: Built-in authentication for Halo. - logo: https://www.halo.run/logo + logo: /images/logo.png website: https://www.halo.run authenticationUrl: /login + method: post + rememberMeSupport: true + authType: form diff --git a/application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java deleted file mode 100644 index b37a20da98..0000000000 --- a/application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package run.halo.app.core.endpoint.theme; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.github.resilience4j.ratelimiter.RateLimiter; -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.test.web.reactive.server.WebTestClient; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.User; -import run.halo.app.core.user.service.UserService; -import run.halo.app.extension.Metadata; -import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; -import run.halo.app.infra.SystemSetting; - -/** - * Tests for {@link PublicUserEndpoint}. - * - * @author guqing - * @since 2.4.0 - */ -@ExtendWith(MockitoExtension.class) -class PublicUserEndpointTest { - @Mock - private UserService userService; - @Mock - private ServerSecurityContextRepository securityContextRepository; - @Mock - private ReactiveUserDetailsService reactiveUserDetailsService; - @Mock - SystemConfigurableEnvironmentFetcher environmentFetcher; - @Mock - RateLimiterRegistry rateLimiterRegistry; - - @InjectMocks - private PublicUserEndpoint publicUserEndpoint; - - private WebTestClient webClient; - - @BeforeEach - void setUp() { - webClient = WebTestClient.bindToRouterFunction(publicUserEndpoint.endpoint()) - .build(); - } - - @Test - void signUp() { - User user = new User(); - user.setMetadata(new Metadata()); - user.getMetadata().setName("fake-user"); - user.setSpec(new User.UserSpec()); - user.getSpec().setDisplayName("hello"); - user.getSpec().setBio("bio"); - - when(userService.signUp(any(User.class), anyString())).thenReturn(Mono.just(user)); - when(securityContextRepository.save(any(), any())).thenReturn(Mono.empty()); - when(reactiveUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just( - org.springframework.security.core.userdetails.User.withUsername("fake-user") - .password("123456") - .authorities("test-role") - .build())); - SystemSetting.User userSetting = mock(SystemSetting.User.class); - when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) - .thenReturn(Mono.just(userSetting)); - - when(rateLimiterRegistry.rateLimiter("signup-from-ip-127.0.0.1", "signup")) - .thenReturn(RateLimiter.ofDefaults("signup")); - - webClient.post() - .uri("/users/-/signup") - .header("X-Forwarded-For", "127.0.0.1") - .bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password", "")) - .exchange() - .expectStatus().isOk(); - - verify(userService).signUp(any(User.class), anyString()); - verify(securityContextRepository).save(any(), any()); - verify(reactiveUserDetailsService).findByUsername(eq("fake-user")); - } -} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java index 08a400a7a2..93a93da7d1 100644 --- a/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; @@ -27,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -34,15 +37,12 @@ import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.Subject; import run.halo.app.core.extension.User; -import run.halo.app.core.user.service.RoleService; -import run.halo.app.core.user.service.UserServiceImpl; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.infra.exception.DuplicateNameException; import run.halo.app.infra.exception.UserNotFoundException; @@ -302,11 +302,14 @@ void signUpWhenRegistrationNotAllowed() { eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + var signUpData = createSignUpData("fake-user", "fake-password"); - userService.signUp(fakeUser, "fake-password") + userService.signUp(signUpData) .as(StepVerifier::create) - .expectError(AccessDeniedException.class) + .consumeErrorWith(e -> { + assertInstanceOf(ServerWebInputException.class, e); + assertTrue(e.getMessage().contains("registration is not allowed")); + }) .verify(); } @@ -318,11 +321,14 @@ void signUpWhenRegistrationDefaultRoleNotConfigured() { eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + var signUpData = createSignUpData("fake-user", "fake-password"); - userService.signUp(fakeUser, "fake-password") + userService.signUp(signUpData) .as(StepVerifier::create) - .expectError(AccessDeniedException.class) + .consumeErrorWith(e -> { + assertInstanceOf(ServerWebInputException.class, e); + assertTrue(e.getMessage().contains("default role is not configured")); + }) .verify(); } @@ -336,11 +342,10 @@ void signUpWhenRegistrationUsernameExists() { .thenReturn(Mono.just(userSetting)); when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password"); when(client.fetch(eq(User.class), eq("fake-user"))) - .thenReturn(Mono.just(fakeSignUpUser("test", "test"))); - - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + .thenReturn(Mono.just(createFakeUser("test", "test"))); - userService.signUp(fakeUser, "fake-password") + var signUpData = createSignUpData("fake-user", "fake-password"); + userService.signUp(signUpData) .as(StepVerifier::create) .expectError(DuplicateNameException.class) .verify(); @@ -358,7 +363,8 @@ void signUpWhenRegistrationSuccessfully() { when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Mono.empty()); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + User fakeUser = createFakeUser("fake-user", "fake-password"); + var signUpData = createSignUpData("fake-user", "fake-password"); when(client.fetch(eq(Role.class), anyString())).thenReturn(Mono.just(new Role())); when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser)); @@ -366,7 +372,7 @@ void signUpWhenRegistrationSuccessfully() { doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"), anySet()); - spyUserService.signUp(fakeUser, "fake-password") + spyUserService.signUp(signUpData) .as(StepVerifier::create) .consumeNextWith(user -> { assertThat(user.getMetadata().getName()).isEqualTo("fake-user"); @@ -378,7 +384,7 @@ void signUpWhenRegistrationSuccessfully() { verify(spyUserService).grantRoles(eq("fake-user"), anySet()); } - User fakeSignUpUser(String name, String password) { + User createFakeUser(String name, String password) { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName(name); @@ -386,6 +392,13 @@ User fakeSignUpUser(String name, String password) { user.getSpec().setPassword(password); return user; } + + SignUpData createSignUpData(String name, String password) { + SignUpData signUpData = new SignUpData(); + signUpData.setUsername(name); + signUpData.setPassword(password); + return signUpData; + } } @Test diff --git a/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java index 37804408ef..589312724e 100644 --- a/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java @@ -1,14 +1,7 @@ package run.halo.app.core.user.service.impl; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import run.halo.app.core.user.service.impl.EmailPasswordRecoveryServiceImpl; -import run.halo.app.infra.exception.RateLimitExceededException; /** * Tests for {@link EmailPasswordRecoveryServiceImpl}. @@ -19,66 +12,4 @@ @ExtendWith(MockitoExtension.class) class EmailPasswordRecoveryServiceImplTest { - @Nested - class ResetPasswordVerificationManagerTest { - @Test - public void generateTokenTest() { - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - verificationManager.generateToken("fake-user"); - var result = verificationManager.contains("fake-user"); - assertThat(result).isTrue(); - - verificationManager.generateToken("guqing"); - result = verificationManager.contains("guqing"); - assertThat(result).isTrue(); - - result = verificationManager.contains("123"); - assertThat(result).isFalse(); - } - } - - @Test - public void removeTest() { - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - verificationManager.generateToken("fake-user"); - var result = verificationManager.contains("fake-user"); - - verificationManager.removeToken("fake-user"); - result = verificationManager.contains("fake-user"); - assertThat(result).isFalse(); - } - - @Test - void verifyTokenTestNormal() { - String username = "guqing"; - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - var result = verificationManager.verifyToken(username, "fake-code"); - assertThat(result).isFalse(); - - var token = verificationManager.generateToken(username); - result = verificationManager.verifyToken(username, "fake-code"); - assertThat(result).isFalse(); - - result = verificationManager.verifyToken(username, token); - assertThat(result).isTrue(); - } - - @Test - void verifyTokenFailedAfterMaxAttempts() { - String username = "guqing"; - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - var token = verificationManager.generateToken(username); - for (int i = 0; i <= EmailPasswordRecoveryServiceImpl.MAX_ATTEMPTS; i++) { - var result = verificationManager.verifyToken(username, "fake-code"); - assertThat(result).isFalse(); - } - - assertThatThrownBy(() -> verificationManager.verifyToken(username, token)) - .isInstanceOf(RateLimitExceededException.class) - .hasMessage("429 TOO_MANY_REQUESTS \"You have exceeded your quota\""); - } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java index 98b09cf0ed..357dacb488 100644 --- a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java +++ b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java @@ -1,15 +1,26 @@ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.web.filter.reactive.ServerWebExchangeContextFilter.EXCHANGE_CONTEXT_ATTRIBUTE; import java.net.MalformedURLException; import java.net.URI; +import java.net.URL; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import reactor.test.StepVerifier; /** * Tests for {@link DefaultExternalLinkProcessor}. @@ -26,9 +37,10 @@ class DefaultExternalLinkProcessorTest { @InjectMocks DefaultExternalLinkProcessor externalLinkProcessor; + @Test void processWhenLinkIsEmpty() { - assertThat(externalLinkProcessor.processLink(null)).isNull(); + assertThat(externalLinkProcessor.processLink((String) null)).isNull(); assertThat(externalLinkProcessor.processLink("")).isEmpty(); } @@ -48,4 +60,66 @@ void process() throws MalformedURLException { assertThat(externalLinkProcessor.processLink("https://halo.run/test")) .isEqualTo("https://halo.run/test"); } + + @ParameterizedTest + @MethodSource("processUriTestWithoutServerWebExchangeArguments") + void processUriWithoutServerWebExchange(String link, String expectedLink) + throws MalformedURLException { + lenient().when(externalUrlSupplier.getRaw()) + .thenReturn(new URL("https://www.halo.run/context-path")); + externalLinkProcessor.processLink(URI.create(link)) + .as(StepVerifier::create) + .expectNext(URI.create(expectedLink)) + .verifyComplete(); + } + + static Stream processUriTestWithoutServerWebExchangeArguments() { + return Stream.of( + Arguments.of("http://localhost:8090/halo", "http://localhost:8090/halo"), + Arguments.of("/halo", "https://www.halo.run/context-path/halo"), + Arguments.of("halo", "https://www.halo.run/context-path/halo"), + Arguments.of("/halo?query", "https://www.halo.run/context-path/halo?query"), + Arguments.of( + "/halo?query#fragment", "https://www.halo.run/context-path/halo?query#fragment" + ), + Arguments.of("/halo/subpath", "https://www.halo.run/context-path/halo/subpath"), + Arguments.of("/halo/中文", "https://www.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"), + Arguments.of("/halo/ooo%2Fooo", "https://www.halo.run/context-path/halo/ooo%2Fooo") + ); + } + + @ParameterizedTest + @MethodSource("processUriTestWithServerWebExchangeArguments") + void processUriWithServerWebExchange(String link, String expectLink) + throws MalformedURLException { + lenient().when(externalUrlSupplier.getRaw()) + .thenReturn(URI.create("https://www.halo.run").toURL()); + var request = mock(ServerHttpRequest.class); + var exchange = mock(ServerWebExchange.class); + lenient().when(exchange.getRequest()).thenReturn(request); + lenient().when(externalUrlSupplier.getURL(request)).thenReturn( + new URL("https://antoher.halo.run/context-path")); + externalLinkProcessor.processLink(URI.create(link)) + .contextWrite(context -> context.put(EXCHANGE_CONTEXT_ATTRIBUTE, exchange)) + .as(StepVerifier::create) + .expectNext(URI.create(expectLink)) + .verifyComplete(); + } + + static Stream processUriTestWithServerWebExchangeArguments() { + return Stream.of( + Arguments.of("http://localhost:8090/halo?query#fragment", + "http://localhost:8090/halo?query#fragment"), + Arguments.of("/halo", "https://antoher.halo.run/context-path/halo"), + Arguments.of("halo", "https://antoher.halo.run/context-path/halo"), + Arguments.of("/halo?query", "https://antoher.halo.run/context-path/halo?query"), + Arguments.of("/halo?query#fragment", + "https://antoher.halo.run/context-path/halo?query#fragment"), + Arguments.of("/halo/subpath", "https://antoher.halo.run/context-path/halo/subpath"), + Arguments.of("/halo/中文", + "https://antoher.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"), + Arguments.of("/halo/ooo%2Fooo", "https://antoher.halo.run/context-path/halo/ooo%2Fooo") + ); + } + } diff --git a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java index 5b41cce60d..3c25919ed7 100644 --- a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java +++ b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -16,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.data.domain.Sort; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Flux; @@ -24,6 +26,7 @@ import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.UserConnection; import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemSetting; @@ -121,9 +124,10 @@ void listAll() { AuthProvider gitee = createAuthProvider("gitee"); - when(client.list(eq(AuthProvider.class), any(), any())) + when(client.listAll(same(AuthProvider.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(github, gitlab, gitee)); - when(client.list(eq(UserConnection.class), any(), any())).thenReturn(Flux.empty()); + when(client.listAll(same(UserConnection.class), any(ListOptions.class), any(Sort.class))) + .thenReturn(Flux.empty()); ConfigMap configMap = new ConfigMap(); configMap.setData(new HashMap<>()); diff --git a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java index f473bfca63..7ede071f7c 100644 --- a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java +++ b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.http.HttpHeaders.WWW_AUTHENTICATE; +import java.net.URI; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -19,8 +20,9 @@ class DefaultServerAuthenticationEntryPointTest { DefaultServerAuthenticationEntryPoint entryPoint; @Test - void commence() { + void commenceForXhrRequest() { var mockReq = MockServerHttpRequest.get("/protected") + .header("X-Requested-With", "XMLHttpRequest") .build(); var mockExchange = MockServerWebExchange.builder(mockReq) .build(); @@ -32,4 +34,17 @@ void commence() { assertEquals("FormLogin realm=\"console\"", headers.getFirst(WWW_AUTHENTICATE)); } + @Test + void commenceForNormalRequest() { + var mockReq = MockServerHttpRequest.get("/protected") + .build(); + var mockExchange = MockServerWebExchange.builder(mockReq) + .build(); + var commenceMono = entryPoint.commence(mockExchange, + new AuthenticationCredentialsNotFoundException("Not Found")); + StepVerifier.create(commenceMono) + .verifyComplete(); + assertEquals(URI.create("/login?authentication_required"), + mockExchange.getResponse().getHeaders().getLocation()); + } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java b/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java deleted file mode 100644 index f646ad040b..0000000000 --- a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package run.halo.app.security; - -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.web.reactive.server.WebTestClient; -import run.halo.app.core.extension.Role; -import run.halo.app.core.extension.RoleBinding; -import run.halo.app.core.extension.User; -import run.halo.app.extension.ReactiveExtensionClient; - -@Disabled -@SpringBootTest(properties = {"halo.security.initializer.disabled=false", - "halo.security.initializer.super-admin-username=fake-admin", - "halo.security.initializer.super-admin-password=fake-password", - "halo.required-extension-disabled=true", - "halo.theme.initializer.disabled=true"}) -@AutoConfigureWebTestClient -@AutoConfigureTestDatabase -class SuperAdminInitializerTest { - - @MockitoSpyBean - ReactiveExtensionClient client; - - @Autowired - WebTestClient webClient; - - @Autowired - PasswordEncoder encoder; - - @Test - void checkSuperAdminInitialization() { - verify(client, times(1)).create(argThat(extension -> { - if (extension instanceof User user) { - return "fake-admin".equals(user.getMetadata().getName()) - && encoder.matches("fake-password", user.getSpec().getPassword()); - } - return false; - })); - verify(client, times(1)).create(argThat(extension -> { - if (extension instanceof Role role) { - return "super-role".equals(role.getMetadata().getName()); - } - return false; - })); - verify(client, times(1)).create(argThat(extension -> { - if (extension instanceof RoleBinding roleBinding) { - return "fake-admin-super-role-binding".equals(roleBinding.getMetadata().getName()); - } - return false; - })); - } -} diff --git a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java index a40207b96f..445895a348 100644 --- a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java +++ b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java @@ -27,8 +27,8 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.exception.TooManyRequestsException; @ExtendWith(MockitoExtension.class) class LoginAuthenticationConverterTest { @@ -77,7 +77,7 @@ void shouldTriggerRateLimit() { when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication")) .thenReturn(rateLimiter); StepVerifier.create(converter.convert(exchange)) - .expectError(RateLimitExceededException.class) + .expectError(TooManyRequestsException.class) .verify(); verify(cryptoService, never()).decrypt(password.getBytes()); diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java index 49912b316d..2dce90adce 100644 --- a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java @@ -70,12 +70,18 @@ void setUp() { void anonymousUserAccessProtectedApi() { when(userDetailsService.findByUsername(eq(AnonymousUserConst.PRINCIPAL))) .thenReturn(Mono.empty()); - when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.empty()); - webClient.get().uri("/apis/fake.halo.run/v1/posts").exchange().expectStatus() - .isUnauthorized(); + webClient.get().uri("/apis/fake.halo.run/v1/posts") + .header("X-Requested-With", "XMLHttpRequest") + .exchange() + .expectStatus().isUnauthorized(); - verify(roleService).listDependenciesFlux(anySet()); + webClient.get().uri("/apis/fake.halo.run/v1/posts") + .exchange() + .expectStatus().isFound() + .expectHeader().location("/login?authentication_required"); + + verify(roleService, times(2)).listDependenciesFlux(anySet()); } @Test @@ -97,13 +103,19 @@ void anonymousUserAccessAuthenticationFreeApi() { .isOk() .expectBody(String.class).isEqualTo("returned posts"); - verify(roleService).listDependenciesFlux(anySet()); - - webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo").exchange() + webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo") + .header("X-Requested-With", "XMLHttpRequest") + .exchange() .expectStatus() .isUnauthorized(); - verify(roleService, times(2)).listDependenciesFlux(anySet()); + webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo") + .exchange() + .expectStatus() + .isFound() + .expectHeader().location("/login?authentication_required"); + + verify(roleService, times(3)).listDependenciesFlux(anySet()); } @Test diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java index b7966673bf..f504f5904e 100644 --- a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java +++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java @@ -41,14 +41,6 @@ void resolveMessagesForTemplateForEnglish() throws URISyntaxException { "title", "这是来自 i18n/default.properties 的标题")); } - @Test - void messageFormat() { - String s = - ThemeMessageResolutionUtils.formatMessage(Locale.ENGLISH, "Welcome {0} to the index", - new Object[] {"Halo"}); - assertThat(s).isEqualTo("Welcome Halo to the index"); - } - ThemeContext getTheme() throws URISyntaxException { return ThemeContext.builder() .name("default") From 8547ffe6138498ffccd086ac098fbc33f8739314 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Sat, 28 Sep 2024 17:38:32 +0800 Subject: [PATCH 2/2] Add frontend support for customizing login and logout pages Signed-off-by: JohnNiang --- .../src/main/resources/static/images/logo.png | Bin 0 -> 11144 bytes .../main/resources/static/images/wordmark.svg | 1 + .../src/main/resources/static/js/main.js | 154 +++++++ .../src/main/resources/static/styles/main.css | 410 ++++++++++++++++++ .../templates/challenges/two-factor/totp.html | 43 ++ .../challenges/two-factor/totp.properties | 4 + .../challenges/two-factor/totp_en.properties | 4 + .../gateway_modules/common_fragments.html | 110 +++++ .../common_fragments.properties | 1 + .../common_fragments_en.properties | 1 + .../gateway_modules/input_fragments.html | 31 ++ .../templates/gateway_modules/layout.html | 23 + .../gateway_modules/login_fragments.html | 95 ++++ .../login_fragments.properties | 13 + .../login_fragments_en.properties | 13 + .../src/main/resources/templates/login.html | 20 + .../main/resources/templates/login.properties | 1 + .../main/resources/templates/login_email.html | 37 ++ .../resources/templates/login_en.properties | 1 + .../main/resources/templates/login_local.html | 56 +++ .../templates/login_local.properties | 3 + .../templates/login_local_en.properties | 3 + .../src/main/resources/templates/logout.html | 18 + .../resources/templates/logout.properties | 3 + .../resources/templates/logout_en.properties | 3 + .../templates/password-reset-link.html | 39 ++ .../templates/password-reset-link.properties | 5 + .../password-reset-link_en.properties | 5 + .../resources/templates/password-reset.html | 36 ++ .../templates/password-reset.properties | 6 + .../templates/password-reset_en.properties | 6 + .../src/main/resources/templates/signup.html | 202 +++++++++ .../resources/templates/signup.properties | 13 + .../resources/templates/signup_en.properties | 9 + ui/console-src/layouts/BasicLayout.vue | 2 +- ui/console-src/main.ts | 4 +- ui/src/utils/cookie.ts | 4 + ui/uc-src/layouts/BasicLayout.vue | 2 +- ui/uc-src/main.ts | 4 +- ui/uc-src/router/guards/auth-check.ts | 2 +- 40 files changed, 1380 insertions(+), 7 deletions(-) create mode 100644 application/src/main/resources/static/images/logo.png create mode 100644 application/src/main/resources/static/images/wordmark.svg create mode 100644 application/src/main/resources/static/js/main.js create mode 100644 application/src/main/resources/static/styles/main.css create mode 100644 application/src/main/resources/templates/challenges/two-factor/totp.html create mode 100644 application/src/main/resources/templates/challenges/two-factor/totp.properties create mode 100644 application/src/main/resources/templates/challenges/two-factor/totp_en.properties create mode 100644 application/src/main/resources/templates/gateway_modules/common_fragments.html create mode 100644 application/src/main/resources/templates/gateway_modules/common_fragments.properties create mode 100644 application/src/main/resources/templates/gateway_modules/common_fragments_en.properties create mode 100644 application/src/main/resources/templates/gateway_modules/input_fragments.html create mode 100644 application/src/main/resources/templates/gateway_modules/layout.html create mode 100644 application/src/main/resources/templates/gateway_modules/login_fragments.html create mode 100644 application/src/main/resources/templates/gateway_modules/login_fragments.properties create mode 100644 application/src/main/resources/templates/gateway_modules/login_fragments_en.properties create mode 100644 application/src/main/resources/templates/login.html create mode 100644 application/src/main/resources/templates/login.properties create mode 100644 application/src/main/resources/templates/login_email.html create mode 100644 application/src/main/resources/templates/login_en.properties create mode 100644 application/src/main/resources/templates/login_local.html create mode 100644 application/src/main/resources/templates/login_local.properties create mode 100644 application/src/main/resources/templates/login_local_en.properties create mode 100644 application/src/main/resources/templates/logout.html create mode 100644 application/src/main/resources/templates/logout.properties create mode 100644 application/src/main/resources/templates/logout_en.properties create mode 100644 application/src/main/resources/templates/password-reset-link.html create mode 100644 application/src/main/resources/templates/password-reset-link.properties create mode 100644 application/src/main/resources/templates/password-reset-link_en.properties create mode 100644 application/src/main/resources/templates/password-reset.html create mode 100644 application/src/main/resources/templates/password-reset.properties create mode 100644 application/src/main/resources/templates/password-reset_en.properties create mode 100644 application/src/main/resources/templates/signup.html create mode 100644 application/src/main/resources/templates/signup.properties create mode 100644 application/src/main/resources/templates/signup_en.properties create mode 100644 ui/src/utils/cookie.ts diff --git a/application/src/main/resources/static/images/logo.png b/application/src/main/resources/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..135bb98e534133802f1dd50204dae618915673ef GIT binary patch literal 11144 zcmXY1bx_n_wEyldu`KPQh2}r{(CEX%nP!b~2N(xIO-Kf+O z(%rfH{Jr;P?wtGie9mXioIlQ;xpU`68yRR(!dPGc08r{^tKSCz&_4(Qppbvti&C=( z007oA(tDuskHVs^0pnT{mp{P$+JE?e>36{Rzx5B3|Cjy#e-RdW_0RMFIO;zPi@N$p z|I`21|9?*MX#78sJo;b2K=N07Jg2KIe^&ok%b(=!q5H{gjn|wJsp7hu9JE!khsl~y*?ug z-Xja#f%l_-mO%{Fv4cLO%4Uq-yk-qv%^lX$Y;*|LL6sgZD8G0Y;w*f)@goPdv zyUY+djzc5QiJgCudan@K50ku{C-qt)a+-j>z9dQ{kVan+yUdYBo{`2}!lJK19O>&*61REc1Ohbb0-Qu3i@qWY-Y1T|goU0!9DhTc{y<}|iQ}%w z(0e5Ai==VakY`iG@z;=;b4bE9D49U)I!_XP4h=s6CtQK!F60yT7nat+@t0uxNuua8 zP~vq#(+Ww{xk2J;LHkit|0WscXmS1I==iv4^u#pxh&b|ec6OF5j$r<7ziDunCUB=| z?2IOO5AHNk(0NjT-821kN{czLjvmoZ+cW!cKW+Mpid%*NmWzp558gky)M}_J^jHlrIyvBv#t@7n9aelbFtrz z4zFwp@V&aKyu7RAoPD7@bmesaEyHKd@MGQ5NY9!$=}g-)Ekez8#YcQ$Z7lx5^W5*t z9;Epvg~Juyp2A)%P6=Fvvs_#+1(P0)j(bus<(;ri<FYc1M z5c(Cm)+}VSb9y$?)~y{u;@f~pCY5cBiifHa5wTe@)#h^>^T(3>tBtvlj3qF|b9SM%Y9wEzToAus8%5#x;)w?q@IREyuM5eiQlVOXYpX|w_))V%r++s7%4UE3* z?ot%9aP?wN9b^92smZW(t{<59exc5$_9mx5!tsoNER>%&wP2??!<^@Z;E-k50E#JO z*ctNDh@$?JE0uM$7g%0~WMnNfYg#a^oSDD8ZLNhx?}-uDQz3SlflIWMaE{`WwzE4{ z24C~KH_Hm-o5}m{GCY^zxO+?p_GL{-=dbKGOCWfe(-JqI zO=0CSjamQrT<$(ZV~#8KuAE}6aEX@qFT*^cs?$b07uw`-!8%{Xd@a9~qJ8Q2NL%L5 zrL}P`frL4Romtn_tnFkjY!Fr>E2W)km`$^2ZxNFK+<%~-D?{mMC9O%u@@ z2cJTyz?-Rlf12-1hvHX5|{#@Z;kxVY(73zbg5`-w(d2tC8P^JaG|4ioQCp zUeo-}6U83BFd!{ais}#fWWro(^3ghik1ofwu(u(1(l|Xt^3%`9JTb)wFthkG`wx*X za@TX|)3hos@0r|v8}=iQKRE-k9}{&P z==u#S;BvQSmNT>`8Hr`zv@aNKG>?~n-Nj7`U12J4Odb2s?9#GDM_YJh7GkORy`Rx6 zYx-?TIJ_q+tCiwbwt-j?>Q+N%ra#EeP}&ew{XH%AOPJ0k^~IgY%0Ge}jgdcdMS@a! zm@Hk)JJVEp&9o1{F>(f@swg~lw5Rw|b^4jR9~T9#?I<*|(TTPQ(5{;?@tFjk)oI7j zEswdd6z>Y_+N_(x3{>b*gNM$NrL7^aq8rO<`zv)>Z=^akqiJJeEQM+&)(U<<5p2>i zX#O;r1G{9Hq?ENnw0tz_FU(b=tAVmJj`%dXy*3MXa>(4`HAa=Sa@S*Bd4i2YgN~M0 zPCNJ&!KQ%@L$4)kGM=t`|4OAC^iJjUdBT~J!{BugbDtstsMR>DQdgk&=U$YtD{nSa zL5aC$EO?Rq+`-+Q!d21#`66ToNRT|OBkr;ajf*dt4`^3)MZ^u@6KEHeL~ z!AIC&aZpg`_?551>FdW-X}S;bo_IdSuNcML3NT6_k8M^o=aa=V9nl>5uSnu6s)2~X zKw=Z}N?kFnYn2Koa@pZBR2qwU0*Bj*Q!6mEdg}^+SQOC&k04x*f?- zQxb3psO4!!yfUsg!WYY21-owo!6#RkCQUY-gEQ?78EF$Qs`t)mjdx7n^EhKpGF06C-@`j1|RzkbGGzN&kXsH>AP5-J4M>y}Q# z8P0+0r~R2y@{Ke)Dx|c_GDw-$5=q@0TrnoOmQ=(#JtjOE@DE&YwNwto4{>Si%ioVW zUhx8Tn3~}a=tOd85oWkRO#K!TPO#3S5roN0SFwc4%+zV8tT|1V*)sJMPHzZEKBWkF zp8S@f=R=IVP_GVaWK4)b+J4Dmxq-JU#T$_TZ3d9H4Gwlv`dV%Hfa7CMnw2=Ud~$iz zguME@x0fRa>QE1BwrbB(78lc&oE4hcSEAAL>qhLl?J5;9?+snZq{R zH1q^>MQN8?MwU+F5+9u@xSlRT5NHtiLp{SM^9DnEU=dd~sCn1BU^I^_zVA|k<*Y{M)LVwZ6SX(hhjP6yrGy~r`pD3{~?eWLYu zzyXwLvd=DvRlJ;&kLx=V5_TY=SLP7?rym?0PGr#3m<$M0mpG9QFXuOddBb!S(3Z=66oEH;{@cA?do>zeq9tzYOc-1IEe6 z!xqeOghisy>0YRj2MFOV59f~2sG^a@1Dv^^*}!lEBIwCuvMjQ5^PjioF5X)V!=*g} z?A{e43i)z!NQ9$}E$w^U-p}R7NZnp7PjRu2qH|<{+Rk6c-1p_-g-YRoQ+}#;&Y5(c zZ_^@oy?zTn>)KJe9e<*8ZfiM^#ZC~RN`~w!^Z1R`v+>`8IM54NK!zAncmMT~&R5J;NtA?mzdpSGh8$;36%$LTF5mfF`4x z`X~Zk8=Hdj4JTdQ<==pa&hiC}w!GUy$#c4Xi2o{XLmAFLrM=@RC#rSF)@dXY@)5j% znzrRk!Cgi7ZMYk;5|gcAmidH2Tch-gEl01OviY>WUskIT~ zL4_DeB1JE{7F3kfb@*cFNBS9wm9^qEa4CIvgrur4WnqATnWtfYt;VH?`g=~$E55ZB z_eWI3DUt5fHJo)VH~ZO~%%cW0?lf*_`Wi_faHmNS{g^iq!zF7j3I6f@(hsV!+F#(d z5Bm!bo%xgGaewz#D(2_sOfj*qo5J7aPS<|TfAMMk@AiD|S1N8dOWs_4H51BfN8gDX z!#NgY80KH>uL58Ur!UQ<*Z=4&Sb_D%&16BJL8<^`*@AT z+gS^pMQ`y1yD{d_j_j$p-xvj7k@p_-bQUCG5-_4mhKA|x%OSV2D?_E=H@1trU>#5V@nf{L`bStzyU`8j z0NH0ZG@sBD5~t*SecC?<$kAMo=jtOXS#Wxx5D@l}QKyHml%=MExu%32y+Yb}~xf5ngCN&ciJU5(#OFSvf#)!R>{LIai z$H{ajAdeRrw)&mN6^=T}6*_x&5O*)>;qZ5)mk~sJhF&N{LZXXFweTZ!RYw^#)P%Vr z+O8i-ayru-_*Sh)OVoPJ_l%QC7|K#C)Qn`H)li4jcc4_nrIf@?RETLMB*v5xOlOCs z8)B7aD-qh#}T^#iN|0? z&WuA>h0&!1pwg(VD)ovU_J-e{8x`}579)x)yJUgJ5edUi4Mk&MWXq8DbCUFliszql8?47SVMsEn z+c~svIPbsSp@Yi$Y|4EKUuz^cim+2Y=R|;DWuF`AEr^mBq4swR^7-%HTPM$vqhZcH zJB;xWV?u|lf~HswUgweMYkhig6C|pODHcF}AitM9EAW)N;|ND3uDBlGJ-r$E^K{wtbho9yojPHJ_PtT5q%1eC1rW@`P_`P)( zzSzC)%id4Dvecb*Nl2sV`_dVupN6F6s`&Ahfk8>)1$EiD{CNcr(V~4*s($f=mf~aZ{+Sk#n!#XB?hex zG$V1nDIFfrqz|or`R;mLAFkjQL_J^O#Hd?;%qlD~sl}QfUN5ua&xtSCS=5=H$b!yx zmb8V>h%K^qk<_2!-hBl-x)oHv=^~e={O*S#Ov)A_kI`>3pqIbTJue{a5GMg7qpAE* zEeugw3XK6_uYgF0lzehAxP(*31?l`HI z6&p{&N|Z!CPGfey$q1hzm4q@~&#F^!BOJL0QAGhZe{2jfGme|31wA;~sBlXVCk1!` zwCzoE^ArOO?W;Po`n3uu;7ltmF*zLaY{QZ9KdK(JPrU$R6z?5YZ*(YGViVPy=U^|W z0JJ)wE5rjXSyZVu9-TymkpklKj5@nT_3AI&3D*2TMgM)&X*Y2am-PC=0(<2WXqkfs z-HDv00Dp&d-N7bBsx&6Iq+ffQAd9=>2k_VmCs<$v!kQ2CiJ#Oq!9GTV=2ryZvx9{; zEv3EHIDMhTu|aM$oAZz6hX6PLj&u!*nVSYmtvXM7}_jEA~|Xn#zwA1Hn~5@A;Dz zSGhki>D+ei^rwIhQ+p+q%oOWT1o5mvaZO)X?U;nGo`U0MC@y6+AMsN_QhGTJ4E1wN|y9#lN9;cnlW=7KDDD=I|2C)^4mT!n&vuy4#fWmp%-bMpg zW)#|Fs>E{GkGt7DB7kTrvQCb&Rla=r>n#%FW47D+CEp%m#%z{}bCHUIf0jX4=gv@I zB?ih&L!ybd)P`PA+AA>kaIvznM*$}5H*eWK1r6`b~ybfv-3N85IGdMNwgEHoTbczyoac{A+4*zNB@=@*E#i&CIFzuc!pYF zpuxp5>v)=6sJjKFV7#+JFJFaplW*(?eQ-r7aR-F>yIzPHagWmZJH`r3vmb`1tuF8& zwZ$fnsNLlTE!-+R>hSg2bXu(?&7GSd-{!Z&r-BC|4Gd4FxdApT1eZSG?#Z;KBfcPq z8>|KLJ^6NK!+@XlotffYm44xZr7#-O&uu=7Hu|tyKg?k{fpJFZq3n0Ir$lC5^;@71 zO%~9-FaIW^=%*!!svY|l!L(EE)|H8uy%J}8dz8)t!MVnqoiYtvFP!|ox9rRkKmOF> zV{h5efhZn;3FKh8eKoeFr{`tRBp0LNzfB zKb83!@6u5=e;ShbF~}OTsue7w)O_?1%Y|vnw)CT_NOWuO>Io5nFnJP7U&!ookr2hg z_WNGePb>~W5lsqbX-w19o7;;Eq_qc0Tlo2CvzI2^` z)y^illp|5-;P4lI&KMsI$+f+ zTF`_ugz8phc_pQ^tJ~%-J^_%2-7y0&@#lt{ZrN#W);oqX_IIDug`n~Auu@M(X6*9&>PD54RQ)386qT(K*<0+2*v^584lnT37x9GTOp~blbU!0#bp6 zE6$HTlH`fce4)+tCEnJf>sDQqnP7$}(XAbdf2s_Q{;YbxH*n4~K}uvo z{ge9Tvu6jZZL5k71}nNpa30pD2(4I9Cs_ttm>Xl#d`#H76!MIB&1HHdkl3hA%$Mcf z!Y=N#NSPk+KU+N(259M8-*xAb%lGi5vZCn42~>L@3<*bjrjZI(A@HEj7PGF~=jB>C z51wM9rGrS=f@xoKz}b{q z*->9HLFt?sCqq7Na-7Z8*;41l0@=ADhg@+XKPe|-n(!jYDb}nv0(GxVuQE?PkPBMr zFiC_7COL#^f30#X$!)oPow7$v)khF>0vqoz6P2&LCaVeBnEaBx;;wa97f7} z!uw%tY(lnDjW1DRhrkNqRuyY4?=K3;W&pyKORRxOuEkjaly2e52Np$y<3=3sI~%is$}iu(UWr-ph)7p zULPzUver8DTHxxk1^v(u;oF<0t8@DIc7OT6JNkoA&yr>wMddps$c>gCkT5)_r*-wb z3DU5|fTZ+KN#0j%xdh^Y*l$NEzTpfKYNuyfzas)8+5=bT?SRTeFw;3YUoVjWZAm9+ z6ep17Y{`(aqp$+fKR_IZaPdiHyfO$mKSWTtWCl5?RS?F1v+RkYi4X*`7U7CttJ#Eg zZsjUmwfRoF;o58{>A=6V@!xqN)VeNUfq#7Tt-i$fJQ|S=DF^fWO652W{sQ){Da}fQ zYpymuK}P$ABMvkH4+sUD>tiGXI|u1S9J=XGIkY53@#GLu+2+ytcCn+JmU+K3&tp$W z>6zF={5w+pYBIK@!DHf!LM9! z9P0Bi9QB538GGLI7>inI$xoKe8hzbI`Iq@5Sad12U~G-0n$lW{nxz7AGk5?$_kv?X z`f@t$>owJx$x*f-DDLA~`TSrU-_ylVH*YrFmeacIU`^uPtw>7WBeU6W-HDLrUUp}s zN`W^ek!J;OVzP@`@(iyD=9l8wwV;zRRP8~_tT^QRW~M~ld#VDs@-O%aFVvu>F zKNwQDMR7Mk~+#jlml(#|bBIK1}xAZ26KvJUy@D=}Ox$%Gt(Zc6b$iaMV}Z|c3Le0!lQ zYRlc(OvAfNCP1+G(_lq0B4eiANRKghHR(@NDY)?q$HBT%*|-UbK>&P<_&*yKCdz`ZKXo^8_-I`LpNTD-%1`h3Y$rCBqYDDIt#uWnBgODJ1 zH+qzJmCNysdfj}#`mA43D{0W|8$W!=wC5x3j5)~WMn;%0fm>+%SO>p75B|wKynvjN zfZ!iTrtDU2_ymrYpEzutTs2nl|0KBXms7~5tx=|A4(46OMHN~f&|}80X1|yY=WA0p zjt+(2{Jc+F`90m*obI8y2u_R;4H`44EvhXVn{Ig{qnWb9AZh5oUIvK*Av4&4u z``P@r1-5OSr%`{VMS=>+5fA11@>xT`4YM)7KVLp|vg5Fb>6;6zQNFrwNj^87y)1Sw zE9aCVqZ=z}id&n1$-ncH1t4G3_Hw2V;@v~KOj)YYVUV1XNxEbELcx0w9zHst0K@K(L7o^UdXsT zQr{;S4!`5-2i6WtYBtykd^5;#vPiiDy>B~=>6XM)ru_LnLAaREP`i*oN(SC{6Kjnu z>K-N70EwaPtIfl&)G3)OZZrRgv96D7326o~ofQZdm!7958YbxfJB(&~>dXAk<{q{G zo29Q`TxxG!eReEDDq1JAm3wqAK~8A&O-KbAOHiFk`ej~*K#18)t#8`Z~?_S+?VI4|M)xJ*$| zK6O;Q&L^csP=h@FboJ$o6+7Q%A*;6^vU<_Y$yFwx!|jY zQc&;(LaMgh>N9?Dt@_d0jZl8exqdlT*H$E$I-RCmcGVjdft+j^^O9Z{*nf z+}DhwL3F712&7`lmp5)`JL~4zMm*kqxa_X@v#t;d5=vs)GOkkXPWR7-uQq6amz7PD zjXlZEQ7ca`$-p>VqV16PPAj74i_7=88vql3z$^E#b))|KmTeyPa1Jjr3|pOpQ!_7E9-#r?Y<+s$o2Xlv(5H8xZAC_IgxSaF^^Em2;`;AuMi1djv^Qa z|L&x31-^H3AaqoYz9`buch|4W3lZ1TY?t>BKd4j~79tV7fm9*bW(~V^ z$iZMsnd>`h^FPWTM0iM2XrRpcp2@Z^XXPTI$uDU+Qri9`MDRkfC}b@vydEXfEdU;W z72S84?9D+qG9{g!5&2T7pvPTi24^6va4u^V*TMXuYAW}_?z}bhA^90mRsv{@OmEQT zI1}-Y_&jXw72xml|D#(z%79wp>>ilt5(M`b+Dg8w=iK&zVW@KsLVM}S z`^CHLiKl_omo{ymlf%7(L2(Jy&z@RNV$jt~Q0-NqISh8qq0mwO(CR~lbl1W62eRQz z!rIaGFeQ5^w3u>+oH(i_f=7!=FfMm6>D8W3GO#Ak4pcZK+O|9bIiAOb*RJ1zes2(N zAgbe55>Wu>0SO_`-D`b5vxq7lJtx~^-d2PB;IruCZe{?R9ZFiwS%~g`qXE7`9zI;! z6N`Ak2{gD}w9H$}|5LGjtJdraW-jw|2y2?O+P4)?_+Z$xCxvB_mIRWsjpGk*cZvW0vz5X4zkd?%yWFpH6%l3bN>ac~{SwvRYCku8(Ze zHUI9`<_sL0VGqw9q{oG2D-2G7(#y#m=36S97H4-4=~LL734}|+?b&|Q?hUY=%^-Hr zATgvpR#whIoOwt6JzJ18o5p+YEiz{ty}}Or5R&O97!BeV3pHB-yxOu{)im6%6XG~4 z5P+s-J*6(T!YqAO+=!yxEb=aSyZJ4Oq>8(G;qI}YxHLNl#Y;&;qtCQGDg;7U z!~y@0kgyw{)Ih7Yy4_33Z{9Cu&{aj;foOa-Qn=Inm?*h7y#8zc%91FX+zO`JytR?@ zwoq)g{Tgqn%Jdl(4WqIJg~j!i^h1r>@brr7r$fcBZ8-VUin1j*zf4utK#7>eWG0Yn;#{K zX^vXo^RWu_%O2f&F0G-w*#u!uM8*5j@i^5+IP|i`+Ip?{lxUJ6u;E!YoS#+WS9I>L zuz)*Bc^1f495b-`)TzM|jkpx}Cm#O@^Zie)m&I4q?o_n3!S*#4fv37lcZ0jUOPHM9 zmH6vR6l2Cdtm+t$}upFD6Ys9uQHcQ3gq&bUgnC2P_6Y(#LHP4#m4sx zyZkiJu0GU4C@pTj|C?f>n!gp+B%dOAR~1z`#XU=_&q{$x?dQZ<=So0+jP_qB%lpf< zEP8&JqMWCiSbEZ0YWZ$zf$wXTF|9I}>uSWl1v~RMStvxsH%gUGJ?2Oy=*gZUfiPwH z!%1@7cc8VEzTZLrnGfGIw>+@@NJ0c?c^F4JTsM|tzu+LAaB1=yR`e> zToifl=8ACr4UgF0ZT6;(1NAahROtTz DOTHoo literal 0 HcmV?d00001 diff --git a/application/src/main/resources/static/images/wordmark.svg b/application/src/main/resources/static/images/wordmark.svg new file mode 100644 index 0000000000..be75721541 --- /dev/null +++ b/application/src/main/resources/static/images/wordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/src/main/resources/static/js/main.js b/application/src/main/resources/static/js/main.js new file mode 100644 index 0000000000..2db2e606b1 --- /dev/null +++ b/application/src/main/resources/static/js/main.js @@ -0,0 +1,154 @@ +const Toast = (function () { + let container; + + function getContainer() { + if (container) return container; + + container = document.createElement("div"); + container.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + `; + + if (document.body) { + document.body.appendChild(container); + } else { + document.addEventListener("DOMContentLoaded", () => { + document.body.appendChild(container); + }); + } + + return container; + } + + class ToastMessage { + constructor(message, type) { + this.message = message; + this.type = type; + this.element = null; + this.create(); + } + + create() { + this.element = document.createElement("div"); + this.element.textContent = this.message; + this.element.style.cssText = ` + background-color: ${this.type === "success" ? "#4CAF50" : "#F44336"}; + color: white; + padding: 12px 24px; + border-radius: 4px; + margin-bottom: 10px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + opacity: 0; + transition: opacity 0.3s ease-in-out; + `; + getContainer().appendChild(this.element); + + setTimeout(() => { + this.element.style.opacity = "1"; + }, 10); + + setTimeout(() => { + this.remove(); + }, 3000); + } + + remove() { + this.element.style.opacity = "0"; + setTimeout(() => { + const parent = this.element.parentNode; + if (parent) { + parent.removeChild(this.element); + } + }, 300); + } + } + + function showToast(message, type) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + new ToastMessage(message, type); + }); + } else { + new ToastMessage(message, type); + } + } + + return { + success: function (message) { + showToast(message, "success"); + }, + error: function (message) { + showToast(message, "error"); + }, + }; +})(); + +function sendVerificationCode(button, sendRequest) { + let timer; + const countdown = 60; + + button.addEventListener("click", () => { + button.disabled = true; + sendRequest() + .then(() => { + startCountdown(); + Toast.success("发送成功"); + }) + .catch((e) => { + button.disabled = false; + if (e instanceof Error) { + Toast.error(e.message); + } else { + Toast.error("发送失败,请稍后再试"); + } + }); + }); + + function startCountdown() { + let remainingTime = countdown; + button.disabled = true; + button.classList.add("disabled"); + + timer = setInterval(() => { + if (remainingTime > 0) { + button.textContent = `${remainingTime}s`; + remainingTime--; + } else { + clearInterval(timer); + button.textContent = "Send"; + button.disabled = false; + button.classList.remove("disabled"); + } + }, 1000); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const passwordContainers = document.querySelectorAll(".toggle-password-display-flag"); + + passwordContainers.forEach((container) => { + const passwordInput = container.querySelector('input[type="password"]'); + const toggleButton = container.querySelector(".toggle-password-button"); + const displayIcon = container.querySelector(".password-display-icon"); + const hiddenIcon = container.querySelector(".password-hidden-icon"); + + if (passwordInput && toggleButton && displayIcon && hiddenIcon) { + toggleButton.addEventListener("click", () => { + if (passwordInput.type === "password") { + passwordInput.type = "text"; + displayIcon.style.display = "none"; + hiddenIcon.style.display = "block"; + } else { + passwordInput.type = "password"; + displayIcon.style.display = "block"; + hiddenIcon.style.display = "none"; + } + }); + } + }); +}); + diff --git a/application/src/main/resources/static/styles/main.css b/application/src/main/resources/static/styles/main.css new file mode 100644 index 0000000000..9d062b0007 --- /dev/null +++ b/application/src/main/resources/static/styles/main.css @@ -0,0 +1,410 @@ +/* Base */ +.gateway-page { + width: 100vw; + height: 100vh; + background-color: #f5f5f5; + overflow: auto; +} + +.gateway-wrapper, +.gateway-wrapper:before, +.gateway-wrapper:after { + box-sizing: border-box; + border-width: 0; + border-style: solid; +} + +.gateway-wrapper *, +.gateway-wrapper *:before, +.gateway-wrapper *:after { + box-sizing: border-box; + border-width: 0; + border-style: solid; +} + +.gateway-wrapper { + --color-primary: #4ccba0; + --color-secondary: #0e1731; + --color-link: #1f75cb; + --color-text: #374151; + --color-border: #d1d5db; + --rounded-sm: 0.125em; + --rounded-base: 0.25em; + --rounded-lg: 0.5em; + --spacing-xl: 1.25em; + --spacing-lg: 1em; + --spacing-md: 0.875em; + --spacing-sm: 0.5em; + --text-md: 0.875em; +} + +.gateway-wrapper { + margin: 0 auto; + max-width: 28em; + padding: 5% 1em; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Helvetica Neue, + Arial, + Noto Sans, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + Segoe UI Symbol, + "Noto Color Emoji"; +} + +/* Form */ +.halo-form-wrapper { + border-radius: var(--rounded-lg); + background: #fff; + padding: 1.5em; +} + +.form-title { + all: unset; + margin-bottom: 1em; + display: block; + font-weight: 500; + font-size: 1.75em; +} + +.halo-form .form-item { + display: flex; + flex-direction: column; + margin-bottom: 1.3em; + width: 100%; +} + +.halo-form .form-item:last-child { + margin-bottom: 0; +} + +.halo-form .form-item-group { + gap: var(--spacing-lg); + display: flex; + align-items: center; + margin-bottom: 1.3em; +} + +.halo-form .form-item-group .form-item { + margin-bottom: 0; +} + +.halo-form .form-input { + border-radius: var(--rounded-base); + border: 1px solid var(--color-border); + height: 2.5em; + background: #fff; + padding: 0 0.75rem; +} + +.halo-form .form-input:focus-within { + border-color: var(--color-primary); + outline: 2px solid transparent; + outline-offset: "2px"; +} + +.halo-form .form-item input { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + display: block; + font-size: 1em; + box-shadow: none; + width: 100%; + height: 100%; + background: transparent; +} + +.halo-form .form-item input:focus { + outline: none; +} + +.halo-form .form-input-stack { + display: flex; + align-items: center; + gap: 0.5em; +} + +.halo-form .form-input-stack-icon { + display: inline-flex; + align-items: center; + color: var(--color-text); + cursor: pointer; +} + +.halo-form .form-input-stack-select { + all: unset; + color: var(--color-text); + font-size: var(--text-md); + padding-right: 1.85em; + display: inline-flex; + align-items: center; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.3em center; +} + +.halo-form .form-input-stack-text { + color: var(--color-text); + font-size: var(--text-md); +} + +.halo-form .form-item label { + color: var(--color-text); + margin-bottom: 0.5em; +} + +.halo-form .form-item .form-label-group { + margin-bottom: 0.5em; + display: flex; + justify-content: space-between; + align-items: center; +} + +.halo-form .form-item .form-label-group label { + margin-bottom: 0; +} + +.halo-form .form-item-extra-link { + color: var(--color-link); + font-size: var(--text-md); + text-decoration: none; +} + +.halo-form .form-item-compact { + gap: var(--spacing-sm); + margin-bottom: 1.5em; + display: flex; + align-items: center; +} + +.halo-form .form-item-compact label { + color: var(--color-text); + font-size: var(--text-md); +} + +.halo-form button[type="submit"] { + background: var(--color-secondary); + border-radius: var(--rounded-base); + height: 2.5em; + color: #fff; + border: none; + cursor: pointer; +} + +.halo-form button[type="submit"]:hover { + opacity: 0.8; +} + +.halo-form button[type="submit"]:active { + opacity: 0.9; +} + +.halo-form button[disabled] { + cursor: not-allowed !important; +} + +.halo-form input[type="checkbox"] { + border: 1px solid var(--color-border); + border-radius: var(--rounded-sm); + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding: 0; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + user-select: none; + flex-shrink: 0; + height: 1em; + width: 1em; + color: #2563eb; + background-color: #fff; +} + +.halo-form input[type="checkbox"]:focus { + outline: 2px solid transparent; + outline-offset: 2px; + box-shadow: + rgb(255, 255, 255) 0px 0px 0px 2px, + rgb(37, 99, 235) 0px 0px 0px 4px, + rgba(0, 0, 0, 0) 0px 0px 0px 0px; +} + +.halo-form input[type="checkbox"]:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +.halo-form .form-input-group { + gap: var(--spacing-sm); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: center; +} + +.halo-form .form-input { + grid-column: span 2 / span 2; +} + +.halo-form .form-input-group button { + border-radius: var(--rounded-base); + border: 1px solid var(--color-border); + color: var(--color-text); + font-size: var(--text-md); + grid-column: span 1 / span 1; + height: 100%; + cursor: pointer; + background: #fff; +} + +.halo-form .form-input-group button:hover { + color: #333; + background: #f3f4f6; +} + +.halo-form .form-input-group button:active { + background: #f9fafb; +} + +.auth-provider-items { + all: unset; + gap: var(--spacing-md); + margin: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +.auth-provider-items li { + all: unset; + border-radius: var(--rounded-lg); + overflow: hidden; + border: 1px solid #e5e7eb; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 0.15s; +} + +.auth-provider-items li a { + gap: var(--spacing-sm); + padding: 0.7em 1em; + display: flex; + align-items: center; + color: #1f2937; + text-decoration: none; + font-size: 0.8em; +} + +.auth-provider-items li img { + width: 1.5em; + height: 1.5em; +} + +.auth-provider-items li:hover { + border-color: var(--color-primary); + background: #f3f4f6; +} + +.auth-provider-items li:hover a { + color: #111827; +} + +.auth-provider-items li:focus-within { + border-color: var(--color-primary); +} + +.divider-wrapper { + color: var(--color-text); + font-size: var(--text-md); + gap: var(--spacing-lg); + display: flex; + align-items: center; + margin: 1.5em 0; +} + +.divider-wrapper hr { + flex-grow: 1; + overflow: hidden; + border: 0; + border-top: 1px solid #f3f4f6; +} + +.alert { + border: 1px solid #e5e7eb; + border-radius: var(--rounded-base); + margin-bottom: var(--spacing-xl); + padding: var(--spacing-md) var(--spacing-xl); + font-size: var(--text-md); + overflow: hidden; + position: relative; + color: var(--color-text); +} + +.alert::before { + content: ""; + position: absolute; + height: 100%; + left: 0; + background: #d1d5db; + width: 0.25em; + top: 0; +} + +.alert-warning { + border-color: #fde047; +} + +.alert-warning::before { + background: #ea580c; +} + +.alert-error { + border-color: #fca5a5; +} + +.alert-error::before { + background: #dc2626; +} + +.alert-success { + border-color: #86efac; +} + +.alert-success::before { + background: #16a34a; +} + +.alert-info { + border-color: #7dd3fc; +} + +.alert-info::before { + background: #0284c7; +} + +@media (forced-colors: active) { + .halo-form input[type="checkbox"]:checked { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +@media only screen and (max-width: 768px) { + .halo-form .form-item-group { + flex-direction: column; + } +} diff --git a/application/src/main/resources/templates/challenges/two-factor/totp.html b/application/src/main/resources/templates/challenges/two-factor/totp.html new file mode 100644 index 0000000000..96baa8d8c2 --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp.html @@ -0,0 +1,43 @@ + + + +
+
+
+

+
+ +
+ +
+ +
+
+
+ +
+
+
+
+
+ diff --git a/application/src/main/resources/templates/challenges/two-factor/totp.properties b/application/src/main/resources/templates/challenges/two-factor/totp.properties new file mode 100644 index 0000000000..46f96ed978 --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp.properties @@ -0,0 +1,4 @@ +title=两步验证 +messages.invalidError=错误的验证码 +form.code.label=验证码 +form.submit=验证 diff --git a/application/src/main/resources/templates/challenges/two-factor/totp_en.properties b/application/src/main/resources/templates/challenges/two-factor/totp_en.properties new file mode 100644 index 0000000000..bffc96863a --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp_en.properties @@ -0,0 +1,4 @@ +title=Two-Factor Authentication +messages.invalidError=Invalid TOTP code +form.code.label=TOTP Code +form.submit=Verify diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments.html b/application/src/main/resources/templates/gateway_modules/common_fragments.html new file mode 100644 index 0000000000..75d718ca33 --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/common_fragments.html @@ -0,0 +1,110 @@ + + + + + + + + + +
+ +
+ + + +
+
+ +
+ + +
+ +
+ +
+
+ +
+
+ +
+
diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments.properties b/application/src/main/resources/templates/gateway_modules/common_fragments.properties new file mode 100644 index 0000000000..6617dd0d7b --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/common_fragments.properties @@ -0,0 +1 @@ +socialLogin.label=社交登录 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments_en.properties b/application/src/main/resources/templates/gateway_modules/common_fragments_en.properties new file mode 100644 index 0000000000..a10abbb87c --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/common_fragments_en.properties @@ -0,0 +1 @@ +socialLogin.label=Social Login \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_modules/input_fragments.html b/application/src/main/resources/templates/gateway_modules/input_fragments.html new file mode 100644 index 0000000000..ffffb5cd6a --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/input_fragments.html @@ -0,0 +1,31 @@ +
+
+ + +
+ + + + + +
+
+
diff --git a/application/src/main/resources/templates/gateway_modules/layout.html b/application/src/main/resources/templates/gateway_modules/layout.html new file mode 100644 index 0000000000..126838d12a --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/layout.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments.html b/application/src/main/resources/templates/gateway_modules/login_fragments.html new file mode 100644 index 0000000000..87e8fe64ca --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/login_fragments.html @@ -0,0 +1,95 @@ + +
+ + + + +
+ +
+ + +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+ + + +
diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments.properties b/application/src/main/resources/templates/gateway_modules/login_fragments.properties new file mode 100644 index 0000000000..5752dae82f --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/login_fragments.properties @@ -0,0 +1,13 @@ +messages.loginError=无效的凭证。 +messages.logoutSuccess=登出成功。 +messages.signupSuccess=恭喜!注册成功,请立即登录。 + +error.invalid-credential=无效的凭证。 +error.rate-limit-exceeded=请求过于频繁,请稍后再试。 + +form.rememberMe.label=保持登录会话 +form.submit=登录 +otherLogin.label=其他登录方式 +signup.description=没有账号? +signup.link=立即注册 +returnToSite=返回网站 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties b/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties new file mode 100644 index 0000000000..d3b5b33f46 --- /dev/null +++ b/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties @@ -0,0 +1,13 @@ +messages.loginError=Invalid credentials. +messages.logoutSuccess=Logout successfully. +messages.signupSuccess=Congratulations! Sign up successfully, please sign in now. + +error.invalid-credential=Invalid credentials. +error.rate-limit-exceeded=Too many requests, please try again later. + +form.rememberMe.label=Remember me +form.submit=Login +otherLogin.label=Other Login +signup.description=Don't have an account? +signup.link=Sign up +returnToSite=Return to site \ No newline at end of file diff --git a/application/src/main/resources/templates/login.html b/application/src/main/resources/templates/login.html new file mode 100644 index 0000000000..47d8084593 --- /dev/null +++ b/application/src/main/resources/templates/login.html @@ -0,0 +1,20 @@ + + + +
+
+ +
+
+
+
+
+ +
+
+
+
+ \ No newline at end of file diff --git a/application/src/main/resources/templates/login.properties b/application/src/main/resources/templates/login.properties new file mode 100644 index 0000000000..26367c07cb --- /dev/null +++ b/application/src/main/resources/templates/login.properties @@ -0,0 +1 @@ +title=登录 \ No newline at end of file diff --git a/application/src/main/resources/templates/login_email.html b/application/src/main/resources/templates/login_email.html new file mode 100644 index 0000000000..cbdda6d645 --- /dev/null +++ b/application/src/main/resources/templates/login_email.html @@ -0,0 +1,37 @@ +
+
+ + +
+ +
+
+
+ +
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/application/src/main/resources/templates/login_en.properties b/application/src/main/resources/templates/login_en.properties new file mode 100644 index 0000000000..eb0443eed6 --- /dev/null +++ b/application/src/main/resources/templates/login_en.properties @@ -0,0 +1 @@ +title=Login diff --git a/application/src/main/resources/templates/login_local.html b/application/src/main/resources/templates/login_local.html new file mode 100644 index 0000000000..3ae24d4001 --- /dev/null +++ b/application/src/main/resources/templates/login_local.html @@ -0,0 +1,56 @@ +
+ + + +
+ + +
+ +
+
+
+
+ + + +
+ + +
+
diff --git a/application/src/main/resources/templates/login_local.properties b/application/src/main/resources/templates/login_local.properties new file mode 100644 index 0000000000..8163bfd74d --- /dev/null +++ b/application/src/main/resources/templates/login_local.properties @@ -0,0 +1,3 @@ +form.username.label=用户名 +form.password.label=密码 +form.password.forgot=忘记密码? diff --git a/application/src/main/resources/templates/login_local_en.properties b/application/src/main/resources/templates/login_local_en.properties new file mode 100644 index 0000000000..d0fbb09017 --- /dev/null +++ b/application/src/main/resources/templates/login_local_en.properties @@ -0,0 +1,3 @@ +form.username.label=Username +form.password.label=Password +form.password.forgot=Forgot your password? diff --git a/application/src/main/resources/templates/logout.html b/application/src/main/resources/templates/logout.html new file mode 100644 index 0000000000..cafcd1b7f4 --- /dev/null +++ b/application/src/main/resources/templates/logout.html @@ -0,0 +1,18 @@ + + + +
+
+

+
+
+ +
+
+
+
+
+ \ No newline at end of file diff --git a/application/src/main/resources/templates/logout.properties b/application/src/main/resources/templates/logout.properties new file mode 100644 index 0000000000..4ff553242b --- /dev/null +++ b/application/src/main/resources/templates/logout.properties @@ -0,0 +1,3 @@ +title=退出登录 +form.title=确定要退出登录吗? +form.submit=退出登录 diff --git a/application/src/main/resources/templates/logout_en.properties b/application/src/main/resources/templates/logout_en.properties new file mode 100644 index 0000000000..3ad1587f0b --- /dev/null +++ b/application/src/main/resources/templates/logout_en.properties @@ -0,0 +1,3 @@ +title=Logout +form.title=Are you sure want to log out? +form.submit=Logout \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset-link.html b/application/src/main/resources/templates/password-reset-link.html new file mode 100644 index 0000000000..2a40d4d81b --- /dev/null +++ b/application/src/main/resources/templates/password-reset-link.html @@ -0,0 +1,39 @@ + + + +
+
+
+

+

+
+
+ + +
+
+ + +
+
+

+
+
+ +
+
+
+
+
+ \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset-link.properties b/application/src/main/resources/templates/password-reset-link.properties new file mode 100644 index 0000000000..a401763b38 --- /dev/null +++ b/application/src/main/resources/templates/password-reset-link.properties @@ -0,0 +1,5 @@ +title=为 {0} 修改密码 +form.password.label=密码 +form.confirmPassword.label=确认密码 +form.password.tips=密码必须至少包含 8 个字符,并且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。 +form.submit=修改密码 diff --git a/application/src/main/resources/templates/password-reset-link_en.properties b/application/src/main/resources/templates/password-reset-link_en.properties new file mode 100644 index 0000000000..3075f3b839 --- /dev/null +++ b/application/src/main/resources/templates/password-reset-link_en.properties @@ -0,0 +1,5 @@ +title=Change password for @{0} +form.password.label=Password +form.confirmPassword.label=Confirm Password +form.password.tips=Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character. +form.submit=Change password diff --git a/application/src/main/resources/templates/password-reset.html b/application/src/main/resources/templates/password-reset.html new file mode 100644 index 0000000000..3e198e1427 --- /dev/null +++ b/application/src/main/resources/templates/password-reset.html @@ -0,0 +1,36 @@ + + + +
+
+
+

+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+ diff --git a/application/src/main/resources/templates/password-reset.properties b/application/src/main/resources/templates/password-reset.properties new file mode 100644 index 0000000000..f008656fcf --- /dev/null +++ b/application/src/main/resources/templates/password-reset.properties @@ -0,0 +1,6 @@ +title=重置密码 +form.email.label=电子邮箱 +form.submit=提交 +sent.form.submit=返回到登录页面 +sent.form.message=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。 +sent.title=已发送重置密码的邮件 \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset_en.properties b/application/src/main/resources/templates/password-reset_en.properties new file mode 100644 index 0000000000..36555ac691 --- /dev/null +++ b/application/src/main/resources/templates/password-reset_en.properties @@ -0,0 +1,6 @@ +title=Reset password +form.email.label=Email +form.submit=Submit +sent.form.submit=Return to login +sent.form.message=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder. +sent.title=Password reset email has been sent \ No newline at end of file diff --git a/application/src/main/resources/templates/signup.html b/application/src/main/resources/templates/signup.html new file mode 100644 index 0000000000..01186b5ae3 --- /dev/null +++ b/application/src/main/resources/templates/signup.html @@ -0,0 +1,202 @@ + + + + + + + + + + \ No newline at end of file diff --git a/application/src/main/resources/templates/signup.properties b/application/src/main/resources/templates/signup.properties new file mode 100644 index 0000000000..ce6a56c6d9 --- /dev/null +++ b/application/src/main/resources/templates/signup.properties @@ -0,0 +1,13 @@ +title=注册 +form.username.label=用户名 +form.displayName.label=名称 +form.email.label=电子邮箱 +form.emailCode.label=邮箱验证码 +form.emailCode.sendButton=发送 +form.password.label=密码 +form.confirmPassword.label=确认密码 +form.submit=注册 + +error.invalid-email-code=无效的邮箱验证码 +error.duplicate-name=用户名已经被注册 +error.rate-limit-exceeded=请求过于频繁,请稍后再试 diff --git a/application/src/main/resources/templates/signup_en.properties b/application/src/main/resources/templates/signup_en.properties new file mode 100644 index 0000000000..bdbb222060 --- /dev/null +++ b/application/src/main/resources/templates/signup_en.properties @@ -0,0 +1,9 @@ +title=Sign up +form.username.label=Username +form.displayName.label=Display name +form.email.label=Email +form.emailCode.label=Email Code +form.emailCode.sendButton=Send +form.password.label=Password +form.confirmPassword.label=Confirm password +form.submit=Sign up \ No newline at end of file diff --git a/ui/console-src/layouts/BasicLayout.vue b/ui/console-src/layouts/BasicLayout.vue index b1fc71be74..1e6ffcbf6a 100644 --- a/ui/console-src/layouts/BasicLayout.vue +++ b/ui/console-src/layouts/BasicLayout.vue @@ -57,7 +57,7 @@ const handleLogout = () => { document.cookie = "XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; - router.replace({ name: "Login" }); + window.location.href = "/login"; } catch (error) { console.error("Failed to logout", error); } diff --git a/ui/console-src/main.ts b/ui/console-src/main.ts index e05d17c4d2..45ee188188 100644 --- a/ui/console-src/main.ts +++ b/ui/console-src/main.ts @@ -14,6 +14,7 @@ import { setupVueQuery } from "@/setup/setupVueQuery"; import { useGlobalInfoStore } from "@/stores/global-info"; import { useRoleStore } from "@/stores/role"; import { useUserStore } from "@/stores/user"; +import { getCookie } from "@/utils/cookie"; import { hasPermission } from "@/utils/permission"; import { setupCoreModules, @@ -78,8 +79,7 @@ async function initApp() { await userStore.fetchCurrentUser(); // set locale - i18n.global.locale.value = - localStorage.getItem("locale") || getBrowserLanguage(); + i18n.global.locale.value = getCookie("language") || getBrowserLanguage(); const globalInfoStore = useGlobalInfoStore(); await globalInfoStore.fetchGlobalInfo(); diff --git a/ui/src/utils/cookie.ts b/ui/src/utils/cookie.ts new file mode 100644 index 0000000000..5b376aa443 --- /dev/null +++ b/ui/src/utils/cookie.ts @@ -0,0 +1,4 @@ +export function getCookie(name: string) { + const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)")); + return match ? match[2] : null; +} diff --git a/ui/uc-src/layouts/BasicLayout.vue b/ui/uc-src/layouts/BasicLayout.vue index 5db0136d32..be079413a3 100644 --- a/ui/uc-src/layouts/BasicLayout.vue +++ b/ui/uc-src/layouts/BasicLayout.vue @@ -53,7 +53,7 @@ const handleLogout = () => { document.cookie = "XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; - window.location.href = "/console/login"; + window.location.href = "/login"; } catch (error) { console.error("Failed to logout", error); } diff --git a/ui/uc-src/main.ts b/ui/uc-src/main.ts index 0715438433..d79ac85364 100644 --- a/ui/uc-src/main.ts +++ b/ui/uc-src/main.ts @@ -6,6 +6,7 @@ import { setupVueQuery } from "@/setup/setupVueQuery"; import { useGlobalInfoStore } from "@/stores/global-info"; import { useRoleStore } from "@/stores/role"; import { useUserStore } from "@/stores/user"; +import { getCookie } from "@/utils/cookie"; import { hasPermission } from "@/utils/permission"; import { consoleApiClient } from "@halo-dev/api-client"; import router from "@uc/router"; @@ -66,8 +67,7 @@ async function initApp() { await userStore.fetchCurrentUser(); // set locale - i18n.global.locale.value = - localStorage.getItem("locale") || getBrowserLanguage(); + i18n.global.locale.value = getCookie("language") || getBrowserLanguage(); const globalInfoStore = useGlobalInfoStore(); await globalInfoStore.fetchGlobalInfo(); diff --git a/ui/uc-src/router/guards/auth-check.ts b/ui/uc-src/router/guards/auth-check.ts index 322a7be26b..9ff1c02d53 100644 --- a/ui/uc-src/router/guards/auth-check.ts +++ b/ui/uc-src/router/guards/auth-check.ts @@ -13,7 +13,7 @@ export function setupAuthCheckGuard(router: Router) { const userStore = useUserStore(); if (userStore.isAnonymous) { - window.location.href = `/console/login?redirect_uri=${encodeURIComponent( + window.location.href = `/login?redirect_uri=${encodeURIComponent( window.location.href )}`; return;