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
guqing 2023-12-01 11:06:09 +08:00 committed by GitHub
parent a5639a8733
commit abd049719d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1185 additions and 22 deletions

View File

@ -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);
}

View File

@ -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;
}
}
}

View File

@ -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");

View File

@ -99,3 +99,7 @@ resilience4j.ratelimiter:
limitForPeriod: 3
limitRefreshPeriod: 1h
timeoutDuration: 0s
send-reset-password-email:
limitForPeriod: 2
limitRefreshPeriod: 1m
timeoutDuration: 0s

View File

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

View File

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

View File

@ -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\"");
}
}

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

@ -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: 附件管理

View File

@ -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: 如果你的用戶名和郵箱地址匹配,我們將會發送一封郵件到你的郵箱。

View File

@ -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) {

View File

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

View File

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

View File

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