diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java index 3bdee33b01..1054fc2367 100644 --- a/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java @@ -2,28 +2,35 @@ import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static org.springframework.web.reactive.function.server.RequestPredicates.path; +import static run.halo.app.infra.ValidationUtils.validate; 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 jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.net.URI; -import java.util.Locale; +import java.util.HashMap; import java.util.Map; import java.util.Objects; -import java.util.Optional; -import org.apache.commons.lang3.StringUtils; +import lombok.Data; 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.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.EmailPasswordRecoveryService; import run.halo.app.core.user.service.InvalidResetTokenException; -import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.infra.utils.IpAddressUtils; /** @@ -38,151 +45,133 @@ class PreAuthEmailPasswordResetEndpoint { private static final String SEND_TEMPLATE = "password-reset/email/send"; private static final String RESET_TEMPLATE = "password-reset/email/reset"; - private final EmailPasswordRecoveryService emailPasswordRecoveryService; - - private final MessageSource messageSource; - private final RateLimiterRegistry rateLimiterRegistry; - private final PasswordResetAvailabilityProviders passwordResetAvailabilityProviders; - public PreAuthEmailPasswordResetEndpoint( - EmailPasswordRecoveryService emailPasswordRecoveryService, - MessageSource messageSource, - RateLimiterRegistry rateLimiterRegistry, - PasswordResetAvailabilityProviders passwordResetAvailabilityProviders + RateLimiterRegistry rateLimiterRegistry ) { - this.emailPasswordRecoveryService = emailPasswordRecoveryService; - this.messageSource = messageSource; this.rateLimiterRegistry = rateLimiterRegistry; - this.passwordResetAvailabilityProviders = passwordResetAvailabilityProviders; } @Bean - RouterFunction preAuthPasswordResetEndpoints() { + RouterFunction preAuthPasswordResetEndpoints( + GlobalInfoService globalInfoService, + PasswordResetAvailabilityProviders availabilityProviders, + MessageSource messageSource, + EmailPasswordRecoveryService emailService, + Validator validator + ) { return RouterFunctions.nest(path("/password-reset/email"), RouterFunctions.route() - .GET("", request -> { - return ServerResponse.ok().render(SEND_TEMPLATE, Map.of( - "otherMethods", - passwordResetAvailabilityProviders.getOtherAvailableMethods("email") - )); - }) + .GET("", request -> request.bind(SendForm.class) + .flatMap(sendForm -> ServerResponse.ok().render(SEND_TEMPLATE, Map.of( + "otherMethods", availabilityProviders.getOtherAvailableMethods("email"), + "globalInfo", globalInfoService.getGlobalInfo(), + "form", sendForm + ))) + ) .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(RESET_TEMPLATE, Map.of( - "username", resetToken.username() - )); - }) - .onErrorResume(InvalidResetTokenException.class, - e -> ServerResponse.status(HttpStatus.FOUND) - .location( - URI.create("/password-reset/email?error=invalid_reset_token") - ) - .build() + return request.bind(ResetForm.class) + .flatMap(resetForm -> { + var model = new HashMap(); + model.put("form", resetForm); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + return emailService.getValidResetToken(token) + .flatMap(resetToken -> { + // TODO Check the 2FA of the user + model.put("username", resetToken.username()); + return ServerResponse.ok().render(RESET_TEMPLATE, model); + }) .transformDeferred(rateLimiterForPasswordResetVerification( request.exchange().getRequest() )) - .onErrorMap( - RequestNotPermitted.class, RateLimitExceededException::new + .onErrorResume(InvalidResetTokenException.class, e -> + ServerResponse.status(HttpStatus.FOUND) + .location(URI.create( + "/password-reset/email?error=invalid_reset_token") + ) + .build() ) - ); + .onErrorResume(RequestNotPermitted.class, e -> { + model.put("error", "rate_limit_exceeded"); + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .render(RESET_TEMPLATE, model); + }); + }); } ) .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(RESET_TEMPLATE, 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(RESET_TEMPLATE, Map.of( - "error", error - )); - } - return emailPasswordRecoveryService.changePassword(password, token) - .then(ServerResponse.status(HttpStatus.FOUND) - .location(URI.create("/login?password_reset")) - .build() - ) - .onErrorResume(InvalidResetTokenException.class, e -> { - var error = messageSource.getMessage( - "passwordReset.resetToken.invalid", - null, - "Invalid reset token", - locale - ); - return ServerResponse.ok().render(RESET_TEMPLATE, 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 request.bind(ResetForm.class) + .flatMap(resetForm -> emailService.getValidResetToken(token) + .flatMap(resetToken -> { + var bindingResult = validate(resetForm, validator, request.exchange()); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + model.put("username", resetToken.username()); + if (!Objects.equals( + resetForm.getPassword(), resetForm.getConfirmPassword() + )) { + bindingResult.rejectValue( + "confirmPassword", + "validation.error.password.confirmPassword.mismatch", + "Password and confirm password mismatch" ); - return ServerResponse.ok().render(SEND_TEMPLATE, Map.of( - "error", error - )); } - return emailPasswordRecoveryService.sendPasswordResetEmail(email) - .then(ServerResponse.ok().render(SEND_TEMPLATE, Map.of( - "sent", true - ))) - .transformDeferred(rateLimiterForSendPasswordResetEmail( + if (bindingResult.hasErrors()) { + return ServerResponse.badRequest().render(RESET_TEMPLATE, model); + } + return emailService.changePassword(resetForm.getPassword(), token) + .then(ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/login?password_reset")) + .build() + ) + .transformDeferred(rateLimiterForPasswordResetVerification( request.exchange().getRequest() )) - .onErrorMap( - RequestNotPermitted.class, RateLimitExceededException::new - ); - }); - }) + .onErrorResume(RequestNotPermitted.class, e -> { + model.put("error", "rate_limit_exceeded"); + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .render(RESET_TEMPLATE, model); + }); + }) + .onErrorResume(InvalidResetTokenException.class, + e -> ServerResponse.status(HttpStatus.FOUND) + .location(URI.create( + "/password-reset/email?error=invalid_reset_token" + )) + .build() + ) + ); + }) + .POST("", contentType(MediaType.APPLICATION_FORM_URLENCODED), + request -> request.bind(SendForm.class) + .flatMap(sendForm -> { + // validate the send form + var bindingResult = validate(sendForm, validator, request.exchange()); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + if (bindingResult.hasErrors()) { + return ServerResponse.badRequest().render(SEND_TEMPLATE, model); + } + return emailService.sendPasswordResetEmail(sendForm.getEmail()) + .then(Mono.defer(() -> { + model.put("sent", true); + return ServerResponse.ok().render(SEND_TEMPLATE, model); + })) + .transformDeferred(rateLimiterForSendPasswordResetEmail( + request.exchange().getRequest() + )) + .onErrorResume(RequestNotPermitted.class, e -> { + model.put("error", "rate_limit_exceeded"); + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .render(SEND_TEMPLATE, model); + }); + })) .build()); } - RateLimiterOperator rateLimiterForSendPasswordResetEmail(ServerHttpRequest request) { var clientIp = IpAddressUtils.getClientIp(request); var rateLimiterKey = "send-password-reset-email-from-" + clientIp; @@ -198,4 +187,29 @@ RateLimiterOperator rateLimiterForPasswordResetVerification(ServerHttpReq rateLimiterRegistry.rateLimiter(rateLimiterKey, "password-reset-verification"); return RateLimiterOperator.of(rateLimiter); } + + @Data + static class ResetForm { + + @NotBlank + @Pattern( + regexp = ValidationUtils.PASSWORD_REGEX, + message = "{validation.error.password.pattern}" + ) + @Size(min = 5, max = 257) + private String password; + + @NotBlank + private String confirmPassword; + + } + + @Data + static class SendForm { + + @NotBlank + @Email + private 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 index 8fea7bdd14..08ac8052b9 100644 --- a/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java @@ -1,10 +1,12 @@ package run.halo.app.security.preauth; +import java.util.Map; 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 run.halo.app.infra.actuator.GlobalInfoService; /** * Pre-auth two-factor endpoints. @@ -16,10 +18,12 @@ class PreAuthTwoFactorEndpoint { @Bean - RouterFunction preAuthTwoFactorEndpoints() { + RouterFunction preAuthTwoFactorEndpoints(GlobalInfoService globalInfoService) { return RouterFunctions.route() .GET("/challenges/two-factor/totp", - request -> ServerResponse.ok().render("challenges/two-factor/totp") + request -> ServerResponse.ok().render("challenges/two-factor/totp", Map.of( + "globalInfo", globalInfoService.getGlobalInfo() + )) ) .build(); } diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html index 231a10990b..b3ce4824b1 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html @@ -3,21 +3,24 @@ class="halo-form" th:action="@{/password-reset/email/{resetToken}(resetToken=${resetToken})}" method="post" + th:object="${form}" >
+

+

diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties index 978712db88..b3bfbf83db 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties @@ -1,4 +1,5 @@ form.password.label=密码 form.confirmPassword.label=确认密码 form.password.tips=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&*。 -form.submit=修改密码 \ No newline at end of file +form.submit=修改密码 +error.rate_limit_exceeded=您的请求过于频繁,请稍后再试。 diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties index cbeb3eb213..89efb84ce0 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties @@ -1,4 +1,5 @@ form.password.label=Password form.confirmPassword.label=Confirm Password form.password.tips=The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !@#$%^&* -form.submit=Change password \ No newline at end of file +form.submit=Change password +error.rate_limit_exceeded=Your request is too frequent, please try again later. diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties index 7bc35afcb2..3d3f065905 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties @@ -1,4 +1,5 @@ form.password.label=Contraseña form.confirmPassword.label=Confirmar Contraseña form.password.tips=La contraseña solo puede usar letras mayúsculas y minúsculas (A-Z, a-z), números (0-9) y los siguientes caracteres especiales: !@#$%^&*. -form.submit=Cambiar Contraseña \ No newline at end of file +form.submit=Cambiar Contraseña +error.rate_limit_exceeded=Su solicitud es demasiado frecuente, por favor intente nuevamente más tarde. \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties index b39dcdd7b5..3667f441a6 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties @@ -1,4 +1,5 @@ form.password.label=密碼 form.confirmPassword.label=確認密碼 form.password.tips=密碼只能使用大小寫字母 (A-Z, a-z)、數字 (0-9),以及以下特殊字符: !@#$%^&*。 -form.submit=修改密碼 \ No newline at end of file +form.submit=修改密碼 +error.rate_limit_exceeded=您的請求過於頻繁,請稍後再試。 diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html index ed9264ad2f..ef20ffc11c 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html @@ -7,18 +7,22 @@
-
+ +
+ +
- +
- +
+
- \ No newline at end of file + diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties index 5c4461f2a6..3541f91c6d 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties @@ -1,4 +1,6 @@ form.email.label=电子邮箱 form.submit=提交 form.sent.submit=返回到登录页面 -form.message.success=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。 \ No newline at end of file +form.message.success=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。 +error.rate_limit_exceeded=您的请求速度太快。请稍后再试。 +error.invalid_reset_token=重置密码令牌无效。请重试。 diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties index 62151a89b4..61f10e07fc 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties @@ -1,4 +1,6 @@ form.email.label=Email form.submit=Submit form.sent.submit=Return to login -form.message.success=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder. \ No newline at end of file +form.message.success=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder. +error.rate_limit_exceeded=You are making requests too quickly. Please try again later. +error.invalid_reset_token=The reset password token is invalid. Please try again. diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties index 0944f68310..56606e3f39 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties @@ -1,4 +1,6 @@ form.email.label=Correo Electrónico form.submit=Enviar form.sent.submit=Volver a la Página de Inicio de Sesión -form.message.success=Revisa tu correo electrónico para ver el enlace de restablecimiento de contraseña. Si no aparece en unos minutos, revisa tu carpeta de spam. \ No newline at end of file +form.message.success=Revisa tu correo electrónico para ver el enlace de restablecimiento de contraseña. Si no aparece en unos minutos, revisa tu carpeta de spam. +error.rate_limit_exceeded=Se ha superado el límite de intentos de restablecimiento de contraseña. Por favor, inténtalo de nuevo más tarde. +error.invalid_reset_token=El enlace de restablecimiento de contraseña no es válido o ha expirado. Por favor, solicita un nuevo enlace. diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties index f908f1299c..ccd1bcf335 100644 --- a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties @@ -1,4 +1,6 @@ form.email.label=電子郵件 form.submit=提交 form.sent.submit=返回到登入頁面 -form.message.success=檢查您的電子郵件中是否有重置密碼的連結。如果幾分鐘內沒有出現,請檢查您的垃圾郵件資料夾。 \ No newline at end of file +form.message.success=檢查您的電子郵件中是否有重置密碼的連結。如果幾分鐘內沒有出現,請檢查您的垃圾郵件資料夾。 +error.rate_limit_exceeded=您的請求過於頻繁。請稍後再試。 +error.invalid_reset_token=重置密碼連結無效。請重試。