Skip to content

Commit

Permalink
fix: a verified email can be reused
Browse files Browse the repository at this point in the history
  • Loading branch information
guqing committed Jun 14, 2024
1 parent ba96118 commit df05beb
Show file tree
Hide file tree
Showing 8 changed files with 99 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupKind;
import run.halo.app.extension.MetadataUtil;
Expand Down Expand Up @@ -47,6 +48,7 @@ public class UserReconciler implements Reconciler<Request> {
.fixedBackoff(300)
.retryOn(IllegalStateException.class)
.build();
private final UserService userService;

@Override
public Result reconcile(Request request) {
Expand All @@ -60,10 +62,36 @@ public Result reconcile(Request request) {
ensureRoleNamesAnno(request.name());
updatePermalink(request.name());
handleAvatar(request.name());

checkVerifiedEmail(user);
client.update(user);
});
return new Result(false, null);
}

private void checkVerifiedEmail(User user) {
var username = user.getMetadata().getName();
if (!user.getSpec().isEmailVerified()) {
return;
}
var email = user.getSpec().getEmail();
if (StringUtils.isBlank(email)) {
return;
}
if (checkEmailInUse(username, email)) {
user.getSpec().setEmailVerified(false);
}
}

private Boolean checkEmailInUse(String username, String email) {
return userService.listByEmail(email)
.filter(existUser -> existUser.getSpec().isEmailVerified())
.filter(existUser -> !existUser.getMetadata().getName().equals(username))
.hasElements()
.blockOptional()
.orElse(false);
}

private void handleAvatar(String name) {
client.fetch(User.class, name).ifPresent(user -> {
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public interface UserService {
Mono<User> createUser(User user, Set<String> roles);

Mono<Boolean> confirmPassword(String username, String rawPassword);

Flux<User> listByEmail(String email);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package run.halo.app.core.extension.service;

import static org.springframework.data.domain.Sort.Order.asc;
import static org.springframework.data.domain.Sort.Order.desc;
import static run.halo.app.core.extension.RoleBinding.containsUser;
import static run.halo.app.extension.index.query.QueryFactory.equal;

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -21,8 +25,10 @@
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.event.user.PasswordChangedEvent;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.exception.AccessDeniedException;
Expand Down Expand Up @@ -198,6 +204,15 @@ public Mono<Boolean> confirmPassword(String username, String rawPassword) {
.hasElement();
}

@Override
public Flux<User> listByEmail(String email) {
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(equal("spec.email", email)));
return client.listAll(User.class, listOptions, Sort.by(desc("metadata.creationTimestamp"),
asc("metadata.name"))
);
}

void publishPasswordChangedEvent(String username) {
eventPublisher.publishEvent(new PasswordChangedEvent(this, username));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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.core.extension.service.UserService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
Expand All @@ -46,6 +47,7 @@ public class EmailVerificationServiceImpl implements EmailVerificationService {
private final ReactiveExtensionClient client;
private final NotificationReasonEmitter reasonEmitter;
private final NotificationCenter notificationCenter;
private final UserService userService;

@Override
public Mono<Void> sendVerificationCode(String username, String email) {
Expand Down Expand Up @@ -80,28 +82,51 @@ 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);
})
.flatMap(user -> verifyUserEmail(user, code))
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
.filter(OptimisticLockingFailureException.class::isInstance));
}

private Mono<Void> verifyUserEmail(User user, String code) {
var username = user.getMetadata().getName();
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);
}

return isEmailInUse(username, emailToVerify)
.flatMap(inUse -> {
if (inUse) {
return Mono.error(new EmailVerificationFailed("Email already in use.",
null,
"problemDetail.user.email.verify.emailInUse",
null)
);
}
// remove code when verified
emailVerificationManager.removeCode(username, emailToVerify);
user.getSpec().setEmailVerified(true);
user.getSpec().setEmail(emailToVerify);
return client.update(user);
})
.then();
}

Mono<Boolean> isEmailInUse(String username, String emailToVerify) {
return userService.listByEmail(emailToVerify)
.filter(user -> user.getSpec().isEmailVerified())
.filter(user -> !user.getMetadata().getName().equals(username))
.hasElements();
}

@Override
public Mono<Void> sendRegisterVerificationCode(String email) {
Assert.state(StringUtils.isNotBlank(email), "Email must not be blank");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event
.setName("spec.displayName")
.setIndexFunc(
simpleAttribute(User.class, user -> user.getSpec().getDisplayName())));
indexSpecs.add(new IndexSpec()
.setName("spec.email")
.setIndexFunc(simpleAttribute(User.class, user -> {
var email = user.getSpec().getEmail();
return StringUtils.isBlank(email) ? null : email;
})));
indexSpecs.add(new IndexSpec()
.setName(User.USER_RELATED_ROLES_INDEX)
.setIndexFunc(multiValueAttribute(User.class, user -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$Wrong
problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependents Not Disabled
problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencies Not Enabled
problemDetail.title.internalServerError=Internal Server Error

# Detail definitions
problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException=Content type {0} is not supported. Supported media types: {1}.
problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException.parseError=Could not parse Content-Type.
Expand All @@ -51,9 +50,9 @@ problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundExc
problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Dependencies have wrong version: {0}.
problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Plugin dependents {0} are not fully disabled, please disable them first.
problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Plugin dependencies {0} are not fully enabled, please enable them first.

problemDetail.index.duplicateKey=The value of {0} already exists for unique index {1}, please rename it and retry.
problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later.
problemDetail.user.email.verify.emailInUse=The email has been used, please change the email and retry.
problemDetail.user.password.unsatisfied=The password does not meet the specifications.
problemDetail.user.username.unsatisfied=The username does not meet the specifications.
problemDetail.user.oldPassword.notMatch=The old password does not match.
Expand All @@ -72,5 +71,4 @@ problemDetail.plugin.version.unsatisfied.requires=Plugin requires a minimum syst
problemDetail.plugin.missingManifest=Missing plugin manifest file "plugin.yaml" or manifest file does not conform to the specification.
problemDetail.internalServerError=Something went wrong, please try again later.
problemDetail.migration.backup.notFound=The backup file does not exist or has been deleted.

title.visibility.identification.private=(Private)
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$Wrong
problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件未禁用
problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=依赖未启用
problemDetail.title.internalServerError=服务器内部错误

problemDetail.org.springframework.security.authentication.BadCredentialsException=用户名或密码错误。
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。
Expand All @@ -28,9 +27,9 @@ problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundExc
problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本有误:{0}。
problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件 {0} 未完全禁用,请先禁用它们。
problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=插件依赖 {0} 未完全启用,请先启用它们。

problemDetail.index.duplicateKey=唯一索引 {1} 中的值 {0} 已存在,请更名后重试。
problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。
problemDetail.user.email.verify.emailInUse=邮箱已被使用, 请更换邮箱后重试。
problemDetail.user.password.unsatisfied=密码不符合规范。
problemDetail.user.username.unsatisfied=用户名不符合规范。
problemDetail.user.oldPassword.notMatch=旧密码不匹配。
Expand All @@ -44,5 +43,4 @@ problemDetail.theme.install.missingManifest=缺少 theme.yaml 配置文件或配
problemDetail.theme.install.alreadyExists=主题 {0} 已存在。
problemDetail.internalServerError=服务器内部发生错误,请稍候再试。
problemDetail.migration.backup.notFound=备份文件不存在或已删除。

title.visibility.identification.private=(私有)
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ void permalinkForFakeUser() throws URISyntaxException {
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Optional.of(user("fake-user")));
userReconciler.reconcile(new Reconciler.Request("fake-user"));
verify(client, times(3)).update(any(User.class));
verify(client, times(4)).update(any(User.class));

ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(client, times(3)).update(captor.capture());
verify(client, times(4)).update(captor.capture());
assertThat(captor.getValue().getStatus().getPermalink())
.isEqualTo("http://localhost:8090/authors/fake-user");
}
Expand All @@ -83,7 +83,7 @@ void permalinkForAnonymousUser() {
when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL)))
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
verify(client, times(2)).update(any(User.class));
verify(client, times(3)).update(any(User.class));
}

@Test
Expand All @@ -108,7 +108,7 @@ void ensureRoleNamesAnno() {

userReconciler.reconcile(new Reconciler.Request("fake-user"));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(client, times(3)).update(captor.capture());
verify(client, times(4)).update(captor.capture());
User user = captor.getAllValues().get(1);
assertThat(user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO))
.isEqualTo("[\"fake-role\"]");
Expand Down

0 comments on commit df05beb

Please sign in to comment.