mirror of https://github.com/halo-dev/halo
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
parent
d63eaed10f
commit
98a131309c
|
@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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=您的请求过于频繁,请稍后再试。
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
|
@ -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=您的請求過於頻繁,請稍後再試。
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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=重置密码令牌无效。请重试。
|
||||
|
|
|
@ -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.
|
||||
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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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=重置密碼連結無效。請重試。
|
||||
|
|
Loading…
Reference in New Issue