Add globalInfo into templates model (#6823)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.20.x

#### What this PR does / why we need it:

This PR adds globalInfo into template models and refactors password reset to adapt data binding.

Fixes https://github.com/halo-dev/halo/issues/6821

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/6829/head v2.20.0-rc.1
John Niang 2024-10-11 17:51:06 +08:00 committed by GitHub
parent d63eaed10f
commit 98a131309c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 174 additions and 137 deletions

View File

@ -2,28 +2,35 @@ 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 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<ServerResponse> preAuthPasswordResetEndpoints() {
RouterFunction<ServerResponse> 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<String, Object>();
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 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(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 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());
}
<T> RateLimiterOperator<T> rateLimiterForSendPasswordResetEmail(ServerHttpRequest request) {
var clientIp = IpAddressUtils.getClientIp(request);
var rateLimiterKey = "send-password-reset-email-from-" + clientIp;
@ -198,4 +187,29 @@ class PreAuthEmailPasswordResetEndpoint {
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;
}
}

View File

@ -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 @@ import org.springframework.web.reactive.function.server.ServerResponse;
class PreAuthTwoFactorEndpoint {
@Bean
RouterFunction<ServerResponse> preAuthTwoFactorEndpoints() {
RouterFunction<ServerResponse> 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();
}

View File

@ -3,21 +3,24 @@
class="halo-form"
th:action="@{/password-reset/email/{resetToken}(resetToken=${resetToken})}"
method="post"
th:object="${form}"
>
<div class="alert alert-error" role="alert" th:if="${error}">
<strong th:text="${error}"></strong>
<strong th:if="${error == 'rate_limit_exceeded'}" th:text="#{error.rate_limit_exceeded}"></strong>
</div>
<div class="form-item">
<label for="password" th:text="#{form.password.label}">Password</label>
<th:block
th:replace="~{gateway_fragments/input :: password(id = 'password', name = 'password', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
<p class="alert alert-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
</div>
<div class="form-item">
<label for="confirmPassword" th:text="#{form.confirmPassword.label}">Confirm Password</label>
<th:block
th:replace="~{gateway_fragments/input :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
<p class="alert alert-error" th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}"></p>
</div>
<div class="form-item">
<div class="alert" th:text="#{form.password.tips}"></div>

View File

@ -1,4 +1,5 @@
form.password.label=密码
form.confirmPassword.label=确认密码
form.password.tips=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&*。
form.submit=修改密码
form.submit=修改密码
error.rate_limit_exceeded=您的请求过于频繁,请稍后再试。

View File

@ -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
form.submit=Change password
error.rate_limit_exceeded=Your request is too frequent, please try again later.

View File

@ -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
form.submit=Cambiar Contraseña
error.rate_limit_exceeded=Su solicitud es demasiado frecuente, por favor intente nuevamente más tarde.

View File

@ -1,4 +1,5 @@
form.password.label=密碼
form.confirmPassword.label=確認密碼
form.password.tips=密碼只能使用大小寫字母 (A-Z, a-z)、數字 (0-9),以及以下特殊字符: !@#$%^&*。
form.submit=修改密碼
form.submit=修改密碼
error.rate_limit_exceeded=您的請求過於頻繁,請稍後再試。

View File

@ -7,18 +7,22 @@
<button type="submit" th:text="#{form.sent.submit}"></button>
</div>
</form>
<form th:unless="${sent}" class="halo-form" th:action="@{/password-reset/email}" method="post">
<form th:unless="${sent}" class="halo-form" th:action="@{/password-reset/email}" method="post" th:object="${form}">
<div class="alert alert-error" th:if="${param.error.size() > 0}">
<strong th:if="${param.error[0] == 'invalid_reset_token'}" th:text="#{error.invalid_reset_token}"></strong>
</div>
<div class="alert alert-error" th:if="${error}">
<strong th:text="${error}"></strong>
<strong th:if="${error == 'rate_limit_exceeded'}" th:text="#{error.rate_limit_exceeded}"></strong>
</div>
<div class="form-item">
<label for="email" th:text="#{form.email.label}"></label>
<div class="form-input">
<input type="email" id="email" name="email" autofocus required />
<input type="email" id="email" name="email" autofocus required th:field="*{email}"/>
</div>
<p class="alert alert-error" role="alert" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}"></button>
</div>
</form>
</th:block>
</th:block>

View File

@ -1,4 +1,6 @@
form.email.label=电子邮箱
form.submit=提交
form.sent.submit=返回到登录页面
form.message.success=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
form.message.success=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
error.rate_limit_exceeded=您的请求速度太快。请稍后再试。
error.invalid_reset_token=重置密码令牌无效。请重试。

View File

@ -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 doesnt appear within a few minutes, check your spam folder.
form.message.success=Check your email for a link to reset your password. If it doesnt 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.

View File

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

View File

@ -1,4 +1,6 @@
form.email.label=電子郵件
form.submit=提交
form.sent.submit=返回到登入頁面
form.message.success=檢查您的電子郵件中是否有重置密碼的連結。如果幾分鐘內沒有出現,請檢查您的垃圾郵件資料夾。
form.message.success=檢查您的電子郵件中是否有重置密碼的連結。如果幾分鐘內沒有出現,請檢查您的垃圾郵件資料夾。
error.rate_limit_exceeded=您的請求過於頻繁。請稍後再試。
error.invalid_reset_token=重置密碼連結無效。請重試。