mirror of https://github.com/halo-dev/halo
feat: support reset passwords based on email address (#4941)
#### What type of PR is this? /kind feature /area core /area console /milestone 2.11.x #### What this PR does / why we need it: 新增使用邮箱地址找回密码功能 #### Which issue(s) this PR fixes: Fixes #4940 #### Does this PR introduce a user-facing change? ```release-note 新增使用邮箱地址找回密码功能 ```pull/4968/head
parent
a5639a8733
commit
abd049719d
|
@ -0,0 +1,38 @@
|
|||
package run.halo.app.core.extension.service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
|
||||
/**
|
||||
* An interface for email password recovery.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.11.0
|
||||
*/
|
||||
public interface EmailPasswordRecoveryService {
|
||||
|
||||
/**
|
||||
* <p>Send password reset email.</p>
|
||||
* if the user does not exist, it will return {@link Mono#empty()}
|
||||
* if the user exists, but the email is not the same, it will return {@link Mono#empty()}
|
||||
*
|
||||
* @param username username to request password reset
|
||||
* @param email email to match the user with the username
|
||||
* @return {@link Mono#empty()} if the user does not exist, or the email is not the same.
|
||||
*/
|
||||
Mono<Void> sendPasswordResetEmail(String username, String email);
|
||||
|
||||
/**
|
||||
* <p>Reset password by token.</p>
|
||||
* 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<Void> changePassword(String username, String newPassword, String token);
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
package run.halo.app.core.extension.service.impl;
|
||||
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
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.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
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.extension.service.EmailPasswordRecoveryService;
|
||||
import run.halo.app.core.extension.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;
|
||||
|
||||
/**
|
||||
* A default implementation for {@link EmailPasswordRecoveryService}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.11.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService {
|
||||
public static final int MAX_ATTEMPTS = 5;
|
||||
public static final long LINK_EXPIRATION_MINUTES = 30;
|
||||
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;
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendPasswordResetEmail(String username, String email) {
|
||||
return client.fetch(User.class, username)
|
||||
.flatMap(user -> {
|
||||
var userEmail = user.getSpec().getEmail();
|
||||
if (!StringUtils.equals(userEmail, email)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
if (!user.getSpec().isEmailVerified()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return sendResetPasswordNotification(username, email);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> changePassword(String username, String newPassword, String token) {
|
||||
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
|
||||
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();
|
||||
}
|
||||
|
||||
Mono<Void> unSubscribeResetPasswordEmailNotification(String email) {
|
||||
if (StringUtils.isBlank(email)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
|
||||
return notificationCenter.unsubscribe(subscriber, createInterestReason(email))
|
||||
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
|
||||
.filter(OptimisticLockingFailureException.class::isInstance));
|
||||
}
|
||||
|
||||
Mono<Void> 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);
|
||||
}
|
||||
|
||||
Mono<Void> autoSubscribeResetPasswordEmailNotification(String email) {
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
|
||||
var interestReason = createInterestReason(email);
|
||||
return notificationCenter.subscribe(subscriber, interestReason)
|
||||
.then();
|
||||
}
|
||||
|
||||
Subscription.InterestReason createInterestReason(String email) {
|
||||
var interestReason = new Subscription.InterestReason();
|
||||
interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_REASON_TYPE);
|
||||
interestReason.setSubject(Subscription.ReasonSubject.builder()
|
||||
.apiVersion(new GroupVersion(User.GROUP, User.KIND).toString())
|
||||
.kind(User.KIND)
|
||||
.name(UserIdentity.anonymousWithEmail(email).name())
|
||||
.build());
|
||||
return interestReason;
|
||||
}
|
||||
|
||||
private String getResetPasswordLink(String username, String token) {
|
||||
return externalLinkProcessor.processLink(
|
||||
"/uc/reset-password/" + username + "?reset_password_token=" + token);
|
||||
}
|
||||
|
||||
static class ResetPasswordVerificationManager {
|
||||
private final Cache<String, Verification> userTokenCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES)
|
||||
.maximumSize(10000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, Boolean>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,14 +2,18 @@ package run.halo.app.theme.endpoint;
|
|||
|
||||
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.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;
|
||||
|
@ -20,9 +24,11 @@ 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.extension.service.EmailPasswordRecoveryService;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
|
@ -40,6 +46,7 @@ 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;
|
||||
|
||||
@Override
|
||||
|
@ -55,9 +62,91 @@ public class PublicUserEndpoint implements CustomEndpoint {
|
|||
)
|
||||
.response(responseBuilder().implementation(User.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<ServerResponse> 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) {
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> 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());
|
||||
}
|
||||
|
||||
<T> RateLimiterOperator<T> 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");
|
||||
|
|
|
@ -99,3 +99,7 @@ resilience4j.ratelimiter:
|
|||
limitForPeriod: 3
|
||||
limitRefreshPeriod: 1h
|
||||
timeoutDuration: 0s
|
||||
send-reset-password-email:
|
||||
limitForPeriod: 2
|
||||
limitRefreshPeriod: 1m
|
||||
timeoutDuration: 0s
|
||||
|
|
|
@ -126,3 +126,30 @@ spec:
|
|||
<p>如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。</p>
|
||||
</div>
|
||||
</div>
|
||||
---
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: NotificationTemplate
|
||||
metadata:
|
||||
name: template-reset-password-by-email
|
||||
spec:
|
||||
reasonSelector:
|
||||
reasonType: reset-password-by-email
|
||||
language: default
|
||||
template:
|
||||
title: "重置密码-[(${site.title})]"
|
||||
rawBody: |
|
||||
【[(${site.title})]】你已经请求了重置密码,可以链接来重置密码:[(${link})],请在 [(${expirationAtMinutes})] 分钟内完成重置。
|
||||
htmlBody: |
|
||||
<div class="notification-content">
|
||||
<div class="head">
|
||||
<p class="honorific" th:text="|${username} 你好:|"></p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>你已经请求了重置密码,可以点击下面的链接来重置密码:</p>
|
||||
<div class="reset-link" style="line-height:24px;">
|
||||
<span th:text="${link}"></span>
|
||||
</div>
|
||||
<p th:text="|链接有效期为 ${expirationAtMinutes} 分钟,请尽快完成重置。|"></p>
|
||||
<p>如果您没有请求重置密码,请忽略此电子邮件。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -163,3 +163,23 @@ spec:
|
|||
- name: expirationAtMinutes
|
||||
type: string
|
||||
description: "The expiration minutes of the verification code, such as 5 minutes."
|
||||
---
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: ReasonType
|
||||
metadata:
|
||||
name: reset-password-by-email
|
||||
labels:
|
||||
halo.run/hide: "true"
|
||||
spec:
|
||||
displayName: "根据邮件地址重置密码"
|
||||
description: "当你通过邮件地址找回密码时,会收到一条带密码重置链接的邮件,你需要点击邮件中的链接来重置密码。"
|
||||
properties:
|
||||
- name: username
|
||||
type: string
|
||||
description: "The username of the user."
|
||||
- name: link
|
||||
type: string
|
||||
description: "The reset link."
|
||||
- name: expirationAtMinutes
|
||||
type: string
|
||||
description: "The expiration minutes of the reset link, such as 30 minutes."
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
package run.halo.app.core.extension.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.infra.exception.RateLimitExceededException;
|
||||
|
||||
/**
|
||||
* Tests for {@link EmailPasswordRecoveryServiceImpl}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.11.0
|
||||
*/
|
||||
@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\"");
|
||||
}
|
||||
}
|
|
@ -52,27 +52,32 @@ watch(
|
|||
<SignupForm v-if="type === 'signup'" @succeed="onSignupSucceed" />
|
||||
<LoginForm v-else @succeed="onLoginSucceed" />
|
||||
<SocialAuthProviders />
|
||||
<div
|
||||
v-if="globalInfo?.allowRegistration"
|
||||
class="flex justify-center gap-1 pt-3.5 text-xs"
|
||||
>
|
||||
<span class="text-slate-500">
|
||||
{{
|
||||
isLoginType
|
||||
? $t("core.login.operations.signup.label")
|
||||
: $t("core.login.operations.return_login.label")
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="cursor-pointer text-secondary hover:text-gray-600"
|
||||
@click="handleChangeType"
|
||||
<div class="flex justify-center gap-2 pt-3.5 text-xs">
|
||||
<div v-if="globalInfo?.allowRegistration" class="space-x-0.5">
|
||||
<span class="text-slate-500">
|
||||
{{
|
||||
isLoginType
|
||||
? $t("core.login.operations.signup.label")
|
||||
: $t("core.login.operations.return_login.label")
|
||||
}},
|
||||
</span>
|
||||
<span
|
||||
class="cursor-pointer text-secondary hover:text-gray-600"
|
||||
@click="handleChangeType"
|
||||
>
|
||||
{{
|
||||
isLoginType
|
||||
? $t("core.login.operations.signup.button")
|
||||
: $t("core.login.operations.return_login.button")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="{ name: 'ResetPassword' }"
|
||||
class="text-secondary hover:text-gray-600"
|
||||
>
|
||||
{{
|
||||
isLoginType
|
||||
? $t("core.login.operations.signup.button")
|
||||
: $t("core.login.operations.return_login.button")
|
||||
}}
|
||||
</span>
|
||||
{{ $t("core.login.operations.reset_password.button") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex justify-center pt-3.5">
|
||||
<a
|
||||
|
|
|
@ -2,7 +2,7 @@ import { rbacAnnotations } from "@/constants/annotations";
|
|||
import { useUserStore } from "@/stores/user";
|
||||
import type { Router } from "vue-router";
|
||||
|
||||
const whiteList = ["Setup", "Login", "Binding"];
|
||||
const whiteList = ["Setup", "Login", "Binding", "ResetPassword"];
|
||||
|
||||
export function setupAuthCheckGuard(router: Router) {
|
||||
router.beforeEach((to, from, next) => {
|
||||
|
|
|
@ -5,6 +5,7 @@ import BasicLayout from "@console/layouts/BasicLayout.vue";
|
|||
import Setup from "@console/views/system/Setup.vue";
|
||||
import Redirect from "@console/views/system/Redirect.vue";
|
||||
import SetupInitialData from "@console/views/system/SetupInitialData.vue";
|
||||
import ResetPassword from "@console/views/system/ResetPassword.vue";
|
||||
|
||||
export const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
|
@ -44,6 +45,14 @@ export const routes: Array<RouteRecordRaw> = [
|
|||
name: "Redirect",
|
||||
component: Redirect,
|
||||
},
|
||||
{
|
||||
path: "/reset-password",
|
||||
name: "ResetPassword",
|
||||
component: ResetPassword,
|
||||
meta: {
|
||||
title: "core.reset_password.title",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { Toast, VButton } from "@halo-dev/components";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface ResetPasswordForm {
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
async function onSubmit(data: ResetPasswordForm) {
|
||||
try {
|
||||
loading.value = true;
|
||||
await apiClient.common.user.sendPasswordResetEmail({
|
||||
passwordResetEmailRequest: {
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
},
|
||||
});
|
||||
|
||||
Toast.success(t("core.reset_password.operations.send.toast_success"));
|
||||
} catch (error) {
|
||||
console.error("Failed to send password reset email", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const inputClasses = {
|
||||
outer: "!py-3 first:!pt-0 last:!pb-0",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
|
||||
<IconLogo class="mb-8" />
|
||||
<div class="flex w-72 flex-col">
|
||||
<FormKit
|
||||
id="reset-password-form"
|
||||
name="reset-password-form"
|
||||
type="form"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="onSubmit"
|
||||
@keyup.enter="$formkit.submit('reset-password-form')"
|
||||
>
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="username"
|
||||
:placeholder="$t('core.reset_password.fields.username.label')"
|
||||
:validation-label="$t('core.reset_password.fields.username.label')"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
validation="required"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="email"
|
||||
:placeholder="$t('core.reset_password.fields.email.label')"
|
||||
:validation-label="$t('core.reset_password.fields.email.label')"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
validation="required"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
class="mt-8"
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
@click="$formkit.submit('reset-password-form')"
|
||||
>
|
||||
{{ $t("core.reset_password.operations.send.label") }}
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -186,6 +186,7 @@ models/notifier-descriptor.ts
|
|||
models/notifier-info.ts
|
||||
models/notifier-setting-ref.ts
|
||||
models/owner-info.ts
|
||||
models/password-reset-email-request.ts
|
||||
models/pat-spec.ts
|
||||
models/personal-access-token-list.ts
|
||||
models/personal-access-token.ts
|
||||
|
@ -232,6 +233,7 @@ models/reply-spec.ts
|
|||
models/reply-vo-list.ts
|
||||
models/reply-vo.ts
|
||||
models/reply.ts
|
||||
models/reset-password-request.ts
|
||||
models/reverse-proxy-list.ts
|
||||
models/reverse-proxy-rule.ts
|
||||
models/reverse-proxy.ts
|
||||
|
|
|
@ -38,6 +38,10 @@ import {
|
|||
RequiredError,
|
||||
} from "../base";
|
||||
// @ts-ignore
|
||||
import { PasswordResetEmailRequest } from "../models";
|
||||
// @ts-ignore
|
||||
import { ResetPasswordRequest } from "../models";
|
||||
// @ts-ignore
|
||||
import { SignUpRequest } from "../models";
|
||||
// @ts-ignore
|
||||
import { User } from "../models";
|
||||
|
@ -49,6 +53,136 @@ export const ApiHaloRunV1alpha1UserApiAxiosParamCreator = function (
|
|||
configuration?: Configuration
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Reset password by token
|
||||
* @param {string} name The name of the user
|
||||
* @param {ResetPasswordRequest} resetPasswordRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
resetPasswordByToken: async (
|
||||
name: string,
|
||||
resetPasswordRequest: ResetPasswordRequest,
|
||||
options: AxiosRequestConfig = {}
|
||||
): Promise<RequestArgs> => {
|
||||
// verify required parameter 'name' is not null or undefined
|
||||
assertParamExists("resetPasswordByToken", "name", name);
|
||||
// verify required parameter 'resetPasswordRequest' is not null or undefined
|
||||
assertParamExists(
|
||||
"resetPasswordByToken",
|
||||
"resetPasswordRequest",
|
||||
resetPasswordRequest
|
||||
);
|
||||
const localVarPath =
|
||||
`/apis/api.halo.run/v1alpha1/users/{name}/reset-password`.replace(
|
||||
`{${"name"}}`,
|
||||
encodeURIComponent(String(name))
|
||||
);
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = {
|
||||
method: "PUT",
|
||||
...baseOptions,
|
||||
...options,
|
||||
};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication BasicAuth required
|
||||
// http basic authentication required
|
||||
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||
|
||||
localVarHeaderParameter["Content-Type"] = "application/json";
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions =
|
||||
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {
|
||||
...localVarHeaderParameter,
|
||||
...headersFromBaseOptions,
|
||||
...options.headers,
|
||||
};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(
|
||||
resetPasswordRequest,
|
||||
localVarRequestOptions,
|
||||
configuration
|
||||
);
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Send password reset email when forgot password
|
||||
* @param {PasswordResetEmailRequest} passwordResetEmailRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
sendPasswordResetEmail: async (
|
||||
passwordResetEmailRequest: PasswordResetEmailRequest,
|
||||
options: AxiosRequestConfig = {}
|
||||
): Promise<RequestArgs> => {
|
||||
// verify required parameter 'passwordResetEmailRequest' is not null or undefined
|
||||
assertParamExists(
|
||||
"sendPasswordResetEmail",
|
||||
"passwordResetEmailRequest",
|
||||
passwordResetEmailRequest
|
||||
);
|
||||
const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/send-password-reset-email`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = {
|
||||
method: "POST",
|
||||
...baseOptions,
|
||||
...options,
|
||||
};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication BasicAuth required
|
||||
// http basic authentication required
|
||||
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||
|
||||
localVarHeaderParameter["Content-Type"] = "application/json";
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions =
|
||||
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {
|
||||
...localVarHeaderParameter,
|
||||
...headersFromBaseOptions,
|
||||
...options.headers,
|
||||
};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(
|
||||
passwordResetEmailRequest,
|
||||
localVarRequestOptions,
|
||||
configuration
|
||||
);
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Sign up a new user
|
||||
* @param {SignUpRequest} signUpRequest
|
||||
|
@ -119,6 +253,57 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
|
|||
const localVarAxiosParamCreator =
|
||||
ApiHaloRunV1alpha1UserApiAxiosParamCreator(configuration);
|
||||
return {
|
||||
/**
|
||||
* Reset password by token
|
||||
* @param {string} name The name of the user
|
||||
* @param {ResetPasswordRequest} resetPasswordRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async resetPasswordByToken(
|
||||
name: string,
|
||||
resetPasswordRequest: ResetPasswordRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<
|
||||
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
|
||||
> {
|
||||
const localVarAxiosArgs =
|
||||
await localVarAxiosParamCreator.resetPasswordByToken(
|
||||
name,
|
||||
resetPasswordRequest,
|
||||
options
|
||||
);
|
||||
return createRequestFunction(
|
||||
localVarAxiosArgs,
|
||||
globalAxios,
|
||||
BASE_PATH,
|
||||
configuration
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Send password reset email when forgot password
|
||||
* @param {PasswordResetEmailRequest} passwordResetEmailRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async sendPasswordResetEmail(
|
||||
passwordResetEmailRequest: PasswordResetEmailRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<
|
||||
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
|
||||
> {
|
||||
const localVarAxiosArgs =
|
||||
await localVarAxiosParamCreator.sendPasswordResetEmail(
|
||||
passwordResetEmailRequest,
|
||||
options
|
||||
);
|
||||
return createRequestFunction(
|
||||
localVarAxiosArgs,
|
||||
globalAxios,
|
||||
BASE_PATH,
|
||||
configuration
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Sign up a new user
|
||||
* @param {SignUpRequest} signUpRequest
|
||||
|
@ -156,6 +341,41 @@ export const ApiHaloRunV1alpha1UserApiFactory = function (
|
|||
) {
|
||||
const localVarFp = ApiHaloRunV1alpha1UserApiFp(configuration);
|
||||
return {
|
||||
/**
|
||||
* Reset password by token
|
||||
* @param {ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
resetPasswordByToken(
|
||||
requestParameters: ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): AxiosPromise<void> {
|
||||
return localVarFp
|
||||
.resetPasswordByToken(
|
||||
requestParameters.name,
|
||||
requestParameters.resetPasswordRequest,
|
||||
options
|
||||
)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Send password reset email when forgot password
|
||||
* @param {ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
sendPasswordResetEmail(
|
||||
requestParameters: ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): AxiosPromise<void> {
|
||||
return localVarFp
|
||||
.sendPasswordResetEmail(
|
||||
requestParameters.passwordResetEmailRequest,
|
||||
options
|
||||
)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Sign up a new user
|
||||
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.
|
||||
|
@ -173,6 +393,41 @@ export const ApiHaloRunV1alpha1UserApiFactory = function (
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request parameters for resetPasswordByToken operation in ApiHaloRunV1alpha1UserApi.
|
||||
* @export
|
||||
* @interface ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest
|
||||
*/
|
||||
export interface ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest {
|
||||
/**
|
||||
* The name of the user
|
||||
* @type {string}
|
||||
* @memberof ApiHaloRunV1alpha1UserApiResetPasswordByToken
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {ResetPasswordRequest}
|
||||
* @memberof ApiHaloRunV1alpha1UserApiResetPasswordByToken
|
||||
*/
|
||||
readonly resetPasswordRequest: ResetPasswordRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for sendPasswordResetEmail operation in ApiHaloRunV1alpha1UserApi.
|
||||
* @export
|
||||
* @interface ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest
|
||||
*/
|
||||
export interface ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest {
|
||||
/**
|
||||
*
|
||||
* @type {PasswordResetEmailRequest}
|
||||
* @memberof ApiHaloRunV1alpha1UserApiSendPasswordResetEmail
|
||||
*/
|
||||
readonly passwordResetEmailRequest: PasswordResetEmailRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for signUp operation in ApiHaloRunV1alpha1UserApi.
|
||||
* @export
|
||||
|
@ -194,6 +449,45 @@ export interface ApiHaloRunV1alpha1UserApiSignUpRequest {
|
|||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class ApiHaloRunV1alpha1UserApi extends BaseAPI {
|
||||
/**
|
||||
* Reset password by token
|
||||
* @param {ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof ApiHaloRunV1alpha1UserApi
|
||||
*/
|
||||
public resetPasswordByToken(
|
||||
requestParameters: ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) {
|
||||
return ApiHaloRunV1alpha1UserApiFp(this.configuration)
|
||||
.resetPasswordByToken(
|
||||
requestParameters.name,
|
||||
requestParameters.resetPasswordRequest,
|
||||
options
|
||||
)
|
||||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email when forgot password
|
||||
* @param {ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof ApiHaloRunV1alpha1UserApi
|
||||
*/
|
||||
public sendPasswordResetEmail(
|
||||
requestParameters: ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) {
|
||||
return ApiHaloRunV1alpha1UserApiFp(this.configuration)
|
||||
.sendPasswordResetEmail(
|
||||
requestParameters.passwordResetEmailRequest,
|
||||
options
|
||||
)
|
||||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign up a new user
|
||||
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.
|
||||
|
|
|
@ -106,6 +106,7 @@ export * from "./notifier-descriptor-spec";
|
|||
export * from "./notifier-info";
|
||||
export * from "./notifier-setting-ref";
|
||||
export * from "./owner-info";
|
||||
export * from "./password-reset-email-request";
|
||||
export * from "./pat-spec";
|
||||
export * from "./personal-access-token";
|
||||
export * from "./personal-access-token-list";
|
||||
|
@ -152,6 +153,7 @@ export * from "./reply-request";
|
|||
export * from "./reply-spec";
|
||||
export * from "./reply-vo";
|
||||
export * from "./reply-vo-list";
|
||||
export * from "./reset-password-request";
|
||||
export * from "./reverse-proxy";
|
||||
export * from "./reverse-proxy-list";
|
||||
export * from "./reverse-proxy-rule";
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Halo Next API
|
||||
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||
*
|
||||
* The version of the OpenAPI document: 2.0.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface PasswordResetEmailRequest
|
||||
*/
|
||||
export interface PasswordResetEmailRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PasswordResetEmailRequest
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PasswordResetEmailRequest
|
||||
*/
|
||||
username: string;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Halo Next API
|
||||
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||
*
|
||||
* The version of the OpenAPI document: 2.0.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ResetPasswordRequest
|
||||
*/
|
||||
export interface ResetPasswordRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ResetPasswordRequest
|
||||
*/
|
||||
newPassword: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ResetPasswordRequest
|
||||
*/
|
||||
token: string;
|
||||
}
|
|
@ -18,6 +18,8 @@ core:
|
|||
label: Already have an account
|
||||
button: Sign in
|
||||
return_site: Return to site
|
||||
reset_password:
|
||||
button: Retrieve password
|
||||
button: Login
|
||||
modal:
|
||||
title: Re-login
|
||||
|
@ -1550,3 +1552,27 @@ core:
|
|||
setting_modal:
|
||||
title: Post settings
|
||||
title: My posts
|
||||
uc_reset_password:
|
||||
fields:
|
||||
username:
|
||||
label: username
|
||||
password:
|
||||
label: New Password
|
||||
password_confirm:
|
||||
label: Confirm Password
|
||||
operations:
|
||||
reset:
|
||||
button: Reset Password
|
||||
toast_success: Reset successful
|
||||
reset_password:
|
||||
fields:
|
||||
username:
|
||||
label: Username
|
||||
email:
|
||||
label: email address
|
||||
operations:
|
||||
send:
|
||||
label: Send verification email
|
||||
toast_success: >-
|
||||
If your username and email address match, we will send an email to
|
||||
your email address.
|
||||
|
|
|
@ -18,6 +18,8 @@ core:
|
|||
label: 已有账号
|
||||
button: 立即登录
|
||||
return_site: 返回到首页
|
||||
reset_password:
|
||||
button: 找回密码
|
||||
button: 登录
|
||||
modal:
|
||||
title: 重新登录
|
||||
|
@ -1207,6 +1209,30 @@ core:
|
|||
label: 密码
|
||||
confirm_password:
|
||||
label: 确认密码
|
||||
reset_password:
|
||||
title: 重置密码
|
||||
fields:
|
||||
username:
|
||||
label: 用户名
|
||||
email:
|
||||
label: 邮箱地址
|
||||
operations:
|
||||
send:
|
||||
label: 发送验证邮件
|
||||
toast_success: 如果你的用户名和邮箱地址匹配,我们将会发送一封邮件到你的邮箱。
|
||||
uc_reset_password:
|
||||
title: 重置密码
|
||||
fields:
|
||||
username:
|
||||
label: 用户名
|
||||
password:
|
||||
label: 新密码
|
||||
password_confirm:
|
||||
label: 确认密码
|
||||
operations:
|
||||
reset:
|
||||
button: 重置密码
|
||||
toast_success: 重置成功
|
||||
rbac:
|
||||
Attachments Management: 附件
|
||||
Attachment Manage: 附件管理
|
||||
|
|
|
@ -18,6 +18,8 @@ core:
|
|||
label: 已有帳號
|
||||
button: 立即登入
|
||||
return_site: 返回到首頁
|
||||
reset_password:
|
||||
button: 找回密碼
|
||||
button: 登入
|
||||
modal:
|
||||
title: 重新登入
|
||||
|
@ -1462,3 +1464,25 @@ core:
|
|||
setting_modal:
|
||||
title: 文章設定
|
||||
title: 我的文章
|
||||
uc_reset_password:
|
||||
fields:
|
||||
username:
|
||||
label: 用户名
|
||||
password:
|
||||
label: 新密碼
|
||||
password_confirm:
|
||||
label: 確認密碼
|
||||
operations:
|
||||
reset:
|
||||
button: 重設密碼
|
||||
toast_success: 重置成功
|
||||
reset_password:
|
||||
fields:
|
||||
username:
|
||||
label: 使用者名稱
|
||||
email:
|
||||
label: 郵件地址
|
||||
operations:
|
||||
send:
|
||||
label: 發送驗證郵件
|
||||
toast_success: 如果你的用戶名和郵箱地址匹配,我們將會發送一封郵件到你的郵箱。
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
import { useUserStore } from "@/stores/user";
|
||||
import type { Router } from "vue-router";
|
||||
|
||||
const whiteList = ["ResetPassword"];
|
||||
|
||||
export function setupAuthCheckGuard(router: Router) {
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (whiteList.includes(to.name as string)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.isAnonymous) {
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { RouteRecordRaw } from "vue-router";
|
|||
import NotFound from "@/views/exceptions/NotFound.vue";
|
||||
import Forbidden from "@/views/exceptions/Forbidden.vue";
|
||||
import BasicLayout from "@uc/layouts/BasicLayout.vue";
|
||||
import ResetPassword from "@uc/views/ResetPassword.vue";
|
||||
|
||||
export const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
|
@ -20,6 +21,14 @@ export const routes: Array<RouteRecordRaw> = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/reset-password/:username",
|
||||
component: ResetPassword,
|
||||
name: "ResetPassword",
|
||||
meta: {
|
||||
title: "core.uc_reset_password.title",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { Toast, VButton } from "@halo-dev/components";
|
||||
import { useRouteParams, useRouteQuery } from "@vueuse/router";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const username = useRouteParams<string>("username");
|
||||
const token = useRouteQuery<string>("reset_password_token");
|
||||
|
||||
interface ResetPasswordForm {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
async function onSubmit(data: ResetPasswordForm) {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await apiClient.common.user.resetPasswordByToken({
|
||||
name: data.username,
|
||||
resetPasswordRequest: {
|
||||
newPassword: data.password,
|
||||
token: token.value,
|
||||
},
|
||||
});
|
||||
|
||||
Toast.success(t("core.uc_reset_password.operations.reset.toast_success"));
|
||||
|
||||
window.location.href = "/console/login";
|
||||
} catch (error) {
|
||||
console.error("Failed to reset password", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const inputClasses = {
|
||||
outer: "!py-3 first:!pt-0 last:!pb-0",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
|
||||
<IconLogo class="mb-8" />
|
||||
<div class="flex w-72 flex-col">
|
||||
<FormKit
|
||||
id="reset-password-form"
|
||||
name="reset-password-form"
|
||||
type="form"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="onSubmit"
|
||||
@keyup.enter="$formkit.submit('reset-password-form')"
|
||||
>
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="username"
|
||||
:model-value="username"
|
||||
:placeholder="$t('core.uc_reset_password.fields.username.label')"
|
||||
:validation-label="$t('core.uc_reset_password.fields.username.label')"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
disabled
|
||||
validation="required"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="password"
|
||||
type="password"
|
||||
:placeholder="$t('core.uc_reset_password.fields.password.label')"
|
||||
:validation-label="$t('core.uc_reset_password.fields.password.label')"
|
||||
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
|
||||
:validation-messages="{
|
||||
matches: $t('core.formkit.validation.trim'),
|
||||
}"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="password_confirm"
|
||||
type="password"
|
||||
:placeholder="
|
||||
$t('core.uc_reset_password.fields.password_confirm.label')
|
||||
"
|
||||
:validation-label="
|
||||
$t('core.uc_reset_password.fields.password_confirm.label')
|
||||
"
|
||||
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
|
||||
:validation-messages="{
|
||||
matches: $t('core.formkit.validation.trim'),
|
||||
}"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
class="mt-8"
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
@click="$formkit.submit('reset-password-form')"
|
||||
>
|
||||
{{ $t("core.uc_reset_password.operations.reset.button") }}
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -139,7 +139,34 @@ items:
|
|||
method: DELETE
|
||||
header:
|
||||
Authorization: "{{.param.auth}}"
|
||||
|
||||
- name: sendPasswordResetEmail
|
||||
request:
|
||||
api: |
|
||||
/api.halo.run/v1alpha1/users/-/send-password-reset-email
|
||||
method: POST
|
||||
header:
|
||||
Content-Type: application/json
|
||||
body: |
|
||||
{
|
||||
"username": "{{.param.userName}}",
|
||||
"email": "{{.param.email}}"
|
||||
}
|
||||
expect:
|
||||
statusCode: 204
|
||||
- name: resetPasswordByToken
|
||||
request:
|
||||
api: |
|
||||
/api.halo.run/v1alpha1/users/{{.param.userName}}/reset-password
|
||||
method: PUT
|
||||
header:
|
||||
Content-Type: application/json
|
||||
body: |
|
||||
{
|
||||
"newPassword": "{{randAlpha 6}}",
|
||||
"token": "{{randAlpha 6}}"
|
||||
}
|
||||
expect:
|
||||
statusCode: 403
|
||||
## Roles
|
||||
- name: createRole
|
||||
request:
|
||||
|
|
Loading…
Reference in New Issue