Skip to content

Commit

Permalink
Merge branch 'halo-dev:main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
LIlGG authored Jan 7, 2024
2 parents 74a28ec + 694ad26 commit 750a343
Show file tree
Hide file tree
Showing 191 changed files with 11,408 additions and 4,717 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
18 changes: 18 additions & 0 deletions api/src/main/java/run/halo/app/extension/JsonExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
Expand Down Expand Up @@ -117,6 +118,23 @@ public MetadataOperator getMetadataOrCreate() {
return new ObjectNodeMetadata(metadataNode);
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
JsonExtension that = (JsonExtension) o;
return Objects.equals(objectNode, that.objectNode);
}

@Override
public int hashCode() {
return Objects.hash(objectNode);
}

class ObjectNodeMetadata implements MetadataOperator {

private final ObjectNode objectNode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public enum Operator implements Converter<String, SelectorCriteria> {
public SelectorCriteria convert(@Nullable String selector) {
if (preFlightCheck(selector, 3)) {
var i = selector.indexOf(getOperator());
if (i > 0 && (i + getOperator().length()) < selector.length() - 1) {
if (i > 0 && (i + getOperator().length()) <= selector.length() - 1) {
String key = selector.substring(0, i);
String value = selector.substring(i + getOperator().length());
return new SelectorCriteria(key, this, Set.of(value));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ record TestCase(String source, Operator converter, SelectorCriteria expected) {
new TestCase("name=", Equals, null),
new TestCase("name=value", Equals,
new SelectorCriteria("name", Equals, Set.of("value"))),
new TestCase("name=v", Equals,
new SelectorCriteria("name", Equals, Set.of("v"))),

new TestCase("", NotEquals, null),
new TestCase("=", NotEquals, null),
Expand Down Expand Up @@ -59,4 +61,4 @@ record TestCase(String source, Operator converter, SelectorCriteria expected) {
assertEquals(testCase.expected(), testCase.converter().convert(testCase.source()));
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.DefaultSchemeWatcherManager;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
Expand All @@ -19,18 +17,8 @@ public class ExtensionConfiguration {

@Bean
RouterFunction<ServerResponse> extensionsRouterFunction(ReactiveExtensionClient client,
SchemeWatcherManager watcherManager) {
return new ExtensionCompositeRouterFunction(client, watcherManager);
}

@Bean
SchemeManager schemeManager(SchemeWatcherManager watcherManager) {
return new DefaultSchemeManager(watcherManager);
}

@Bean
SchemeWatcherManager schemeWatcherManager() {
return new DefaultSchemeWatcherManager();
SchemeWatcherManager watcherManager, SchemeManager schemeManager) {
return new ExtensionCompositeRouterFunction(client, watcherManager, schemeManager);
}

@Configuration(proxyBeanMethods = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.concurrent.locks.StampedLock;
import java.util.function.BiConsumer;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
Expand All @@ -19,7 +20,6 @@
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.Watcher;
import run.halo.app.extension.controller.RequestSynchronizer;
import run.halo.app.infra.SchemeInitializedEvent;

/**
* <p>Monitor changes to {@link Post} resources and establish a local, in-memory cache in an
Expand All @@ -33,7 +33,7 @@
* @since 2.0.0
*/
@Component
public class PostIndexInformer implements ApplicationListener<SchemeInitializedEvent>,
public class PostIndexInformer implements ApplicationListener<ApplicationStartedEvent>,
DisposableBean {
public static final String TAG_POST_INDEXER = "tag-post-indexer";
public static final String LABEL_INDEXER_NAME = "post-label-indexer";
Expand Down Expand Up @@ -71,10 +71,6 @@ private DefaultIndexer.IndexFunc<Post> labelIndexFunc() {
};
}

public Set<String> getByIndex(String indexName, String indexKey) {
return postIndexer.getByIndex(indexName, indexKey);
}

public Set<String> getByTagName(String tagName) {
return postIndexer.getByIndex(TAG_POST_INDEXER, tagName);
}
Expand Down Expand Up @@ -104,10 +100,6 @@ String labelKey(String labelName, String labelValue) {
return labelName + "=" + labelValue;
}

public Set<String> getByLabel(String labelName, String labelValue) {
return postIndexer.getByIndex(LABEL_INDEXER_NAME, labelKey(labelName, labelValue));
}

@Override
public void destroy() throws Exception {
if (postWatcher != null) {
Expand All @@ -119,7 +111,7 @@ public void destroy() throws Exception {
}

@Override
public void onApplicationEvent(@NonNull SchemeInitializedEvent event) {
public void onApplicationEvent(@NonNull ApplicationStartedEvent event) {
if (!synchronizer.isStarted()) {
synchronizer.start();
}
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;
}
}
}
Loading

0 comments on commit 750a343

Please sign in to comment.