Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main'
Browse files Browse the repository at this point in the history
  • Loading branch information
ruibaby committed Dec 4, 2023
2 parents ab7bc07 + 1558f12 commit 2126de7
Show file tree
Hide file tree
Showing 33 changed files with 1,299 additions and 114 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
## 快速开始

```bash
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.10
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.11
```

以上仅作为体验使用,详细部署文档请查阅:<https://docs.halo.run/getting-started/install/docker-compose>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@ resilience4j.ratelimiter:
limitForPeriod: 3
limitRefreshPeriod: 1h
timeoutDuration: 0s
send-reset-password-email:
limitForPeriod: 2
limitRefreshPeriod: 1m
timeoutDuration: 0s
Loading

0 comments on commit 2126de7

Please sign in to comment.