diff --git a/api/src/main/java/run/halo/app/core/extension/User.java b/api/src/main/java/run/halo/app/core/extension/User.java index 045bf5f55a..0f5e56fbef 100644 --- a/api/src/main/java/run/halo/app/core/extension/User.java +++ b/api/src/main/java/run/halo/app/core/extension/User.java @@ -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 LAST_USED_EMAIL_ANNO = "halo.run/last-used-email"; + public static final String LAST_AVATAR_ATTACHMENT_NAME_ANNO = "halo.run/last-avatar-attachment-name"; @@ -58,6 +60,8 @@ public static class UserSpec { @Schema(requiredMode = REQUIRED) private String email; + private boolean emailVerified; + private String phone; private String password; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index 47441a1d4d..f91d570835 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -17,9 +17,13 @@ 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.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,7 @@ import run.halo.app.extension.router.IListRequest; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.utils.JsonUtils; @Component @@ -92,6 +99,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 +210,83 @@ public RouterFunction endpoint() { ) .response(responseBuilder().implementation(User.class)) .build()) + .POST("users/-/send-email-verification-code", + this::sendEmailVerificationCode, + builder -> builder + .tag(tag) + .operationId("SendEmailVerificationCode") + .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(VerifyEmailRequest.class)) + .response(responseBuilder().implementation(Void.class)) + .build() + ) .build(); } + private Mono verifyEmail(ServerRequest request) { + return request.bodyToMono(VerifyEmailRequest.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 VerifyEmailRequest(@Schema(requiredMode = REQUIRED, minLength = 1) String code) { + } + + private Mono sendEmailVerificationCode(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Principal::getName) + .flatMap(username -> client.get(User.class, username)) + .flatMap(user -> { + var email = user.getSpec().getEmail(); + var username = user.getMetadata().getName(); + return Mono.just(user) + .transformDeferred(sendEmailVerificationCodeRateLimiter(username, email)) + .flatMap(u -> emailVerificationService.sendVerificationCode(username)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + }) + .then(ServerResponse.ok().build()); + } + + RateLimiterOperator verificationEmailRateLimiter(String username) { + String rateLimiterKey = "verify-email-" + username; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "verify-email"); + return RateLimiterOperator.of(rateLimiter); + } + + RateLimiterOperator 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 deleteUserAvatar(ServerRequest request) { final var nameInPath = request.pathVariable("name"); return getUserOrSelf(nameInPath) @@ -396,6 +479,8 @@ private Mono updateProfile(ServerRequest request) { 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.LAST_USED_EMAIL_ANNO, + oldAnnotations.get(User.LAST_USED_EMAIL_ANNO)); currentUser.getMetadata().setAnnotations(newAnnotations); } var spec = currentUser.getSpec(); @@ -405,6 +490,12 @@ private Mono updateProfile(ServerRequest request) { spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled()); spec.setEmail(newSpec.getEmail()); spec.setPhone(newSpec.getPhone()); + + // if email changed, set email verified to false + var oldEmail = oldAnnotations.get(User.LAST_USED_EMAIL_ANNO); + if (StringUtils.isNotBlank(oldEmail) && !oldEmail.equals(newSpec.getEmail())) { + spec.setEmailVerified(false); + } return currentUser; }) ) diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java index 927c50d750..89dfca33d9 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java @@ -56,7 +56,7 @@ public Result reconcile(Request request) { } addFinalizerIfNecessary(user); - ensureRoleNamesAnno(request.name()); + ensureImportantAnnotation(request.name()); updatePermalink(request.name()); handleAvatar(request.name()); }); @@ -100,13 +100,14 @@ private void handleAvatar(String name) { }); } - private void ensureRoleNamesAnno(String name) { + private void ensureImportantAnnotation(String name) { client.fetch(User.class, name).ifPresent(user -> { Map annotations = MetadataUtil.nullSafeAnnotations(user); Map oldAnnotations = Map.copyOf(annotations); List roleNames = listRoleNamesRef(name); annotations.put(User.ROLE_NAMES_ANNO, JsonUtils.objectToJson(roleNames)); + annotations.put(User.LAST_USED_EMAIL_ANNO, user.getSpec().getEmail()); if (!oldAnnotations.equals(annotations)) { client.update(user); diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java b/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java new file mode 100644 index 0000000000..87e55591ce --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java @@ -0,0 +1,29 @@ +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 obtain email + */ + Mono sendVerificationCode(String username); + + /** + * Verify email by given username and code. + * + * @param username username to obtain email + * @param code code to verify + * @throws EmailVerificationFailed if send failed + */ + Mono verify(String username, String code); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java new file mode 100644 index 0000000000..dd000f2211 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java @@ -0,0 +1,221 @@ +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.ArrayList; +import java.util.List; +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.reactivestreams.Publisher; +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 sendVerificationCode(String username) { + return client.get(User.class, username) + .flatMap(user -> { + var email = user.getSpec().getEmail(); + if (StringUtils.isBlank(email)) { + return Mono.error( + () -> new ServerWebInputException("Email must not be blank.")); + } + if (user.getSpec().isEmailVerified()) { + return Mono.error(() -> new ServerWebInputException("Email already verified.")); + } + return sendVerificationNotification(user); + }); + } + + @Override + public Mono verify(String username, String code) { + return Mono.defer(() -> client.get(User.class, username) + .flatMap(user -> { + var email = user.getSpec().getEmail(); + var verified = emailVerificationManager.verifyCode(username, email, code); + if (!verified) { + return Mono.error(EmailVerificationFailed::new); + } + user.getSpec().setEmailVerified(true); + emailVerificationManager.removeCode(username, email); + return client.update(user); + }) + ) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)) + .then(); + } + + Mono sendVerificationNotification(User user) { + var email = user.getSpec().getEmail(); + var username = user.getMetadata().getName(); + var oldEmail = MetadataUtil.nullSafeAnnotations(user) + .get(User.LAST_USED_EMAIL_ANNO); + var code = emailVerificationManager.generateCode(username, email); + var subscribeNotification = autoSubscribeVerificationEmail(email, oldEmail); + 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 autoSubscribeVerificationEmail(String email, String oldEmail) { + List> publishers = new ArrayList<>(2); + if (StringUtils.isNotBlank(oldEmail) && !oldEmail.equals(email)) { + var subscriber = new Subscription.Subscriber(); + subscriber.setName(UserIdentity.anonymousWithEmail(oldEmail).name()); + var unsubMono = notificationCenter.unsubscribe(subscriber, + createInterestReason(oldEmail)); + publishers.add(unsubMono); + } + var subscriber = new Subscription.Subscriber(); + subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); + var interestReason = createInterestReason(email); + var subMono = notificationCenter.subscribe(subscriber, interestReason); + publishers.add(subMono); + return Mono.when(publishers); + } + + 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 emailVerificationCodeCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES) + .maximumSize(10000) + .build(); + + private final Cache 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; + } + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java b/application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java new file mode 100644 index 0000000000..40074ba1a9 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java @@ -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); + } +} diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 0bd9b2e56c..28d4bf58ff 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -64,7 +64,7 @@ management: endpoints: web: exposure: - include: ["health", "info", "startup", "globalinfo", "logfile", "shutdown", "restart"] + include: [ "health", "info", "startup", "globalinfo", "logfile", "shutdown", "restart" ] endpoint: shutdown: enabled: true @@ -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 diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties index 4870b7ffdb..c32006c1b3 100644 --- a/application/src/main/resources/config/i18n/messages.properties +++ b/application/src/main/resources/config/i18n/messages.properties @@ -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. diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties index cfe6ef98d6..b51796f066 100644 --- a/application/src/main/resources/config/i18n/messages_zh.properties +++ b/application/src/main/resources/config/i18n/messages_zh.properties @@ -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=系统不允许注册新用户。 diff --git a/application/src/main/resources/extensions/notification-templates.yaml b/application/src/main/resources/extensions/notification-templates.yaml index efee330588..cb80998493 100644 --- a/application/src/main/resources/extensions/notification-templates.yaml +++ b/application/src/main/resources/extensions/notification-templates.yaml @@ -99,3 +99,30 @@ spec:
+--- +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: | +
+
+

+
+
+

使用下面的动态验证码(OTP)验证您的电子邮件地址。

+
+ +
+

+

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

+
+
diff --git a/application/src/main/resources/extensions/notification.yaml b/application/src/main/resources/extensions/notification.yaml index b50cf5b6dd..b3d8cd655f 100644 --- a/application/src/main/resources/extensions/notification.yaml +++ b/application/src/main/resources/extensions/notification.yaml @@ -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." diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml index dff7d74dcc..53ee8400b4 100644 --- a/application/src/main/resources/extensions/role-template-authenticated.yaml +++ b/application/src/main/resources/extensions/role-template-authenticated.yaml @@ -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" diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java new file mode 100644 index 0000000000..3ec5a99fef --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java @@ -0,0 +1,113 @@ +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())) + .thenReturn(Mono.empty()); + webClient.post() + .uri("/users/-/send-email-verification-code") + .exchange() + .expectStatus() + .isOk(); + + // request again to trigger rate limit + webClient.post() + .uri("/users/-/send-email-verification-code") + .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); + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java new file mode 100644 index 0000000000..81a99bf028 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java @@ -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.\""); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java b/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java index 34ac1a8e8d..95016f02ca 100644 --- a/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java @@ -54,6 +54,7 @@ void from() throws JSONException { "displayName": "fake-user-display-name", "avatar": "avatar", "email": "example@example.com", + "emailVerified": false, "phone": "123456789", "password": "[PROTECTED]", "bio": "user bio", diff --git a/docs/email-verification/README.md b/docs/email-verification/README.md new file mode 100644 index 0000000000..d58b9b9f24 --- /dev/null +++ b/docs/email-verification/README.md @@ -0,0 +1,129 @@ +## 背景 + +在 Halo 中,邮箱作为用户主要的身份识别和通信方式,不仅有助于确保用户提供的邮箱地址的有效性和所有权,还对于减少滥用行为、提高账户安全性以及确保用户可以接收重要通知(如密码重置、注册新账户、确认重要操作等)至关重要。 + +邮箱验证是用户管理过程中的一个关键组成部分,可以帮助维护了一个健康、可靠的用户基础,并且为系统管理员提供了一个额外的安全和管理手段,因此实现一个高效、安全且用户友好的邮箱验证功能至关重要。 + +## 需求 + +1. **用户注册验证**:确保新用户在注册过程中提供有效的邮箱地址。邮箱验证作为新用户激活其账户的必要步骤,有助于减少虚假账户和提升用户的整体质量。 +2. **密码重置和安全操作**:在用户忘记密码或需要重置密码时,向已验证的邮箱地址发送密码重置链接来确保安全性。 +3. **用户通知**:验证邮箱地址有助于确保用户可以接收到重要通知,如文章被评论、有新回复等。 + +## 目标 + +- 支持用户在修改邮箱后支持重新进行邮箱验证。 +- 允许用户在未收到邮件或邮件过期时重新请求发送验证邮件。 +- 避免邮件通知被滥用,如频繁发送验证邮件,需要添加限制。 +- 验证码过期机制,以确保验证邮件的有效性和安全性。 + +## 非目标 + +- 不考虑用户多邮箱地址的验证。 + +## 方案 + +### EmailVerificationManager + +通过使用 guava 提供的 Cache 来实现一个 EmailVerificationManager 来管理邮箱验证的缓存。 + +```java +class EmailVerificationManager { + private final Cache emailVerificationCodeCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES) + .maximumSize(10000) + .build(); + + private final Cache 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 6 种可能的组合。 +10 6 / 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. 验证状态的指示和反馈 + +我们将能够提供一个安全、可靠且用户友好的邮箱验证功能。