Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support reset passwords based on email address #4941

Merged
merged 10 commits into from Dec 1, 2023
@@ -0,0 +1,38 @@
package run.halo.app.core.extension.service;

import reactor.core.publisher.Mono;
import run.halo.app.infra.exception.AccessDeniedException;

/**
* An interface for email password recovery.
*
* @author guqing
* @since 2.11.0
*/
public interface EmailPasswordRecoveryService {

/**
* <p>Send password reset email.</p>
* if the user does not exist, it will return {@link Mono#empty()}
* if the user exists, but the email is not the same, it will return {@link Mono#empty()}
*
* @param username username to request password reset
* @param email email to match the user with the username
* @return {@link Mono#empty()} if the user does not exist, or the email is not the same.
*/
Mono<Void> sendPasswordResetEmail(String username, String email);

/**
* <p>Reset password by token.</p>
* if the token is invalid, it will return {@link Mono#error(Throwable)}}
* if the token is valid, but the username is not the same, it will return
* {@link Mono#error(Throwable)}
*
* @param username username to reset password
* @param newPassword new password
* @param token token to validate the user
* @return {@link Mono#empty()} if the token is invalid or the username is not the same.
* @throws AccessDeniedException if the token is invalid
*/
Mono<Void> changePassword(String username, String newPassword, String token);
}
@@ -0,0 +1,208 @@
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 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.EmailPasswordRecoveryService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.notification.NotificationCenter;
import run.halo.app.notification.NotificationReasonEmitter;
import run.halo.app.notification.UserIdentity;

/**
* A default implementation for {@link EmailPasswordRecoveryService}.
*
* @author guqing
* @since 2.11.0
*/
@Component
@RequiredArgsConstructor
public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService {
public static final int MAX_ATTEMPTS = 5;
public static final long LINK_EXPIRATION_MINUTES = 30;
static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email";

private final ResetPasswordVerificationManager resetPasswordVerificationManager =
new ResetPasswordVerificationManager();
private final ExternalLinkProcessor externalLinkProcessor;
private final ReactiveExtensionClient client;
private final NotificationReasonEmitter reasonEmitter;
private final NotificationCenter notificationCenter;
private final UserService userService;

@Override
public Mono<Void> sendPasswordResetEmail(String username, String email) {
return client.fetch(User.class, username)
.flatMap(user -> {
var userEmail = user.getSpec().getEmail();
if (!StringUtils.equals(userEmail, email)) {
return Mono.empty();
}
if (!user.getSpec().isEmailVerified()) {
return Mono.empty();
}
return sendResetPasswordNotification(username, email);
});
}

@Override
public Mono<Void> changePassword(String username, String newPassword, String token) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank");
Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank");
var verified = resetPasswordVerificationManager.verifyToken(username, token);
if (!verified) {
return Mono.error(AccessDeniedException::new);
}
return userService.updateWithRawPassword(username, newPassword)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
.flatMap(user -> {
resetPasswordVerificationManager.removeToken(username);
return unSubscribeResetPasswordEmailNotification(user.getSpec().getEmail());
})
.then();
}

Mono<Void> unSubscribeResetPasswordEmailNotification(String email) {
if (StringUtils.isBlank(email)) {
return Mono.empty();
}
var subscriber = new Subscription.Subscriber();
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
return notificationCenter.unsubscribe(subscriber, createInterestReason(email))
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}

Mono<Void> sendResetPasswordNotification(String username, String email) {
var token = resetPasswordVerificationManager.generateToken(username);
var link = getResetPasswordLink(username, token);

var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email);
var interestReasonSubject = createInterestReason(email).getSubject();
var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE,
builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES)
.attribute("username", username)
.attribute("link", link)
.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> autoSubscribeResetPasswordEmailNotification(String email) {
var subscriber = new Subscription.Subscriber();
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
var interestReason = createInterestReason(email);
return notificationCenter.subscribe(subscriber, interestReason)
.then();
}

Subscription.InterestReason createInterestReason(String email) {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_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;
}

private String getResetPasswordLink(String username, String token) {
return externalLinkProcessor.processLink(
"/uc/reset-password/" + username + "?reset_password_token=" + token);
}

static class ResetPasswordVerificationManager {
private final Cache<String, Verification> userTokenCache =
CacheBuilder.newBuilder()
.expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES)
.maximumSize(10000)
.build();

private final Cache<String, Boolean>
blackListCache = CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofHours(2))
.maximumSize(1000)
.build();

public boolean verifyToken(String username, String token) {
var verification = userTokenCache.getIfPresent(username);
if (verification == null) {
// expired or not generated
return false;
}
if (blackListCache.getIfPresent(username) != null) {
// in blacklist
throw new RateLimitExceededException(null);
}
synchronized (verification) {
if (verification.getAttempts().get() >= MAX_ATTEMPTS) {
// add to blacklist to prevent brute force attack
blackListCache.put(username, true);
return false;
}
if (!verification.getToken().equals(token)) {
verification.getAttempts().incrementAndGet();
return false;
}
}
return true;
}

public void removeToken(String username) {
userTokenCache.invalidate(username);
}

public String generateToken(String username) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
var verification = new Verification();
verification.setToken(RandomStringUtils.randomAlphanumeric(20));
verification.setAttempts(new AtomicInteger(0));
userTokenCache.put(username, verification);
return verification.getToken();
}

/**
* Only for test.
*/
boolean contains(String username) {
return userTokenCache.getIfPresent(username) != null;
}

@Data
@Accessors(chain = true)
static class Verification {
private String token;
private AtomicInteger attempts;
}
}
}
Expand Up @@ -2,14 +2,18 @@

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;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;

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.Schema;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextImpl;
Expand All @@ -20,9 +24,11 @@
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
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.UserService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.infra.exception.RateLimitExceededException;
Expand All @@ -40,6 +46,7 @@ public class PublicUserEndpoint implements CustomEndpoint {
private final UserService userService;
private final ServerSecurityContextRepository securityContextRepository;
private final ReactiveUserDetailsService reactiveUserDetailsService;
private final EmailPasswordRecoveryService emailPasswordRecoveryService;
private final RateLimiterRegistry rateLimiterRegistry;

@Override
Expand All @@ -55,9 +62,91 @@ public RouterFunction<ServerResponse> endpoint() {
)
.response(responseBuilder().implementation(User.class))
)
.POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail,
builder -> builder.operationId("SendPasswordResetEmail")
.description("Send password reset email when forgot password")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.implementation(PasswordResetEmailRequest.class)
)
.response(responseBuilder()
.responseCode(HttpStatus.NO_CONTENT.toString())
.implementation(Void.class))
)
.PUT("/users/{name}/reset-password", this::resetPasswordByToken,
builder -> builder.operationId("ResetPasswordByToken")
.description("Reset password by token")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.description("The name of the user")
.required(true)
.in(ParameterIn.PATH)
)
.requestBody(requestBodyBuilder()
.required(true)
.implementation(ResetPasswordRequest.class)
)
.response(responseBuilder()
.responseCode(HttpStatus.NO_CONTENT.toString())
.implementation(Void.class)
)
)
.build();
}

private Mono<ServerResponse> resetPasswordByToken(ServerRequest request) {
var username = request.pathVariable("name");
return request.bodyToMono(ResetPasswordRequest.class)
.doOnNext(resetReq -> {
if (StringUtils.isBlank(resetReq.token())) {
throw new ServerWebInputException("Token must not be blank");
}
if (StringUtils.isBlank(resetReq.newPassword())) {
throw new ServerWebInputException("New password must not be blank");
}
})
.switchIfEmpty(
Mono.error(() -> new ServerWebInputException("Request body must not be empty"))
)
.flatMap(resetReq -> {
var token = resetReq.token();
var newPassword = resetReq.newPassword();
return emailPasswordRecoveryService.changePassword(username, newPassword, token);
})
.then(ServerResponse.noContent().build());
}

record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username,
@Schema(requiredMode = REQUIRED) String email) {
}

record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword,
@Schema(requiredMode = REQUIRED) String token) {
}

private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) {
return request.bodyToMono(PasswordResetEmailRequest.class)
.flatMap(passwordResetRequest -> {
var username = passwordResetRequest.username();
var email = passwordResetRequest.email();
return Mono.just(passwordResetRequest)
.transformDeferred(sendResetPasswordEmailRateLimiter(username, email))
.flatMap(
r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
})
.then(ServerResponse.noContent().build());
}

<T> RateLimiterOperator<T> sendResetPasswordEmailRateLimiter(String username, String email) {
String rateLimiterKey = "send-reset-password-email-" + username + ":" + email;
var rateLimiter =
rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email");
return RateLimiterOperator.of(rateLimiter);
}

@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
Expand Down
4 changes: 4 additions & 0 deletions application/src/main/resources/application.yaml
Expand Up @@ -99,3 +99,7 @@ resilience4j.ratelimiter:
limitForPeriod: 3
limitRefreshPeriod: 1h
timeoutDuration: 0s
send-reset-password-email:
limitForPeriod: 2
limitRefreshPeriod: 1m
timeoutDuration: 0s