Skip to content

Commit

Permalink
feat: support user email verification mechanism
Browse files Browse the repository at this point in the history
Signed-off-by: guqing <1484563614@qq.com>
  • Loading branch information
guqing committed Nov 21, 2023
1 parent 747cab3 commit 21849b2
Show file tree
Hide file tree
Showing 16 changed files with 769 additions and 3 deletions.
4 changes: 4 additions & 0 deletions api/src/main/java/run/halo/app/core/extension/User.java
Expand Up @@ -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";

Expand All @@ -58,6 +60,8 @@ public static class UserSpec {
@Schema(requiredMode = REQUIRED)
private String email;

private boolean emailVerified;

private String phone;

private String password;
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -201,9 +210,83 @@ public RouterFunction<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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());
}

<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)
Expand Down Expand Up @@ -396,6 +479,8 @@ private Mono<ServerResponse> 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();
Expand All @@ -405,6 +490,12 @@ private Mono<ServerResponse> 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;
})
)
Expand Down
Expand Up @@ -56,7 +56,7 @@ public Result reconcile(Request request) {
}

addFinalizerIfNecessary(user);
ensureRoleNamesAnno(request.name());
ensureImportantAnnotation(request.name());
updatePermalink(request.name());
handleAvatar(request.name());
});
Expand Down Expand Up @@ -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<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
Map<String, String> oldAnnotations = Map.copyOf(annotations);

List<String> 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);
Expand Down
@@ -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<Void> 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<Void> verify(String username, String code);
}

0 comments on commit 21849b2

Please sign in to comment.