diff --git a/README.md b/README.md index c2041ffc6f..3b18c4e88a 100755 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ## 快速开始 ```bash -docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.10 +docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.11 ``` 以上仅作为体验使用,详细部署文档请查阅: diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java b/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java new file mode 100644 index 0000000000..3e1a97723a --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java @@ -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 { + + /** + *

Send password reset email.

+ * 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 sendPasswordResetEmail(String username, String email); + + /** + *

Reset password by token.

+ * if the token is invalid, it will return {@link Mono#error(Throwable)}} + * if the token is valid, but the username is not the same, it will return + * {@link Mono#error(Throwable)} + * + * @param username username to reset password + * @param newPassword new password + * @param token token to validate the user + * @return {@link Mono#empty()} if the token is invalid or the username is not the same. + * @throws AccessDeniedException if the token is invalid + */ + Mono changePassword(String username, String newPassword, String token); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java new file mode 100644 index 0000000000..4fbe8c859e --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java @@ -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 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 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 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 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 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 userTokenCache = + CacheBuilder.newBuilder() + .expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES) + .maximumSize(10000) + .build(); + + private final Cache + blackListCache = CacheBuilder.newBuilder() + .expireAfterWrite(Duration.ofHours(2)) + .maximumSize(1000) + .build(); + + public boolean verifyToken(String username, String token) { + var verification = userTokenCache.getIfPresent(username); + if (verification == null) { + // expired or not generated + return false; + } + if (blackListCache.getIfPresent(username) != null) { + // in blacklist + throw new RateLimitExceededException(null); + } + synchronized (verification) { + if (verification.getAttempts().get() >= MAX_ATTEMPTS) { + // add to blacklist to prevent brute force attack + blackListCache.put(username, true); + return false; + } + if (!verification.getToken().equals(token)) { + verification.getAttempts().incrementAndGet(); + return false; + } + } + return true; + } + + public void removeToken(String username) { + userTokenCache.invalidate(username); + } + + public String generateToken(String username) { + Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); + var verification = new Verification(); + verification.setToken(RandomStringUtils.randomAlphanumeric(20)); + verification.setAttempts(new AtomicInteger(0)); + userTokenCache.put(username, verification); + return verification.getToken(); + } + + /** + * Only for test. + */ + boolean contains(String username) { + return userTokenCache.getIfPresent(username) != null; + } + + @Data + @Accessors(chain = true) + static class Verification { + private String token; + private AtomicInteger attempts; + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java index fa8f78700a..eb7ffd8634 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java +++ b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java @@ -2,14 +2,18 @@ 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.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 RouterFunction endpoint() { ) .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 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 sendPasswordResetEmail(ServerRequest request) { + return request.bodyToMono(PasswordResetEmailRequest.class) + .flatMap(passwordResetRequest -> { + var username = passwordResetRequest.username(); + var email = passwordResetRequest.email(); + return Mono.just(passwordResetRequest) + .transformDeferred(sendResetPasswordEmailRateLimiter(username, email)) + .flatMap( + r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + }) + .then(ServerResponse.noContent().build()); + } + + RateLimiterOperator sendResetPasswordEmailRateLimiter(String username, String email) { + String rateLimiterKey = "send-reset-password-email-" + username + ":" + email; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email"); + return RateLimiterOperator.of(rateLimiter); + } + @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 3233542d2e..d289d8cf24 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -99,3 +99,7 @@ resilience4j.ratelimiter: limitForPeriod: 3 limitRefreshPeriod: 1h timeoutDuration: 0s + send-reset-password-email: + limitForPeriod: 2 + limitRefreshPeriod: 1m + timeoutDuration: 0s diff --git a/application/src/main/resources/extensions/notification-templates.yaml b/application/src/main/resources/extensions/notification-templates.yaml index cb80998493..3661e8cc69 100644 --- a/application/src/main/resources/extensions/notification-templates.yaml +++ b/application/src/main/resources/extensions/notification-templates.yaml @@ -126,3 +126,30 @@ spec:

如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。

+--- +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: | +
+
+

+
+
+

你已经请求了重置密码,可以点击下面的链接来重置密码:

+ +

+

如果您没有请求重置密码,请忽略此电子邮件。

+
+
diff --git a/application/src/main/resources/extensions/notification.yaml b/application/src/main/resources/extensions/notification.yaml index b3d8cd655f..23429a95ad 100644 --- a/application/src/main/resources/extensions/notification.yaml +++ b/application/src/main/resources/extensions/notification.yaml @@ -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." diff --git a/application/src/main/resources/extensions/role-template-uc-content.yaml b/application/src/main/resources/extensions/role-template-uc-content.yaml index 0e4c416487..7b9b42a2e0 100644 --- a/application/src/main/resources/extensions/role-template-uc-content.yaml +++ b/application/src/main/resources/extensions/role-template-uc-content.yaml @@ -4,11 +4,26 @@ metadata: name: post-editor labels: rbac.authorization.halo.run/system-reserved: "true" + annotations: + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "编辑者" + rbac.authorization.halo.run/dependencies: | + ["role-template-post-editor"] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-post-editor + labels: + halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Editor" rbac.authorization.halo.run/dependencies: | - ["role-template-manage-posts", "post-author"] + ["role-template-manage-posts", "role-template-post-author"] rules: [ ] --- @@ -19,12 +34,27 @@ metadata: labels: rbac.authorization.halo.run/system-reserved: "true" annotations: - rbac.authorization.halo.run/module: "Posts Management" - rbac.authorization.halo.run/display-name: "Post Author" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "作者" rbac.authorization.halo.run/disallow-access-console: "true" rbac.authorization.halo.run/redirect-on-login: "/uc" rbac.authorization.halo.run/dependencies: | - [ "post-contributor", "post-publisher" ] + [ "role-template-post-author" ] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-post-author + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "Post Author" + rbac.authorization.halo.run/dependencies: | + [ "role-template-post-contributor", "role-template-post-publisher", "role-template-post-attachment-manager" ] rules: [ ] --- @@ -35,10 +65,25 @@ metadata: labels: rbac.authorization.halo.run/system-reserved: "true" annotations: - rbac.authorization.halo.run/module: "Posts Management" - rbac.authorization.halo.run/display-name: "Post Contributor" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "投稿者" rbac.authorization.halo.run/disallow-access-console: "true" rbac.authorization.halo.run/redirect-on-login: "/uc" + rbac.authorization.halo.run/dependencies: | + [ "role-template-post-contributor" ] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-post-contributor + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "Post Contributor" rbac.authorization.halo.run/dependencies: | [ "role-template-view-categories", "role-template-view-tags" ] rbac.authorization.halo.run/ui-permissions: | @@ -55,7 +100,7 @@ rules: apiVersion: v1alpha1 kind: Role metadata: - name: post-publisher + name: role-template-post-publisher labels: halo.run/role-template: "true" annotations: @@ -67,38 +112,20 @@ rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "posts/publish", "posts/unpublish" ] verbs: [ "update" ] + --- apiVersion: v1alpha1 kind: Role metadata: - name: post-attachment-manager + name: role-template-post-attachment-manager labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Post Attachment Manager" - rbac.authorization.halo.run/dependencies: | - [ "role-template-post-attachment-viewer" ] rbac.authorization.halo.run/ui-permissions: | [ "uc:attachments:manage" ] rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "attachments" ] verbs: [ "create", "update", "delete" ] - ---- -apiVersion: v1alpha1 -kind: Role -metadata: - name: post-attachment-viewer - labels: - halo.run/role-template: "true" - annotations: - rbac.authorization.halo.run/module: "Posts Management" - rbac.authorization.halo.run/display-name: "Post Attachment Viewer" - rbac.authorization.halo.run/ui-permissions: | - [ "uc:attachments:view" ] -rules: - - apiGroups: [ "uc.api.content.halo.run" ] - resources: [ "attachments" ] - verbs: [ "get", "list" ] \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java new file mode 100644 index 0000000000..1d95fced07 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java @@ -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\""); + } +} \ No newline at end of file diff --git a/console/console-src/modules/system/users/Login.vue b/console/console-src/modules/system/users/Login.vue index f10eca8fb0..5c85a21103 100644 --- a/console/console-src/modules/system/users/Login.vue +++ b/console/console-src/modules/system/users/Login.vue @@ -52,27 +52,32 @@ watch( -
- - {{ - isLoginType - ? $t("core.login.operations.signup.label") - : $t("core.login.operations.return_login.label") - }} - - +
+ + {{ + isLoginType + ? $t("core.login.operations.signup.label") + : $t("core.login.operations.return_login.label") + }}, + + + {{ + isLoginType + ? $t("core.login.operations.signup.button") + : $t("core.login.operations.return_login.button") + }} + +
+ - {{ - isLoginType - ? $t("core.login.operations.signup.button") - : $t("core.login.operations.return_login.button") - }} -
+ {{ $t("core.login.operations.reset_password.button") }} +
{ diff --git a/console/console-src/router/routes.config.ts b/console/console-src/router/routes.config.ts index 0761ae9613..ff820410e6 100644 --- a/console/console-src/router/routes.config.ts +++ b/console/console-src/router/routes.config.ts @@ -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 = [ { @@ -44,6 +45,14 @@ export const routes: Array = [ name: "Redirect", component: Redirect, }, + { + path: "/reset-password", + name: "ResetPassword", + component: ResetPassword, + meta: { + title: "core.reset_password.title", + }, + }, ]; export default routes; diff --git a/console/console-src/views/system/ResetPassword.vue b/console/console-src/views/system/ResetPassword.vue new file mode 100644 index 0000000000..7349cacc83 --- /dev/null +++ b/console/console-src/views/system/ResetPassword.vue @@ -0,0 +1,85 @@ + + + diff --git a/console/package.json b/console/package.json index 5bdc9b3a01..c255ff64cf 100644 --- a/console/package.json +++ b/console/package.json @@ -1,6 +1,4 @@ { - "name": "@halo-dev/console", - "version": "2.11.0", "scripts": { "prepare": "cd .. && husky install console/.husky", "dev": "run-p dev:console dev:uc", diff --git a/console/packages/api-client/package.json b/console/packages/api-client/package.json index 2f317288ff..a40dc7f91f 100644 --- a/console/packages/api-client/package.json +++ b/console/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@halo-dev/api-client", - "version": "2.11.0", + "version": "2.12.0", "description": "", "scripts": { "build": "unbuild", diff --git a/console/packages/api-client/src/.openapi-generator/FILES b/console/packages/api-client/src/.openapi-generator/FILES index d6fe6ca578..b1676ea613 100644 --- a/console/packages/api-client/src/.openapi-generator/FILES +++ b/console/packages/api-client/src/.openapi-generator/FILES @@ -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 diff --git a/console/packages/api-client/src/api/api-halo-run-v1alpha1-user-api.ts b/console/packages/api-client/src/api/api-halo-run-v1alpha1-user-api.ts index 0c4aed09ab..e6d4c728e3 100644 --- a/console/packages/api-client/src/api/api-halo-run-v1alpha1-user-api.ts +++ b/console/packages/api-client/src/api/api-halo-run-v1alpha1-user-api.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 => { + // 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 => { + // 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 + > { + 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 + > { + 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 { + 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 { + 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. diff --git a/console/packages/api-client/src/models/index.ts b/console/packages/api-client/src/models/index.ts index 96c103ed99..f1bc6a1300 100644 --- a/console/packages/api-client/src/models/index.ts +++ b/console/packages/api-client/src/models/index.ts @@ -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"; diff --git a/console/packages/api-client/src/models/password-reset-email-request.ts b/console/packages/api-client/src/models/password-reset-email-request.ts new file mode 100644 index 0000000000..9b4762e6f5 --- /dev/null +++ b/console/packages/api-client/src/models/password-reset-email-request.ts @@ -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; +} diff --git a/console/packages/api-client/src/models/reset-password-request.ts b/console/packages/api-client/src/models/reset-password-request.ts new file mode 100644 index 0000000000..5e82194561 --- /dev/null +++ b/console/packages/api-client/src/models/reset-password-request.ts @@ -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; +} diff --git a/console/packages/components/package.json b/console/packages/components/package.json index 6b46b9e02f..77fa63d444 100644 --- a/console/packages/components/package.json +++ b/console/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@halo-dev/components", - "version": "1.10.0", + "version": "2.12.0", "description": "", "files": [ "dist" diff --git a/console/packages/editor/index.html b/console/packages/editor/index.html index 05b9034ebc..daed65da86 100644 --- a/console/packages/editor/index.html +++ b/console/packages/editor/index.html @@ -8,6 +8,6 @@
- + diff --git a/console/packages/shared/package.json b/console/packages/shared/package.json index 154c1fc2b4..6269ac5fc3 100644 --- a/console/packages/shared/package.json +++ b/console/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@halo-dev/console-shared", - "version": "2.11.0", + "version": "2.12.0", "description": "", "files": [ "dist" diff --git a/console/packages/ui-plugin-bundler-kit/package.json b/console/packages/ui-plugin-bundler-kit/package.json index 7b950332e5..d88d7409a8 100644 --- a/console/packages/ui-plugin-bundler-kit/package.json +++ b/console/packages/ui-plugin-bundler-kit/package.json @@ -1,6 +1,6 @@ { "name": "@halo-dev/ui-plugin-bundler-kit", - "version": "1.0.0", + "version": "2.12.0", "homepage": "https://github.com/halo-dev/halo/tree/main/console/packages/ui-plugin-bundler-kit#readme", "bugs": { "url": "https://github.com/halo-dev/halo/issues" diff --git a/console/src/components/editor/DefaultEditor.vue b/console/src/components/editor/DefaultEditor.vue index 0fcfbce656..e19557b7bd 100644 --- a/console/src/components/editor/DefaultEditor.vue +++ b/console/src/components/editor/DefaultEditor.vue @@ -45,6 +45,10 @@ import { ExtensionNodeSelected, ExtensionTrailingNode, ToolbarItem, + Plugin, + PluginKey, + Decoration, + DecorationSet, } from "@halo-dev/richtext-editor"; import { IconCalendar, @@ -67,7 +71,6 @@ import RiLayoutRightLine from "~icons/ri/layout-right-line"; import { inject, markRaw, - nextTick, ref, watch, onMounted, @@ -86,8 +89,8 @@ import { usePluginModuleStore } from "@/stores/plugin"; import type { PluginModule } from "@halo-dev/console-shared"; import { useDebounceFn, useLocalStorage } from "@vueuse/core"; import { onBeforeUnmount } from "vue"; -import { generateAnchor } from "@/utils/anchor"; import { usePermission } from "@/utils/permission"; +import { generateAnchor } from "@/utils/anchor"; const { t } = useI18n(); const { currentUserHasPermission } = usePermission(); @@ -288,13 +291,49 @@ onMounted(() => { ExtensionColumn, ExtensionNodeSelected, ExtensionTrailingNode, + Extension.create({ + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("generate-heading-id"), + props: { + decorations: (state) => { + const headings: HeadingNode[] = []; + const { doc } = state; + const decorations: Decoration[] = []; + doc.descendants((node, pos) => { + if (node.type.name === ExtensionHeading.name) { + const id = generateAnchor(node.textContent); + if (node.attrs.id !== id) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + id, + }) + ); + } + + headings.push({ + level: node.attrs.level, + text: node.textContent, + id, + }); + } + }); + headingNodes.value = headings; + if (!selectedHeadingNode.value) { + selectedHeadingNode.value = headings[0]; + } + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, + }), ], autofocus: "start", onUpdate: () => { debounceOnUpdate(); - nextTick(() => { - handleGenerateTableOfContent(); - }); }, editorProps: { handleDrop: (view, event: DragEvent, _, moved) => { @@ -408,45 +447,6 @@ async function asyncWorker(arg: Task): Promise { } } -const handleGenerateTableOfContent = () => { - if (!editor.value) { - return; - } - - const headings: HeadingNode[] = []; - const transaction = editor.value.state.tr; - - editor.value.state.doc.descendants((node, pos) => { - if (node.type.name === "heading") { - const id = generateAnchor(node.textContent); - - if (node.attrs.id !== id) { - transaction?.setNodeMarkup(pos, undefined, { - ...node.attrs, - id, - }); - } - - headings.push({ - level: node.attrs.level, - text: node.textContent, - id, - }); - } - }); - - transaction.setMeta("addToHistory", false); - transaction.setMeta("preventUpdate", true); - - editor.value.view.dispatch(transaction); - - headingNodes.value = headings; - - if (!selectedHeadingNode.value) { - selectedHeadingNode.value = headings[0]; - } -}; - const handleSelectHeadingNode = (node: HeadingNode) => { selectedHeadingNode.value = node; document.getElementById(node.id)?.scrollIntoView({ behavior: "smooth" }); @@ -459,9 +459,6 @@ watch( () => { if (props.raw !== editor.value?.getHTML()) { editor.value?.commands.setContent(props.raw); - nextTick(() => { - handleGenerateTableOfContent(); - }); } }, { diff --git a/console/src/locales/en.yaml b/console/src/locales/en.yaml index a8e769c523..44d2c4afd6 100644 --- a/console/src/locales/en.yaml +++ b/console/src/locales/en.yaml @@ -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 @@ -1345,9 +1347,9 @@ core: Notification Configuration: Notification Configuration Configure Notifier: Configure Notifier Post Attachment Manager: Allow images to be uploaded in posts - Post Author: Author - Post Contributor: Contributor - Post Editor: Editor + Post Author: Contributions allowed + Post Contributor: Allows you to manage your own posts + Post Editor: Allow management of all posts Post Publisher: Allow to publish own posts components: submit_button: @@ -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. diff --git a/console/src/locales/zh-CN.yaml b/console/src/locales/zh-CN.yaml index 02fe401439..3a0c45381e 100644 --- a/console/src/locales/zh-CN.yaml +++ b/console/src/locales/zh-CN.yaml @@ -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: 附件管理 @@ -1272,9 +1298,9 @@ core: Cache Manage: 缓存管理 Notification Configuration: 通知配置 Configure Notifier: 配置通知器 - Post Editor: 编辑者 - Post Contributor: 投稿者 - Post Author: 作者 + Post Editor: 允许管理所有文章 + Post Contributor: 允许管理自己的文章 + Post Author: 允许投稿 Post Attachment Manager: 允许在文章中上传图片 Post Publisher: 允许发布自己的文章 components: diff --git a/console/src/locales/zh-TW.yaml b/console/src/locales/zh-TW.yaml index 71eed660ef..9c54fb424c 100644 --- a/console/src/locales/zh-TW.yaml +++ b/console/src/locales/zh-TW.yaml @@ -18,6 +18,8 @@ core: label: 已有帳號 button: 立即登入 return_site: 返回到首頁 + reset_password: + button: 找回密碼 button: 登入 modal: title: 重新登入 @@ -1261,9 +1263,9 @@ core: Configure Notifier: 配置通知器 Notification Configuration: 通知配置 Post Attachment Manager: 允許在文章中上傳圖片 - Post Author: 作者 - Post Contributor: 投稿者 - Post Editor: 編輯者 + Post Author: 允许管理自己的文章 + Post Contributor: 允许投稿 + Post Editor: 允许管理所有文章 Post Publisher: 允許發布自己的文章 components: submit_button: @@ -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: 如果你的用戶名和郵箱地址匹配,我們將會發送一封郵件到你的郵箱。 diff --git a/console/uc-src/router/guards/auth-check.ts b/console/uc-src/router/guards/auth-check.ts index 4fc4440eda..322a7be26b 100644 --- a/console/uc-src/router/guards/auth-check.ts +++ b/console/uc-src/router/guards/auth-check.ts @@ -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) { diff --git a/console/uc-src/router/routes.config.ts b/console/uc-src/router/routes.config.ts index 48fd30517b..41d65d58c5 100644 --- a/console/uc-src/router/routes.config.ts +++ b/console/uc-src/router/routes.config.ts @@ -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 = [ { @@ -20,6 +21,14 @@ export const routes: Array = [ }, ], }, + { + path: "/reset-password/:username", + component: ResetPassword, + name: "ResetPassword", + meta: { + title: "core.uc_reset_password.title", + }, + }, ]; export default routes; diff --git a/console/uc-src/views/ResetPassword.vue b/console/uc-src/views/ResetPassword.vue new file mode 100644 index 0000000000..238937fa9a --- /dev/null +++ b/console/uc-src/views/ResetPassword.vue @@ -0,0 +1,112 @@ + + + diff --git a/e2e/testsuite.yaml b/e2e/testsuite.yaml index 19b3c96814..2ad3875830 100644 --- a/e2e/testsuite.yaml +++ b/e2e/testsuite.yaml @@ -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: diff --git a/gradle.properties b/gradle.properties index 5fb5d935d5..910803e42c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -version=2.11.0-SNAPSHOT +version=2.12.0-SNAPSHOT snakeyaml.version=2.2