mirror of https://github.com/halo-dev/halo
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
parent
82a6ba6d90
commit
96d4897d11
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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=系统不允许注册新用户。
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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.\"");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -48,6 +48,12 @@ export interface UserSpec {
|
|||
* @memberof UserSpec
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof UserSpec
|
||||
*/
|
||||
emailVerified?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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. 验证状态的指示和反馈
|
||||
|
||||
我们将能够提供一个安全、可靠且用户友好的邮箱验证功能。
|
Loading…
Reference in New Issue