Skip to content

Commit

Permalink
feat: add support for force verify email during user registration (#5320
Browse files Browse the repository at this point in the history
)

#### What type of PR is this?

/kind feature
/kind improvement
/area core
/area console
/kind api-change

#### What this PR does / why we need it:
增加对用户注册时必须验证邮箱的支持

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

Fixes #5016

#### Special notes for your reviewer:
`regRequireVerifyEmail` 为 `false` 时与现在的注册行为一致
为 `true` 时注册页显示验证码校验相关,注册成功后 `UserSpec.emailVerified` 即为 `true`
没有判断邮件通知是否开启,与现有的邮箱验证一致,如未开启则收不到邮件

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

```release-note
增加对用户注册时必须验证邮箱的支持
```
  • Loading branch information
ShiinaKin committed Feb 22, 2024
1 parent 9e67671 commit 50fbe37
Show file tree
Hide file tree
Showing 18 changed files with 519 additions and 59 deletions.
1 change: 1 addition & 0 deletions api/src/main/java/run/halo/app/infra/SystemSetting.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public static class Basic {
public static class User {
public static final String GROUP = "user";
Boolean allowRegistration;
Boolean mustVerifyEmailOnRegistration;
String defaultRole;
String avatarPolicy;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,11 @@ public GlobalInfo globalInfo() {
handleBasicSetting(info, configMap);
handlePostSlugGenerationStrategy(info, configMap);
}));

return info;
}

@Data
public static class GlobalInfo {

private URL externalUrl;

private boolean useAbsolutePermalink;
Expand All @@ -85,6 +83,8 @@ public static class GlobalInfo {
private String postSlugGenerationStrategy;

private List<SocialAuthProvider> socialAuthProviders;

private Boolean mustVerifyEmailOnRegistration;
}

@Data
Expand Down Expand Up @@ -117,12 +117,14 @@ private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) {
}

private void handleUserSetting(GlobalInfo info, ConfigMap configMap) {
var user = SystemSetting.get(configMap, User.GROUP, User.class);
if (user == null) {
var userSetting = SystemSetting.get(configMap, User.GROUP, User.class);
if (userSetting == null) {
info.setAllowRegistration(false);
info.setMustVerifyEmailOnRegistration(false);
} else {
info.setAllowRegistration(
user.getAllowRegistration() != null && user.getAllowRegistration());
userSetting.getAllowRegistration() != null && userSetting.getAllowRegistration());
info.setMustVerifyEmailOnRegistration(userSetting.getMustVerifyEmailOnRegistration());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,21 @@ public interface EmailVerificationService {
* @throws EmailVerificationFailed if send failed
*/
Mono<Void> verify(String username, String code);

/**
* Send verification code.
* The only difference is use email as username.
*
* @param email email to send must not be blank
*/
Mono<Void> sendRegisterVerificationCode(String email);

/**
* Verify email by given code.
*
* @param email email as username to verify email must not be blank
* @param code code to verify email must not be blank
* @throws EmailVerificationFailed if send failed
*/
Mono<Boolean> verifyRegisterVerificationCode(String email, String code);
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ public Mono<Void> verify(String username, String code) {
.then();
}

@Override
public Mono<Void> sendRegisterVerificationCode(String email) {
Assert.state(StringUtils.isNotBlank(email), "Email must not be blank");
return sendVerificationNotification(email, email);
}

@Override
public Mono<Boolean> verifyRegisterVerificationCode(String email, String code) {
Assert.state(StringUtils.isNotBlank(email), "Username must not be blank");
Assert.state(StringUtils.isNotBlank(code), "Code must not be blank");
return Mono.just(emailVerificationManager.verifyCode(email, email, code));
}

Mono<Void> sendVerificationNotification(String username, String email) {
var code = emailVerificationManager.generateCode(username, email);
var subscribeNotification = autoSubscribeVerificationEmailNotification(email);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package run.halo.app.theme.endpoint;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
Expand All @@ -11,6 +12,7 @@
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus;
Expand All @@ -29,8 +31,14 @@
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.service.EmailPasswordRecoveryService;
import run.halo.app.core.extension.service.EmailVerificationService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ValidationUtils;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.infra.utils.IpAddressUtils;

Expand All @@ -48,6 +56,8 @@ public class PublicUserEndpoint implements CustomEndpoint {
private final ReactiveUserDetailsService reactiveUserDetailsService;
private final EmailPasswordRecoveryService emailPasswordRecoveryService;
private final RateLimiterRegistry rateLimiterRegistry;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final EmailVerificationService emailVerificationService;

@Override
public RouterFunction<ServerResponse> endpoint() {
Expand All @@ -62,6 +72,22 @@ public RouterFunction<ServerResponse> endpoint() {
)
.response(responseBuilder().implementation(User.class))
)
.POST("/users/-/send-register-verify-email", this::sendRegisterVerifyEmail,
builder -> builder.operationId("SendRegisterVerifyEmail")
.description(
"Send registration verification email, which can be called when "
+ "mustVerifyEmailOnRegistration in user settings is true"
)
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.implementation(RegisterVerifyEmailRequest.class)
)
.response(responseBuilder()
.responseCode(HttpStatus.NO_CONTENT.toString())
.implementation(Void.class)
)
)
.POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail,
builder -> builder.operationId("SendPasswordResetEmail")
.description("Send password reset email when forgot password")
Expand Down Expand Up @@ -126,6 +152,9 @@ record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) Stri
@Schema(requiredMode = REQUIRED) String token) {
}

record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) {
}

private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) {
return request.bodyToMono(PasswordResetEmailRequest.class)
.flatMap(passwordResetRequest -> {
Expand Down Expand Up @@ -154,6 +183,30 @@ public GroupVersion groupVersion() {

private Mono<ServerResponse> signUp(ServerRequest request) {
return request.bodyToMono(SignUpRequest.class)
.doOnNext(signUpRequest -> signUpRequest.user().getSpec().setEmailVerified(false))
.flatMap(signUpRequest -> environmentFetcher.fetch(SystemSetting.User.GROUP,
SystemSetting.User.class)
.map(user -> BooleanUtils.isTrue(user.getMustVerifyEmailOnRegistration()))
.defaultIfEmpty(false)
.flatMap(mustVerifyEmailOnRegistration -> {
if (!mustVerifyEmailOnRegistration) {
return Mono.just(signUpRequest);
}
if (!StringUtils.isNumeric(signUpRequest.verifyCode)) {
return Mono.error(new EmailVerificationFailed());
}
return emailVerificationService.verifyRegisterVerificationCode(
signUpRequest.user().getSpec().getEmail(),
signUpRequest.verifyCode)
.flatMap(verified -> {
if (BooleanUtils.isNotTrue(verified)) {
return Mono.error(new EmailVerificationFailed());
}
signUpRequest.user().getSpec().setEmailVerified(true);
return Mono.just(signUpRequest);
});
})
)
.flatMap(signUpRequest ->
userService.signUp(signUpRequest.user(), signUpRequest.password())
)
Expand All @@ -168,6 +221,35 @@ private Mono<ServerResponse> signUp(ServerRequest request) {
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
}

private Mono<ServerResponse> sendRegisterVerifyEmail(ServerRequest request) {
return request.bodyToMono(RegisterVerifyEmailRequest.class)
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("Required request body is missing."))
)
.map(emailReq -> {
var email = emailReq.email();
if (!ValidationUtils.isValidEmail(email)) {
throw new ServerWebInputException("Invalid email address.");
}
return email;
})
.flatMap(email -> environmentFetcher.fetch(SystemSetting.User.GROUP,
SystemSetting.User.class)
.map(config -> BooleanUtils.isTrue(config.getMustVerifyEmailOnRegistration()))
.defaultIfEmpty(false)
.doOnNext(mustVerifyEmailOnRegistration -> {
if (!mustVerifyEmailOnRegistration) {
throw new AccessDeniedException("Email verification is not required.");
}
})
.transformDeferred(sendRegisterEmailVerificationCodeRateLimiter(email))
.flatMap(s -> emailVerificationService.sendRegisterVerificationCode(email)
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
)
.then(ServerResponse.ok().build());
}

private <T> RateLimiterOperator<T> getRateLimiterForSignUp(ServerWebExchange exchange) {
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp,
Expand All @@ -187,7 +269,17 @@ private Mono<Void> authenticate(String username, ServerWebExchange exchange) {
});
}

private <T> RateLimiterOperator<T> sendRegisterEmailVerificationCodeRateLimiter(String email) {
String rateLimiterKey = "send-register-verify-email:" + email;
var rateLimiter =
rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code");
return RateLimiterOperator.of(rateLimiter);
}

record SignUpRequest(@Schema(requiredMode = REQUIRED) User user,
@Schema(requiredMode = REQUIRED, minLength = 6) String password) {
@Schema(requiredMode = REQUIRED, minLength = 6) String password,
@Schema(requiredMode = NOT_REQUIRED, minLength = 6, maxLength = 6)
String verifyCode
) {
}
}
4 changes: 4 additions & 0 deletions application/src/main/resources/extensions/system-setting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ spec:
name: allowRegistration
label: "开放注册"
value: false
- $formkit: checkbox
name: mustVerifyEmailOnRegistration
label: "注册需验证邮箱(请确保启用邮件通知)"
value: false
- $formkit: roleSelect
name: defaultRole
label: "默认角色"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

Expand All @@ -21,6 +22,8 @@
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;

/**
* Tests for {@link PublicUserEndpoint}.
Expand All @@ -36,7 +39,8 @@ class PublicUserEndpointTest {
private ServerSecurityContextRepository securityContextRepository;
@Mock
private ReactiveUserDetailsService reactiveUserDetailsService;

@Mock
SystemConfigurableEnvironmentFetcher environmentFetcher;
@Mock
RateLimiterRegistry rateLimiterRegistry;

Expand Down Expand Up @@ -67,14 +71,17 @@ void signUp() {
.password("123456")
.authorities("test-role")
.build()));
SystemSetting.User userSetting = mock(SystemSetting.User.class);
when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class))
.thenReturn(Mono.just(userSetting));

when(rateLimiterRegistry.rateLimiter("signup-from-ip-127.0.0.1", "signup"))
.thenReturn(RateLimiter.ofDefaults("signup"));

webClient.post()
.uri("/users/-/signup")
.header("X-Forwarded-For", "127.0.0.1")
.bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password"))
.bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password", ""))
.exchange()
.expectStatus().isOk();

Expand Down

0 comments on commit 50fbe37

Please sign in to comment.