feat: support user email verification mechanism (#4878)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.11.x

#### What this PR does / why we need it:
新增用户邮箱验证机制

#### Which issue(s) this PR fixes:

Fixes #4656

#### Special notes for your reviewer:

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

```release-note
新增用户邮箱验证机制
```
pull/4920/head^2
guqing 2023-11-27 22:20:09 +08:00 committed by GitHub
parent 82a6ba6d90
commit 96d4897d11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1420 additions and 10 deletions

View File

@ -35,6 +35,8 @@ public class User extends AbstractExtension {
public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names";
public static final String EMAIL_TO_VERIFY = "halo.run/email-to-verify";
public static final String LAST_AVATAR_ATTACHMENT_NAME_ANNO =
"halo.run/last-avatar-attachment-name";
@ -58,6 +60,8 @@ public class User extends AbstractExtension {
@Schema(requiredMode = REQUIRED)
private String email;
private boolean emailVerified;
private String phone;
private String password;

View File

@ -17,9 +17,13 @@ import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRo
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.io.Files;
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.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.security.Principal;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
@ -63,11 +67,13 @@ import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuples;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.core.extension.service.EmailVerificationService;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Comparators;
@ -78,6 +84,8 @@ import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ValidationUtils;
import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.infra.utils.JsonUtils;
@Component
@ -92,6 +100,8 @@ public class UserEndpoint implements CustomEndpoint {
private final UserService userService;
private final RoleService roleService;
private final AttachmentService attachmentService;
private final EmailVerificationService emailVerificationService;
private final RateLimiterRegistry rateLimiterRegistry;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
@ -201,9 +211,102 @@ public class UserEndpoint implements CustomEndpoint {
)
.response(responseBuilder().implementation(User.class))
.build())
.POST("users/-/send-email-verification-code",
this::sendEmailVerificationCode,
builder -> builder
.tag(tag)
.operationId("SendEmailVerificationCode")
.requestBody(requestBodyBuilder()
.implementation(EmailVerifyRequest.class)
.required(true)
)
.description("Send email verification code for user")
.response(responseBuilder().implementation(Void.class))
.build()
)
.POST("users/-/verify-email", this::verifyEmail,
builder -> builder
.tag(tag)
.operationId("VerifyEmail")
.description("Verify email for user by code.")
.requestBody(requestBodyBuilder()
.required(true)
.implementation(VerifyCodeRequest.class))
.response(responseBuilder().implementation(Void.class))
.build()
)
.build();
}
private Mono<ServerResponse> verifyEmail(ServerRequest request) {
return request.bodyToMono(VerifyCodeRequest.class)
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("Request body is required."))
)
.flatMap(verifyEmailRequest -> ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.map(username -> Tuples.of(username, verifyEmailRequest.code()))
)
.flatMap(tuple2 -> {
var username = tuple2.getT1();
var code = tuple2.getT2();
return Mono.just(username)
.transformDeferred(verificationEmailRateLimiter(username))
.flatMap(name -> emailVerificationService.verify(username, code))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
})
.then(ServerResponse.ok().build());
}
public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) String email) {
}
public record VerifyCodeRequest(@Schema(requiredMode = REQUIRED, minLength = 1) String code) {
}
private Mono<ServerResponse> sendEmailVerificationCode(ServerRequest request) {
return request.bodyToMono(EmailVerifyRequest.class)
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("Request body is required."))
)
.doOnNext(emailRequest -> {
if (!ValidationUtils.isValidEmail(emailRequest.email())) {
throw new ServerWebInputException("Invalid email address.");
}
})
.flatMap(emailRequest -> {
var email = emailRequest.email();
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.map(username -> Tuples.of(username, email));
})
.flatMap(tuple -> {
var username = tuple.getT1();
var email = tuple.getT2();
return Mono.just(username)
.transformDeferred(sendEmailVerificationCodeRateLimiter(username, email))
.flatMap(u -> emailVerificationService.sendVerificationCode(username, email))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
})
.then(ServerResponse.ok().build());
}
<T> RateLimiterOperator<T> verificationEmailRateLimiter(String username) {
String rateLimiterKey = "verify-email-" + username;
var rateLimiter =
rateLimiterRegistry.rateLimiter(rateLimiterKey, "verify-email");
return RateLimiterOperator.of(rateLimiter);
}
<T> RateLimiterOperator<T> sendEmailVerificationCodeRateLimiter(String username, String email) {
String rateLimiterKey = "send-email-verification-code-" + username + ":" + email;
var rateLimiter =
rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code");
return RateLimiterOperator.of(rateLimiter);
}
private Mono<ServerResponse> deleteUserAvatar(ServerRequest request) {
final var nameInPath = request.pathVariable("name");
return getUserOrSelf(nameInPath)
@ -396,6 +499,8 @@ public class UserEndpoint implements CustomEndpoint {
oldAnnotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO));
newAnnotations.put(User.AVATAR_ATTACHMENT_NAME_ANNO,
oldAnnotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO));
newAnnotations.put(User.EMAIL_TO_VERIFY,
oldAnnotations.get(User.EMAIL_TO_VERIFY));
currentUser.getMetadata().setAnnotations(newAnnotations);
}
var spec = currentUser.getSpec();
@ -403,7 +508,6 @@ public class UserEndpoint implements CustomEndpoint {
spec.setBio(newSpec.getBio());
spec.setDisplayName(newSpec.getDisplayName());
spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled());
spec.setEmail(newSpec.getEmail());
spec.setPhone(newSpec.getPhone());
return currentUser;
})

View File

@ -0,0 +1,30 @@
package run.halo.app.core.extension.service;
import reactor.core.publisher.Mono;
import run.halo.app.infra.exception.EmailVerificationFailed;
/**
* Email verification service to handle email verification.
*
* @author guqing
* @since 2.11.0
*/
public interface EmailVerificationService {
/**
* Send verification code by given username.
*
* @param username username to verify email must not be blank
* @param email email to send must not be blank
*/
Mono<Void> sendVerificationCode(String username, String email);
/**
* Verify email by given username and code.
*
* @param username username to verify email must not be blank
* @param code code to verify email must not be blank
* @throws EmailVerificationFailed if send failed
*/
Mono<Void> verify(String username, String code);
}

View File

@ -0,0 +1,235 @@
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 org.springframework.web.server.ServerWebInputException;
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.EmailVerificationService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.notification.NotificationCenter;
import run.halo.app.notification.NotificationReasonEmitter;
import run.halo.app.notification.UserIdentity;
/**
* A default implementation of {@link EmailVerificationService}.
*
* @author guqing
* @since 2.11.0
*/
@Component
@RequiredArgsConstructor
public class EmailVerificationServiceImpl implements EmailVerificationService {
public static final int MAX_ATTEMPTS = 5;
public static final long CODE_EXPIRATION_MINUTES = 10;
static final String EMAIL_VERIFICATION_REASON_TYPE = "email-verification";
private final EmailVerificationManager emailVerificationManager =
new EmailVerificationManager();
private final ReactiveExtensionClient client;
private final NotificationReasonEmitter reasonEmitter;
private final NotificationCenter notificationCenter;
@Override
public Mono<Void> sendVerificationCode(String username, String email) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
Assert.state(StringUtils.isNotBlank(email), "Email must not be blank");
return Mono.defer(() -> client.get(User.class, username)
.flatMap(user -> {
var userEmail = user.getSpec().getEmail();
var isVerified = user.getSpec().isEmailVerified();
if (StringUtils.equals(userEmail, email) && isVerified) {
return Mono.error(
() -> new ServerWebInputException("Email already verified."));
}
var annotations = MetadataUtil.nullSafeAnnotations(user);
var oldEmailToVerify = annotations.get(User.EMAIL_TO_VERIFY);
var unsubMono = unSubscribeVerificationEmailNotification(oldEmailToVerify);
var updateUserAnnoMono = Mono.defer(() -> {
annotations.put(User.EMAIL_TO_VERIFY, email);
return client.update(user);
});
emailVerificationManager.removeCode(username, oldEmailToVerify);
return Mono.when(unsubMono, updateUserAnnoMono).thenReturn(user);
})
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
.flatMap(user -> sendVerificationNotification(username, email));
}
@Override
public Mono<Void> verify(String username, String code) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
Assert.state(StringUtils.isNotBlank(code), "Code must not be blank");
return Mono.defer(() -> client.get(User.class, username)
.flatMap(user -> {
var annotations = MetadataUtil.nullSafeAnnotations(user);
var emailToVerify = annotations.get(User.EMAIL_TO_VERIFY);
if (StringUtils.isBlank(emailToVerify)) {
return Mono.error(EmailVerificationFailed::new);
}
var verified =
emailVerificationManager.verifyCode(username, emailToVerify, code);
if (!verified) {
return Mono.error(EmailVerificationFailed::new);
}
user.getSpec().setEmailVerified(true);
user.getSpec().setEmail(emailToVerify);
emailVerificationManager.removeCode(username, emailToVerify);
return client.update(user);
})
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
.then();
}
Mono<Void> sendVerificationNotification(String username, String email) {
var code = emailVerificationManager.generateCode(username, email);
var subscribeNotification = autoSubscribeVerificationEmailNotification(email);
var interestReasonSubject = createInterestReason(email).getSubject();
var emitReasonMono = reasonEmitter.emit(EMAIL_VERIFICATION_REASON_TYPE,
builder -> builder.attribute("code", code)
.attribute("expirationAtMinutes", CODE_EXPIRATION_MINUTES)
.attribute("username", username)
.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> autoSubscribeVerificationEmailNotification(String email) {
var subscriber = new Subscription.Subscriber();
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
var interestReason = createInterestReason(email);
return notificationCenter.subscribe(subscriber, interestReason)
.then();
}
Mono<Void> unSubscribeVerificationEmailNotification(String oldEmail) {
if (StringUtils.isBlank(oldEmail)) {
return Mono.empty();
}
var subscriber = new Subscription.Subscriber();
subscriber.setName(UserIdentity.anonymousWithEmail(oldEmail).name());
return notificationCenter.unsubscribe(subscriber,
createInterestReason(oldEmail));
}
Subscription.InterestReason createInterestReason(String email) {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(EMAIL_VERIFICATION_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;
}
/**
* A simple email verification manager that stores the verification code in memory.
* It is a thread-safe class.
*
* @author guqing
* @since 2.11.0
*/
static class EmailVerificationManager {
private final Cache<UsernameEmail, Verification> emailVerificationCodeCache =
CacheBuilder.newBuilder()
.expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
private final Cache<UsernameEmail, Boolean> blackListCache = CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(1000)
.build();
public boolean verifyCode(String username, String email, String code) {
var key = new UsernameEmail(username, email);
var verification = emailVerificationCodeCache.getIfPresent(key);
if (verification == null) {
// expired or not generated
return false;
}
if (blackListCache.getIfPresent(key) != null) {
// in blacklist
throw new EmailVerificationFailed("Too many attempts. Please try again later.",
null,
"problemDetail.user.email.verify.maxAttempts",
null);
}
synchronized (verification) {
if (verification.getAttempts().get() >= MAX_ATTEMPTS) {
// add to blacklist to prevent brute force attack
blackListCache.put(key, true);
return false;
}
if (!verification.getCode().equals(code)) {
verification.getAttempts().incrementAndGet();
return false;
}
}
return true;
}
public void removeCode(String username, String email) {
var key = new UsernameEmail(username, email);
emailVerificationCodeCache.invalidate(key);
}
public String generateCode(String username, String email) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
Assert.state(StringUtils.isNotBlank(email), "Email must not be blank");
var key = new UsernameEmail(username, email);
var verification = new Verification();
verification.setCode(RandomStringUtils.randomNumeric(6));
verification.setAttempts(new AtomicInteger(0));
emailVerificationCodeCache.put(key, verification);
return verification.getCode();
}
/**
* Only for test.
*/
boolean contains(String username, String email) {
return emailVerificationCodeCache
.getIfPresent(new UsernameEmail(username, email)) != null;
}
record UsernameEmail(String username, String email) {
}
@Data
@Accessors(chain = true)
static class Verification {
private String code;
private AtomicInteger attempts;
}
}
}

View File

@ -9,6 +9,9 @@ public class ValidationUtils {
public static final Pattern NAME_PATTERN =
Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$");
public static final String EMAIL_REGEX =
"^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
public static final String NAME_VALIDATION_MESSAGE = """
Super administrator username must be a valid subdomain name, the name must:
1. contain no more than 63 characters
@ -30,4 +33,8 @@ public class ValidationUtils {
boolean matches = NAME_PATTERN.matcher(name).matches();
return matches && name.length() <= 63;
}
public static boolean isValidEmail(String email) {
return StringUtils.isNotBlank(email) && email.matches(EMAIL_REGEX);
}
}

View File

@ -0,0 +1,26 @@
package run.halo.app.infra.exception;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerWebInputException;
/**
* Exception thrown when email verification failed.
*
* @author guqing
* @since 2.11.0
*/
public class EmailVerificationFailed extends ServerWebInputException {
public EmailVerificationFailed() {
super("Invalid verification code");
}
public EmailVerificationFailed(String reason, @Nullable Throwable cause) {
super(reason, null, cause);
}
public EmailVerificationFailed(String reason, @Nullable Throwable cause,
@Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) {
super(reason, null, cause, messageDetailCode, messageDetailArguments);
}
}

View File

@ -91,3 +91,11 @@ resilience4j.ratelimiter:
limitForPeriod: 3
limitRefreshPeriod: 1h
timeoutDuration: 0s
send-email-verification-code:
limitForPeriod: 1
limitRefreshPeriod: 1m
timeoutDuration: 0s
verify-email:
limitForPeriod: 3
limitRefreshPeriod: 1h
timeoutDuration: 0s

View File

@ -20,6 +20,7 @@ problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=Pl
problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=Duplicate Name Error
problemDetail.title.run.halo.app.infra.exception.RateLimitExceededException=Request Not Permitted
problemDetail.title.run.halo.app.infra.exception.NotFoundException=Resource Not Found
problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=Email Verification Failed
problemDetail.title.internalServerError=Internal Server Error
# Detail definitions
@ -38,7 +39,9 @@ problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=File
problemDetail.run.halo.app.infra.exception.DuplicateNameException=Duplicate name detected, please rename it and retry.
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists.
problemDetail.run.halo.app.infra.exception.RateLimitExceededException=API rate limit exceeded, please try again later.
problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=Invalid email verification code.
problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later.
problemDetail.user.password.unsatisfied=The password does not meet the specifications.
problemDetail.user.username.unsatisfied=The username does not meet the specifications.
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.

View File

@ -8,6 +8,7 @@ problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=
problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=主题安装失败
problemDetail.title.run.halo.app.infra.exception.RateLimitExceededException=请求限制
problemDetail.title.run.halo.app.infra.exception.NotFoundException=资源不存在
problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=邮箱验证失败
problemDetail.title.internalServerError=服务器内部错误
problemDetail.org.springframework.security.authentication.BadCredentialsException=用户名或密码错误。
@ -15,7 +16,9 @@ problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文
problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
problemDetail.run.halo.app.infra.exception.RateLimitExceededException=请求过于频繁,请稍候再试。
problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=验证码错误或已失效。
problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。
problemDetail.user.password.unsatisfied=密码不符合规范。
problemDetail.user.username.unsatisfied=用户名不符合规范。
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。

View File

@ -99,3 +99,30 @@ spec:
</div>
<div></div>
</div>
---
apiVersion: notification.halo.run/v1alpha1
kind: NotificationTemplate
metadata:
name: template-email-verification
spec:
reasonSelector:
reasonType: email-verification
language: default
template:
title: "邮箱验证-[(${site.title})]"
rawBody: |
【[(${site.title})]】你的邮箱验证码是:[(${code})],请在 [(${expirationAtMinutes})] 分钟内完成验证。
htmlBody: |
<div class="notification-content">
<div class="head">
<p class="honorific" th:text="|${username} 你好:|"></p>
</div>
<div class="body">
<p>使用下面的动态验证码OTP验证您的电子邮件地址。</p>
<div class="verify-code" style="font-size:24px;line-height:24px;color:#333;">
<b th:text="${code}"></b>
</div>
<p th:text="|动态验证码的有效期为 ${expirationAtMinutes} 分钟。|"></p>
<p>如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。</p>
</div>
</div>

View File

@ -143,3 +143,23 @@ spec:
- name: content
type: string
description: "The content of the reply."
---
apiVersion: notification.halo.run/v1alpha1
kind: ReasonType
metadata:
name: email-verification
labels:
halo.run/hide: "true"
spec:
displayName: "邮箱验证"
description: "当你的邮箱被用于注册账户时,会收到一条带有验证码的邮件,你需要点击邮件中的链接来验证邮箱是否属于你。"
properties:
- name: username
type: string
description: "The username of the user."
- name: code
type: string
description: "The verification code."
- name: expirationAtMinutes
type: string
description: "The expiration minutes of the verification code, such as 5 minutes."

View File

@ -45,6 +45,10 @@ rules:
resources: [ "users/avatar" ]
resourceNames: [ "-" ]
verbs: [ "create", "delete" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "users/send-email-verification-code", "users/verify-email" ]
resourceNames: [ "-" ]
verbs: [ "create" ]
---
apiVersion: v1alpha1
kind: "Role"

View File

@ -0,0 +1,115 @@
package run.halo.app.core.extension.endpoint;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import java.time.Duration;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.http.HttpStatus;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.EmailVerificationService;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* Tests for a part of {@link UserEndpoint} about sending email verification code.
*
* @author guqing
* @see UserEndpoint
* @see EmailVerificationService
* @since 2.11.0
*/
@ExtendWith(SpringExtension.class)
@WithMockUser(username = "fake-user", password = "fake-password")
class EmailVerificationCodeTest {
WebTestClient webClient;
@Mock
ReactiveExtensionClient client;
@Mock
EmailVerificationService emailVerificationService;
@InjectMocks
UserEndpoint endpoint;
@BeforeEach
void setUp() {
var spyUserEndpoint = spy(endpoint);
var config = RateLimiterConfig.custom()
.limitRefreshPeriod(Duration.ofSeconds(10))
.limitForPeriod(1)
.build();
var sendCodeRateLimiter = RateLimiterRegistry.of(config)
.rateLimiter("send-email-verification-code-fake-user:hi@halo.run");
doReturn(RateLimiterOperator.of(sendCodeRateLimiter)).when(spyUserEndpoint)
.sendEmailVerificationCodeRateLimiter(eq("fake-user"), eq("hi@halo.run"));
var verifyEmailRateLimiter = RateLimiterRegistry.of(config)
.rateLimiter("verify-email-fake-user");
doReturn(RateLimiterOperator.of(verifyEmailRateLimiter)).when(spyUserEndpoint)
.verificationEmailRateLimiter(eq("fake-user"));
webClient = WebTestClient.bindToRouterFunction(spyUserEndpoint.endpoint()).build()
.mutateWith(csrf());
}
@Test
void sendEmailVerificationCode() {
var user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("fake-user");
user.setSpec(new User.UserSpec());
user.getSpec().setEmail("hi@halo.run");
when(client.get(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user));
when(emailVerificationService.sendVerificationCode(anyString(), anyString()))
.thenReturn(Mono.empty());
webClient.post()
.uri("/users/-/send-email-verification-code")
.bodyValue(Map.of("email", "hi@halo.run"))
.exchange()
.expectStatus()
.isOk();
// request again to trigger rate limit
webClient.post()
.uri("/users/-/send-email-verification-code")
.bodyValue(Map.of("email", "hi@halo.run"))
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
}
@Test
void verifyEmail() {
when(emailVerificationService.verify(anyString(), anyString()))
.thenReturn(Mono.empty());
webClient.post()
.uri("/users/-/verify-email")
.bodyValue(Map.of("code", "fake-code-1"))
.exchange()
.expectStatus()
.isOk();
// request again to trigger rate limit
webClient.post()
.uri("/users/-/verify-email")
.bodyValue(Map.of("code", "fake-code-2"))
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
}
}

View File

@ -0,0 +1,86 @@
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 static run.halo.app.core.extension.service.impl.EmailVerificationServiceImpl.MAX_ATTEMPTS;
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.EmailVerificationFailed;
/**
* Tests for {@link EmailVerificationServiceImpl}.
*
* @author guqing
* @since 2.11.0
*/
@ExtendWith(MockitoExtension.class)
class EmailVerificationServiceImplTest {
@Nested
class EmailVerificationManagerTest {
@Test
public void generateCodeTest() {
var emailVerificationManager =
new EmailVerificationServiceImpl.EmailVerificationManager();
emailVerificationManager.generateCode("fake-user", "fake-email");
var result = emailVerificationManager.contains("fake-user", "fake-email");
assertThat(result).isTrue();
emailVerificationManager.generateCode("guqing", "hi@halo.run");
result = emailVerificationManager.contains("guqing", "hi@halo.run");
assertThat(result).isTrue();
result = emailVerificationManager.contains("123", "123");
assertThat(result).isFalse();
}
@Test
public void removeTest() {
var emailVerificationManager =
new EmailVerificationServiceImpl.EmailVerificationManager();
emailVerificationManager.generateCode("fake-user", "fake-email");
var result = emailVerificationManager.contains("fake-user", "fake-email");
emailVerificationManager.removeCode("fake-user", "fake-email");
result = emailVerificationManager.contains("fake-user", "fake-email");
assertThat(result).isFalse();
}
@Test
void verifyCodeTestNormal() {
String username = "guqing";
String email = "hi@halo.run";
var emailVerificationManager =
new EmailVerificationServiceImpl.EmailVerificationManager();
var result = emailVerificationManager.verifyCode(username, email, "fake-code");
assertThat(result).isFalse();
var code = emailVerificationManager.generateCode(username, email);
result = emailVerificationManager.verifyCode(username, email, "fake-code");
assertThat(result).isFalse();
result = emailVerificationManager.verifyCode(username, email, code);
assertThat(result).isTrue();
}
@Test
void verifyCodeFailedAfterMaxAttempts() {
String username = "guqing";
String email = "example@example.com";
var emailVerificationManager =
new EmailVerificationServiceImpl.EmailVerificationManager();
var code = emailVerificationManager.generateCode(username, email);
for (int i = 0; i <= MAX_ATTEMPTS; i++) {
var result = emailVerificationManager.verifyCode(username, email, "fake-code");
assertThat(result).isFalse();
}
assertThatThrownBy(() -> emailVerificationManager.verifyCode(username, email, code))
.isInstanceOf(EmailVerificationFailed.class)
.hasMessage("400 BAD_REQUEST \"Too many attempts. Please try again later.\"");
}
}
}

View File

@ -2,6 +2,7 @@ package run.halo.app.infra;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.HashMap;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -56,4 +57,36 @@ class ValidationUtilsTest {
assertThat(ValidationUtils.validateName("ast-1")).isTrue();
}
}
@Test
void validateEmailTest() {
var cases = new HashMap<String, Boolean>();
// Valid cases
cases.put("simple@example.com", true);
cases.put("very.common@example.com", true);
cases.put("disposable.style.email.with+symbol@example.com", true);
cases.put("other.email-with-hyphen@example.com", true);
cases.put("fully-qualified-domain@example.com", true);
cases.put("user.name+tag+sorting@example.com", true);
cases.put("x@example.com", true);
cases.put("example-indeed@strange-example.com", true);
cases.put("example@s.example", true);
cases.put("john.doe@example.com", true);
cases.put("a.little.lengthy.but.fine@dept.example.com", true);
cases.put("123ada@halo.co", true);
cases.put("23ad@halo.top", true);
// Invalid cases
cases.put("Abc.example.com", false);
cases.put("admin@mailserver1", false);
cases.put("\" \"@example.org", false);
cases.put("A@b@c@example.com", false);
cases.put("a\"b(c)d,e:f;g<h>i[j\\k]l@example.com", false);
cases.put("just\"not\"right@example.com", false);
cases.put("this is\"not\\allowed@example.com", false);
cases.put("this\\ still\\\"not\\\\allowed@example.com", false);
cases.put("123456789012345678901234567890123456789012345", false);
cases.forEach((email, expected) -> assertThat(ValidationUtils.isValidEmail(email))
.isEqualTo(expected));
}
}

View File

@ -54,6 +54,7 @@ class UserVoTest {
"displayName": "fake-user-display-name",
"avatar": "avatar",
"email": "example@example.com",
"emailVerified": false,
"phone": "123456789",
"password": "[PROTECTED]",
"bio": "user bio",

View File

@ -39,6 +39,7 @@ api/content-halo-run-v1alpha1-reply-api.ts
api/content-halo-run-v1alpha1-single-page-api.ts
api/content-halo-run-v1alpha1-snapshot-api.ts
api/content-halo-run-v1alpha1-tag-api.ts
api/doc-halo-run-v1alpha1-doc-tree-api.ts
api/login-api.ts
api/metrics-halo-run-v1alpha1-counter-api.ts
api/migration-halo-run-v1alpha1-backup-api.ts
@ -123,6 +124,10 @@ models/create-user-request.ts
models/custom-templates.ts
models/dashboard-stats.ts
models/detailed-user.ts
models/doc-tree-list.ts
models/doc-tree-status.ts
models/doc-tree.ts
models/email-verify-request.ts
models/excerpt.ts
models/extension-definition-list.ts
models/extension-definition.ts
@ -257,6 +262,7 @@ models/site-stats-vo.ts
models/snap-shot-spec.ts
models/snapshot-list.ts
models/snapshot.ts
models/spec.ts
models/stats-vo.ts
models/stats.ts
models/subject.ts
@ -287,4 +293,5 @@ models/user-permission.ts
models/user-spec.ts
models/user-status.ts
models/user.ts
models/verify-code-request.ts
models/vote-request.ts

View File

@ -125,7 +125,7 @@ export const ApiConsoleHaloRunV1alpha1SystemApiFp = function (
systemInitializationRequest?: SystemInitializationRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<boolean>
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.initialize(
systemInitializationRequest,
@ -161,7 +161,7 @@ export const ApiConsoleHaloRunV1alpha1SystemApiFactory = function (
initialize(
requestParameters: ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<boolean> {
): AxiosPromise<void> {
return localVarFp
.initialize(requestParameters.systemInitializationRequest, options)
.then((request) => request(axios, basePath));

View File

@ -44,6 +44,8 @@ import { CreateUserRequest } from "../models";
// @ts-ignore
import { DetailedUser } from "../models";
// @ts-ignore
import { EmailVerifyRequest } from "../models";
// @ts-ignore
import { GrantRequest } from "../models";
// @ts-ignore
import { User } from "../models";
@ -51,6 +53,8 @@ import { User } from "../models";
import { UserEndpointListedUserList } from "../models";
// @ts-ignore
import { UserPermission } from "../models";
// @ts-ignore
import { VerifyCodeRequest } from "../models";
/**
* ApiConsoleHaloRunV1alpha1UserApi - axios parameter creator
* @export
@ -546,6 +550,67 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
options: localVarRequestOptions,
};
},
/**
* Send email verification code for user
* @param {EmailVerifyRequest} emailVerifyRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendEmailVerificationCode: async (
emailVerifyRequest: EmailVerifyRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'emailVerifyRequest' is not null or undefined
assertParamExists(
"sendEmailVerificationCode",
"emailVerifyRequest",
emailVerifyRequest
);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/users/-/send-email-verification-code`;
// 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(
emailVerifyRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Update current user profile, but password.
* @param {User} user
@ -666,6 +731,63 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
};
localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Verify email for user by code.
* @param {VerifyCodeRequest} verifyCodeRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
verifyEmail: async (
verifyCodeRequest: VerifyCodeRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'verifyCodeRequest' is not null or undefined
assertParamExists("verifyEmail", "verifyCodeRequest", verifyCodeRequest);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/users/-/verify-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(
verifyCodeRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -889,6 +1011,30 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
configuration
);
},
/**
* Send email verification code for user
* @param {EmailVerifyRequest} emailVerifyRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async sendEmailVerificationCode(
emailVerifyRequest: EmailVerifyRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.sendEmailVerificationCode(
emailVerifyRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Update current user profile, but password.
* @param {User} user
@ -933,6 +1079,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
configuration
);
},
/**
* Verify email for user by code.
* @param {VerifyCodeRequest} verifyCodeRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async verifyEmail(
verifyCodeRequest: VerifyCodeRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.verifyEmail(
verifyCodeRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
@ -1074,6 +1243,23 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
)
.then((request) => request(axios, basePath));
},
/**
* Send email verification code for user
* @param {ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCodeRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendEmailVerificationCode(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCodeRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.sendEmailVerificationCode(
requestParameters.emailVerifyRequest,
options
)
.then((request) => request(axios, basePath));
},
/**
* Update current user profile, but password.
* @param {ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest} requestParameters Request parameters.
@ -1106,6 +1292,20 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
)
.then((request) => request(axios, basePath));
},
/**
* Verify email for user by code.
* @param {ApiConsoleHaloRunV1alpha1UserApiVerifyEmailRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
verifyEmail(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiVerifyEmailRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.verifyEmail(requestParameters.verifyCodeRequest, options)
.then((request) => request(axios, basePath));
},
};
};
@ -1263,6 +1463,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
readonly sort?: Array<string>;
}
/**
* Request parameters for sendEmailVerificationCode operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCodeRequest
*/
export interface ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCodeRequest {
/**
*
* @type {EmailVerifyRequest}
* @memberof ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCode
*/
readonly emailVerifyRequest: EmailVerifyRequest;
}
/**
* Request parameters for updateCurrentUser operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export
@ -1298,6 +1512,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest {
readonly file: File;
}
/**
* Request parameters for verifyEmail operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1UserApiVerifyEmailRequest
*/
export interface ApiConsoleHaloRunV1alpha1UserApiVerifyEmailRequest {
/**
*
* @type {VerifyCodeRequest}
* @memberof ApiConsoleHaloRunV1alpha1UserApiVerifyEmail
*/
readonly verifyCodeRequest: VerifyCodeRequest;
}
/**
* ApiConsoleHaloRunV1alpha1UserApi - object-oriented interface
* @export
@ -1446,6 +1674,22 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath));
}
/**
* Send email verification code for user
* @param {ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCodeRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1UserApi
*/
public sendEmailVerificationCode(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiSendEmailVerificationCodeRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
.sendEmailVerificationCode(requestParameters.emailVerifyRequest, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Update current user profile, but password.
* @param {ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest} requestParameters Request parameters.
@ -1477,4 +1721,20 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
.uploadUserAvatar(requestParameters.name, requestParameters.file, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Verify email for user by code.
* @param {ApiConsoleHaloRunV1alpha1UserApiVerifyEmailRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1UserApi
*/
public verifyEmail(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiVerifyEmailRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
.verifyEmail(requestParameters.verifyCodeRequest, options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -0,0 +1,27 @@
/* 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 EmailVerifyRequest
*/
export interface EmailVerifyRequest {
/**
*
* @type {string}
* @memberof EmailVerifyRequest
*/
email: string;
}

View File

@ -47,6 +47,7 @@ export * from "./create-user-request";
export * from "./custom-templates";
export * from "./dashboard-stats";
export * from "./detailed-user";
export * from "./email-verify-request";
export * from "./excerpt";
export * from "./extension";
export * from "./extension-definition";
@ -180,6 +181,7 @@ export * from "./site-stats-vo";
export * from "./snap-shot-spec";
export * from "./snapshot";
export * from "./snapshot-list";
export * from "./spec";
export * from "./stats";
export * from "./stats-vo";
export * from "./subject";
@ -210,4 +212,5 @@ export * from "./user-list";
export * from "./user-permission";
export * from "./user-spec";
export * from "./user-status";
export * from "./verify-code-request";
export * from "./vote-request";

View File

@ -48,6 +48,12 @@ export interface UserSpec {
* @memberof UserSpec
*/
email: string;
/**
*
* @type {boolean}
* @memberof UserSpec
*/
emailVerified?: boolean;
/**
*
* @type {number}

View File

@ -0,0 +1,27 @@
/* 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 VerifyCodeRequest
*/
export interface VerifyCodeRequest {
/**
*
* @type {string}
* @memberof VerifyCodeRequest
*/
code: string;
}

View File

@ -0,0 +1,27 @@
/* 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 VerifyEmailRequest
*/
export interface VerifyEmailRequest {
/**
*
* @type {string}
* @memberof VerifyEmailRequest
*/
code: string;
}

View File

@ -90,7 +90,7 @@ const activeTab = useRouteQuery<string>("tab", tabs[0].id, {
});
</script>
<template>
<ProfileEditingModal v-model:visible="editingModal" :user="user?.user" />
<ProfileEditingModal v-model:visible="editingModal" />
<PasswordChangeModal
v-model:visible="passwordChangeModal"

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import { Toast, VButton, VSpace } from "@halo-dev/components";
import { VModal } from "@halo-dev/components";
import { nextTick, onMounted, ref } from "vue";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { apiClient } from "@/utils/api-client";
import { useUserStore } from "@/stores/user";
import { useIntervalFn } from "@vueuse/shared";
import { computed } from "vue";
const queryClient = useQueryClient();
const { currentUser, fetchCurrentUser } = useUserStore();
const emit = defineEmits<{
(event: "close"): void;
}>();
// fixme: refactor VModal component
const shouldRender = ref(false);
const visible = ref(false);
onMounted(() => {
shouldRender.value = true;
nextTick(() => {
visible.value = true;
});
});
function onClose() {
visible.value = false;
setTimeout(() => {
shouldRender.value = false;
emit("close");
}, 200);
}
// count down
const timer = ref(0);
const { pause, resume, isActive } = useIntervalFn(
() => {
if (timer.value <= 0) {
pause();
} else {
timer.value--;
}
},
1000,
{
immediate: false,
}
);
const email = ref(currentUser?.spec.email);
const { mutate: sendVerifyCode, isLoading: isSending } = useMutation({
mutationKey: ["send-verify-code"],
mutationFn: async () => {
if (!email.value) {
Toast.error("请输入电子邮箱");
throw new Error("email is empty");
}
return await apiClient.user.sendEmailVerificationCode({
emailVerifyRequest: {
email: email.value,
},
});
},
onSuccess() {
Toast.success("验证码已发送");
timer.value = 60;
resume();
},
});
const sendVerifyCodeButtonText = computed(() => {
if (isSending.value) {
return "发送中";
}
return isActive.value ? `${timer.value} 秒后重发` : "发送验证码";
});
const { mutate: verifyEmail, isLoading: isVerifying } = useMutation({
mutationKey: ["verify-email"],
mutationFn: async ({ code }: { code: string }) => {
return await apiClient.user.verifyEmail({
verifyCodeRequest: {
code: code,
},
});
},
onSuccess() {
Toast.success("验证成功");
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
fetchCurrentUser();
onClose();
},
});
function handleVerify(data: { code: string }) {
verifyEmail({ code: data.code });
}
</script>
<template>
<VModal
v-if="shouldRender"
v-model:visible="visible"
:title="currentUser?.spec.emailVerified ? '修改电子邮箱' : '绑定电子邮箱'"
@close="onClose"
>
<FormKit
id="email-verify-form"
type="form"
name="email-verify-form"
@submit="handleVerify"
>
<FormKit
v-model="email"
type="email"
:label="currentUser?.spec.emailVerified ? '新电子邮箱' : '电子邮箱'"
name="email"
validation="required|email"
></FormKit>
<FormKit type="number" name="code" label="验证码" validation="required">
<template #suffix>
<VButton
:loading="isSending"
:disabled="isActive"
class="rounded-none border-y-0 border-l border-r-0 tabular-nums"
@click="sendVerifyCode"
>
{{ sendVerifyCodeButtonText }}
</VButton>
</template>
</FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton
:loading="isVerifying"
type="secondary"
@click="$formkit.submit('email-verify-form')"
>
验证
</VButton>
<VButton @click="emit('close')"></VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -16,18 +16,19 @@ import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import { useUserStore } from "@/stores/user";
import EmailVerifyModal from "./EmailVerifyModal.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
const userStore = useUserStore();
const props = withDefaults(
defineProps<{
visible: boolean;
user?: User;
}>(),
{
visible: false,
user: undefined,
}
);
@ -65,7 +66,8 @@ watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.user) formState.value = cloneDeep(props.user);
if (userStore.currentUser)
formState.value = cloneDeep(userStore.currentUser);
setFocus("displayNameInput");
} else {
handleResetForm();
@ -97,8 +99,18 @@ const handleUpdateUser = async () => {
console.error("Failed to update profile", e);
} finally {
saving.value = false;
userStore.fetchCurrentUser();
}
};
// verify email
const emailVerifyModal = ref(false);
async function onEmailVerifyModalClose() {
emailVerifyModal.value = false;
await userStore.fetchCurrentUser();
if (userStore.currentUser) formState.value = cloneDeep(userStore.currentUser);
}
</script>
<template>
<VModal
@ -145,8 +157,18 @@ const handleUpdateUser = async () => {
:label="$t('core.user.editing_modal.fields.email.label')"
type="email"
name="email"
readonly
validation="required|email|length:0,100"
></FormKit>
>
<template #suffix>
<VButton
class="rounded-none border-y-0 border-l border-r-0"
@click="emailVerifyModal = true"
>
修改
</VButton>
</template>
</FormKit>
<FormKit
v-model="formState.spec.phone"
:label="$t('core.user.editing_modal.fields.phone.label')"
@ -182,4 +204,6 @@ const handleUpdateUser = async () => {
</VSpace>
</template>
</VModal>
<EmailVerifyModal v-if="emailVerifyModal" @close="onEmailVerifyModalClose" />
</template>

View File

@ -2,6 +2,7 @@
import {
Dialog,
IconUserSettings,
VAlert,
VButton,
VDescription,
VDescriptionItem,
@ -16,6 +17,8 @@ import { useQuery } from "@tanstack/vue-query";
import { apiClient } from "@/utils/api-client";
import axios from "axios";
import { useI18n } from "vue-i18n";
import EmailVerifyModal from "../components/EmailVerifyModal.vue";
import { ref } from "vue";
const user = inject<Ref<DetailedUser | undefined>>("user");
@ -63,6 +66,9 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
authProvider.bindingUrl
}?redirect_uri=${encodeURIComponent(window.location.href)}`;
};
// verify email
const emailVerifyModal = ref(false);
</script>
<template>
<div class="border-t border-gray-100">
@ -79,9 +85,40 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
/>
<VDescriptionItem
:label="$t('core.user.detail.fields.email')"
:content="user?.user.spec.email || $t('core.common.text.none')"
class="!px-2"
/>
>
<div v-if="user" class="w-full xl:w-1/2">
<VAlert
v-if="!user.user.spec.email"
title="设置电子邮箱"
description="电子邮箱地址还未设置,点击下方按钮进行设置"
type="warning"
:closable="false"
>
<template #actions>
<VButton size="sm" @click="emailVerifyModal = true">设置</VButton>
</template>
</VAlert>
<div v-else>
<span>{{ user.user.spec.email }}</span>
<div v-if="!user.user.spec.emailVerified" class="mt-3">
<VAlert
title="验证电子邮箱"
description="电子邮箱地址还未验证,点击下方按钮进行验证"
type="warning"
:closable="false"
>
<template #actions>
<VButton size="sm" @click="emailVerifyModal = true">
验证
</VButton>
</template>
</VAlert>
</div>
</div>
</div>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.user.detail.fields.roles')"
class="!px-2"
@ -151,5 +188,10 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
</ul>
</VDescriptionItem>
</VDescription>
<EmailVerifyModal
v-if="emailVerifyModal"
@close="emailVerifyModal = false"
/>
</div>
</template>

View File

@ -0,0 +1,129 @@
## 背景
在 Halo 中,邮箱作为用户主要的身份识别和通信方式,不仅有助于确保用户提供的邮箱地址的有效性和所有权,还对于减少滥用行为、提高账户安全性以及确保用户可以接收重要通知(如密码重置、注册新账户、确认重要操作等)至关重要。
邮箱验证是用户管理过程中的一个关键组成部分,可以帮助维护了一个健康、可靠的用户基础,并且为系统管理员提供了一个额外的安全和管理手段,因此实现一个高效、安全且用户友好的邮箱验证功能至关重要。
## 需求
1. **用户注册验证**:确保新用户在注册过程中提供有效的邮箱地址。邮箱验证作为新用户激活其账户的必要步骤,有助于减少虚假账户和提升用户的整体质量。
2. **密码重置和安全操作**:在用户忘记密码或需要重置密码时,向已验证的邮箱地址发送密码重置链接来确保安全性。
3. **用户通知**:验证邮箱地址有助于确保用户可以接收到重要通知,如文章被评论、有新回复等。
## 目标
- 支持用户在修改邮箱后支持重新进行邮箱验证。
- 允许用户在未收到邮件或邮件过期时重新请求发送验证邮件。
- 避免邮件通知被滥用,如频繁发送验证邮件,需要添加限制。
- 验证码过期机制,以确保验证邮件的有效性和安全性。
## 非目标
- 不考虑用户多邮箱地址的验证。
## 方案
### EmailVerificationManager
通过使用 guava 提供的 Cache 来实现一个 EmailVerificationManager 来管理邮箱验证的缓存。
```java
class EmailVerificationManager {
private final Cache<UsernameEmail, Verification> emailVerificationCodeCache =
CacheBuilder.newBuilder()
.expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
private final Cache<UsernameEmail, Boolean> blackListCache = CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(1000)
.build();
record UsernameEmail(String username, String email) {
}
@Data
@Accessors(chain = true)
static class Verification {
private String code;
private AtomicInteger attempts;
}
}
```
当用户请求发送验证邮件时,会生成一个随机的验证码,并将其存储在缓存中,默认有效期为 10 分钟,当十分钟内用户未验证成功,验证码会自动过期被缓存清除。
用户可以在十分钟内重新请求发送验证邮件,此时会生成一个新的验证码有效期依然为 10 分钟。但会限制用户发送频率,同一个用户的邮箱发送验证邮件的时间间隔不得小于
1 分钟,以防止滥用。
当用户请求验证邮箱时,会从缓存中获取验证码,如果验证码不存在或已过期,会提示验证码无效或已过期,如果验证码存在且未过期,会进行验证码的比对,如果验证码不正确,会提示验证码无效,如果验证码正确,会将用户邮箱地址标记为已验证,并从缓存中清除验证码。
如果用户反复使用 code 验证邮箱,会记录失败次数,如果达到了默认的最大尝试次数(默认为 5 次),将被加入黑名单,需要 1
小时后才能重新验证邮件。
根据上述规则:
- 每个验证码有10分钟的有效期。
- 在这10分钟内如果失败次数超过5次用户会被加入黑名单禁止验证1小时。
- 如果在10分钟内尝试了5次且失败然后请求重新发送验证码可以再次尝试5次。
那么:
- 在不触发黑名单的情况下每10分钟可以尝试5次。
- 一小时内,可以尝试 (60/10) * 5 = 30 次前提是每10分钟都请求一次新的验证码。
- 但是如果在任何10分钟内尝试超过5次则会被禁止1小时。
因此,为了最大化尝试次数而不触发黑名单,每小时可以尝试 30 次预计一天内24h最多可以尝试 720 次验证码。
验证码的组成为随机的 6 为数字,可能组合总数:一个 6 位数字的验证码可以从 000000 到 999999总共有 10 <sup>6</sup> 种可能的组合。
10 <sup>6</sup> / 720 = 1388因此预计最坏情况下需要 1388 天可以破解验证码。这个时间足够长,可以认为非常安全的。
### 提供 APIs 用于处理验证请求
- `POST /apis/v1alpha1/users/-/send-verification-email`:用于请求发送验证邮件来验证邮箱地址。
- `POST /apis/v1alpha1/users/-/verify-email`:用于根据邮箱验证码来验证邮箱地址。
以上两个 APIs 认证用户都可以访问,但会对请求进行限制,请求间隔不得小于 1 分钟,以防止滥用。
并且会在用户个人资料 API 中添加 emailVerified 字段,用于标识用户邮箱是否已验证。
### 验证码邮件通知
只会通过用户请求验证的邮箱地址发送验证邮件,并且提供了以下变量用户自定义通知模板:
- **username**: 请求验证邮件地址的用户名。
- **code**: 验证码。
- **expirationAtMinutes**: 验证码过期时间(分钟)。
验证邮件默认模板示例内容如下:
```markdown
guqing 你好:
使用下面的动态验证码OTP验证您的电子邮件地址。
277436
动态验证码的有效期为 10 分钟。如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。
guqing's blog
```
### 安全和异常处理
- 确保所有敏感数据安全传输,当验证码不正确或过期时,只应该提示一个通用的错误信息防止用户猜测或爆破验证码。
- 异常提示多语言支持。
## 结论
通过实施上述方案,考虑到了以下情况:
1. 新邮箱验证请求
2. 用户邮箱地址更新
3. 用户请求重新发送验证邮件
4. 邮件发送失败
5. 验证码有效期
6. 发送频率限制
7. 验证状态的指示和反馈
我们将能够提供一个安全、可靠且用户友好的邮箱验证功能。