diff --git a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java
index 5f496c687..256072b78 100644
--- a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java
+++ b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java
@@ -48,6 +48,15 @@ public class AuthProvider extends AbstractExtension {
@Schema(requiredMode = REQUIRED, description = "Authentication url of the auth provider")
private String authenticationUrl;
+ private String method = "GET";
+
+ private boolean rememberMeSupport = false;
+
+ /**
+ * Auth type: form or oauth2.
+ */
+ private AuthType authType;
+
private String bindingUrl;
private String unbindUrl;
@@ -77,4 +86,10 @@ public class AuthProvider extends AbstractExtension {
@Schema(requiredMode = REQUIRED, minLength = 1)
private String name;
}
+
+ public enum AuthType {
+ FORM,
+ OAUTH2,
+ ;
+ }
}
diff --git a/api/src/main/java/run/halo/app/extension/Unstructured.java b/api/src/main/java/run/halo/app/extension/Unstructured.java
index 2ea80b5c5..6335fd198 100644
--- a/api/src/main/java/run/halo/app/extension/Unstructured.java
+++ b/api/src/main/java/run/halo/app/extension/Unstructured.java
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
@@ -35,7 +36,12 @@ import lombok.EqualsAndHashCode;
@SuppressWarnings("rawtypes")
public class Unstructured implements Extension {
- public static final ObjectMapper OBJECT_MAPPER = Json.mapper();
+ @SuppressWarnings("deprecation")
+ public static final ObjectMapper OBJECT_MAPPER = Json.mapper()
+ // We don't want to change the default mapper
+ // so we copy a new one and configure it
+ .copy()
+ .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
private final Map data;
diff --git a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java
index ea653c709..99dae5bcb 100644
--- a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java
+++ b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java
@@ -1,5 +1,9 @@
package run.halo.app.infra;
+import java.net.URI;
+import org.springframework.http.HttpRequest;
+import reactor.core.publisher.Mono;
+
/**
* {@link ExternalLinkProcessor} to process an in-site link to an external link.
*
@@ -17,4 +21,18 @@ public interface ExternalLinkProcessor {
* @return processed link or original link
*/
String processLink(String link);
+
+ /**
+ * Process the URI to an external URL.
+ *
+ * If the URI is an in-site link, then process it to an external link with
+ * {@link ExternalUrlSupplier#getRaw()} or {@link ExternalUrlSupplier#getURL(HttpRequest)},
+ * otherwise return the original URI.
+ *
+ *
+ * @param uri uri to process
+ * @return processed URI or original URI
+ */
+ Mono processLink(URI uri);
+
}
diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java
index c54848624..9beabb6c4 100644
--- a/api/src/main/java/run/halo/app/infra/SystemSetting.java
+++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java
@@ -67,8 +67,8 @@ public class SystemSetting {
@Data
public static class User {
public static final String GROUP = "user";
- Boolean allowRegistration;
- Boolean mustVerifyEmailOnRegistration;
+ boolean allowRegistration;
+ boolean mustVerifyEmailOnRegistration;
String defaultRole;
String avatarPolicy;
}
diff --git a/application/build.gradle b/application/build.gradle
index f0cc3488b..cffbdfd6a 100644
--- a/application/build.gradle
+++ b/application/build.gradle
@@ -80,6 +80,10 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'io.projectreactor:reactor-test'
+
+ // webjars
+ runtimeOnly 'org.webjars.npm:jsencrypt:3.3.2'
+ runtimeOnly 'org.webjars.npm:normalize.css:8.0.1'
}
tasks.register('createChecksums', Checksum) {
@@ -164,4 +168,4 @@ tasks.named('generateOpenApiDocs') {
outputs.upToDateWhen {
false
}
-}
+}
\ No newline at end of file
diff --git a/application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java
deleted file mode 100644
index 862aad334..000000000
--- a/application/src/main/java/run/halo/app/core/endpoint/theme/PublicUserEndpoint.java
+++ /dev/null
@@ -1,285 +0,0 @@
-package run.halo.app.core.endpoint.theme;
-
-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;
-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.BooleanUtils;
-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;
-import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
-import org.springframework.security.web.server.context.ServerSecurityContextRepository;
-import org.springframework.stereotype.Component;
-import org.springframework.web.reactive.function.server.RouterFunction;
-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.user.service.EmailPasswordRecoveryService;
-import run.halo.app.core.user.service.EmailVerificationService;
-import run.halo.app.core.user.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;
-
-/**
- * User endpoint for unauthenticated user.
- *
- * @author guqing
- * @since 2.4.0
- */
-@Component
-@RequiredArgsConstructor
-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;
- private final SystemConfigurableEnvironmentFetcher environmentFetcher;
- private final EmailVerificationService emailVerificationService;
-
- @Override
- public RouterFunction endpoint() {
- var tag = "UserV1alpha1Public";
- return SpringdocRouteBuilder.route()
- .POST("/users/-/signup", this::signUp,
- builder -> builder.operationId("SignUp")
- .description("Sign up a new user")
- .tag(tag)
- .requestBody(requestBodyBuilder().required(true)
- .implementation(SignUpRequest.class)
- )
- .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")
- .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 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) {
- }
-
- record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) {
- }
-
- private Mono 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());
- }
-
- RateLimiterOperator 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");
- }
-
- private Mono 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())
- )
- .flatMap(user -> authenticate(user.getMetadata().getName(), request.exchange())
- .thenReturn(user)
- )
- .flatMap(user -> ServerResponse.ok()
- .contentType(MediaType.APPLICATION_JSON)
- .bodyValue(user)
- )
- .transformDeferred(getRateLimiterForSignUp(request.exchange()))
- .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
- }
-
- private Mono 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 RateLimiterOperator getRateLimiterForSignUp(ServerWebExchange exchange) {
- var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
- var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp,
- "signup");
- return RateLimiterOperator.of(rateLimiter);
- }
-
- private Mono authenticate(String username, ServerWebExchange exchange) {
- return reactiveUserDetailsService.findByUsername(username)
- .flatMap(userDetails -> {
- SecurityContextImpl securityContext = new SecurityContextImpl();
- UsernamePasswordAuthenticationToken authentication =
- new UsernamePasswordAuthenticationToken(userDetails.getUsername(),
- userDetails.getPassword(), userDetails.getAuthorities());
- securityContext.setAuthentication(authentication);
- return securityContextRepository.save(exchange, securityContext);
- });
- }
-
- private RateLimiterOperator 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 = NOT_REQUIRED, minLength = 6, maxLength = 6)
- String verifyCode
- ) {
- }
-}
diff --git a/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java b/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java
index a586589f0..2770cec88 100644
--- a/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java
+++ b/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java
@@ -22,17 +22,21 @@ public interface EmailPasswordRecoveryService {
*/
Mono sendPasswordResetEmail(String username, String email);
+ Mono sendPasswordResetEmail(String email);
+
/**
* Reset password by token.
* 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 changePassword(String username, String newPassword, String token);
+ Mono changePassword(String newPassword, String token);
+
+ Mono getValidResetToken(String token);
+
}
diff --git a/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java b/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java
new file mode 100644
index 000000000..1c8b3a2ac
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java
@@ -0,0 +1,54 @@
+package run.halo.app.core.user.service;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import java.time.Duration;
+import java.util.Objects;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+/**
+ * In-memory reset token repository.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Component
+public class InMemoryResetTokenRepository implements ResetTokenRepository {
+
+ /**
+ * Key: Token Hash.
+ */
+ private final Cache tokens;
+
+ public InMemoryResetTokenRepository() {
+ this.tokens = Caffeine.newBuilder()
+ .expireAfterWrite(Duration.ofDays(1))
+ .maximumSize(10000)
+ .build();
+ }
+
+ @Override
+ public Mono save(ResetToken resetToken) {
+ return Mono.defer(() -> {
+ var savedResetToken = tokens.get(resetToken.tokenHash(), k -> resetToken);
+ if (Objects.equals(savedResetToken, resetToken)) {
+ return Mono.empty();
+ }
+ // should never happen
+ return Mono.error(new DuplicateKeyException("Reset token already exists"));
+ });
+ }
+
+ @Override
+ public Mono findByTokenHash(String tokenHash) {
+ return Mono.fromSupplier(() -> tokens.getIfPresent(tokenHash));
+ }
+
+ @Override
+ public Mono removeByTokenHash(String tokenHash) {
+ return Mono.fromRunnable(() -> tokens.invalidate(tokenHash));
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java b/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java
new file mode 100644
index 000000000..597c90af6
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java
@@ -0,0 +1,17 @@
+package run.halo.app.core.user.service;
+
+import org.springframework.web.server.ServerWebInputException;
+
+/**
+ * Invalid reset token exception.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+public class InvalidResetTokenException extends ServerWebInputException {
+
+ public InvalidResetTokenException() {
+ super("Invalid reset token");
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/core/user/service/ResetToken.java b/application/src/main/java/run/halo/app/core/user/service/ResetToken.java
new file mode 100644
index 000000000..31a416105
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/user/service/ResetToken.java
@@ -0,0 +1,15 @@
+package run.halo.app.core.user.service;
+
+import java.time.Instant;
+
+/**
+ * Reset token data.
+ *
+ * @param tokenHash The token hash
+ * @param username The username
+ * @param expiresAt The expires at
+ * @author johnniang
+ * @since 2.20.0
+ */
+public record ResetToken(String tokenHash, String username, Instant expiresAt) {
+}
diff --git a/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java b/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java
new file mode 100644
index 000000000..0dcc67b3e
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java
@@ -0,0 +1,38 @@
+package run.halo.app.core.user.service;
+
+import reactor.core.publisher.Mono;
+
+/**
+ * Reset token repository.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+public interface ResetTokenRepository {
+
+ /**
+ * Save reset token.
+ *
+ * @param resetToken reset token
+ * @return empty mono if saved successfully.
+ * @throws org.springframework.dao.DuplicateKeyException if token already exists.
+ */
+ Mono save(ResetToken resetToken);
+
+ /**
+ * Find reset token by token hash.
+ *
+ * @param tokenHash token hash
+ * @return reset token if found, or empty mono.
+ */
+ Mono findByTokenHash(String tokenHash);
+
+ /**
+ * Remove reset token by token hash.
+ *
+ * @param tokenHash token hash
+ * @return empty mono if removed successfully.
+ */
+ Mono removeByTokenHash(String tokenHash);
+
+}
diff --git a/application/src/main/java/run/halo/app/core/user/service/SignUpData.java b/application/src/main/java/run/halo/app/core/user/service/SignUpData.java
new file mode 100644
index 000000000..d609fa0cc
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/user/service/SignUpData.java
@@ -0,0 +1,57 @@
+package run.halo.app.core.user.service;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Optional;
+import lombok.Data;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+/**
+ * Sign up data.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Data
+public class SignUpData {
+
+ @NotBlank
+ private String username;
+
+ @NotBlank
+ private String displayName;
+
+ @Email
+ private String email;
+
+ private String emailCode;
+
+ @NotBlank
+ private String password;
+
+ public static SignUpData of(MultiValueMap formData) {
+ var form = new SignUpData();
+ Optional.ofNullable(formData.getFirst("username"))
+ .filter(StringUtils::hasText)
+ .ifPresent(form::setUsername);
+
+ Optional.ofNullable(formData.getFirst("displayName"))
+ .filter(StringUtils::hasText)
+ .ifPresent(form::setDisplayName);
+
+ Optional.ofNullable(formData.getFirst("email"))
+ .filter(StringUtils::hasText)
+ .ifPresent(form::setEmail);
+
+ Optional.ofNullable(formData.getFirst("password"))
+ .filter(StringUtils::hasText)
+ .ifPresent(form::setPassword);
+
+ Optional.ofNullable(formData.getFirst("emailCode"))
+ .filter(StringUtils::hasText)
+ .ifPresent(form::setEmailCode);
+
+ return form;
+ }
+}
diff --git a/application/src/main/java/run/halo/app/core/user/service/UserService.java b/application/src/main/java/run/halo/app/core/user/service/UserService.java
index 5a2b8135a..58039c2f2 100644
--- a/application/src/main/java/run/halo/app/core/user/service/UserService.java
+++ b/application/src/main/java/run/halo/app/core/user/service/UserService.java
@@ -17,7 +17,7 @@ public interface UserService {
Mono grantRoles(String username, Set roles);
- Mono signUp(User user, String password);
+ Mono signUp(SignUpData signUpData);
Mono createUser(User user, Set roles);
diff --git a/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java
index 9290b3ec4..982765cfb 100644
--- a/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java
+++ b/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java
@@ -1,7 +1,6 @@
package run.halo.app.core.user.service;
-import static org.springframework.data.domain.Sort.Order.asc;
-import static org.springframework.data.domain.Sort.Order.desc;
+import static run.halo.app.extension.ExtensionUtil.defaultSort;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.time.Clock;
@@ -12,10 +11,8 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
-import org.apache.commons.lang3.BooleanUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
-import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
@@ -30,18 +27,20 @@ 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.Metadata;
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;
import run.halo.app.infra.exception.DuplicateNameException;
+import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.infra.exception.UserNotFoundException;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
+
public static final String GHOST_USER_NAME = "ghost";
private final ReactiveExtensionClient client;
@@ -54,6 +53,8 @@ public class UserServiceImpl implements UserService {
private final RoleService roleService;
+ private final EmailVerificationService emailVerificationService;
+
private Clock clock = Clock.systemUTC();
void setClock(Clock clock) {
@@ -147,29 +148,49 @@ public class UserServiceImpl implements UserService {
}
@Override
- public Mono signUp(User user, String password) {
- if (!StringUtils.hasText(password)) {
- throw new IllegalArgumentException("Password must not be blank");
- }
+ public Mono signUp(SignUpData signUpData) {
return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)
- .switchIfEmpty(Mono.error(new IllegalStateException("User setting is not configured")))
- .flatMap(userSetting -> {
- Boolean allowRegistration = userSetting.getAllowRegistration();
- if (BooleanUtils.isFalse(allowRegistration)) {
- return Mono.error(new AccessDeniedException("Registration is not allowed",
- "problemDetail.user.signUpFailed.disallowed",
- null));
+ .filter(SystemSetting.User::isAllowRegistration)
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException(
+ "The registration is not allowed by the administrator."
+ )))
+ .filter(setting -> StringUtils.hasText(setting.getDefaultRole()))
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException(
+ "The default role is not configured by the administrator."
+ )))
+ .flatMap(setting -> {
+ var user = new User();
+ user.setMetadata(new Metadata());
+ var metadata = user.getMetadata();
+ metadata.setName(signUpData.getUsername());
+ user.setSpec(new User.UserSpec());
+ var spec = user.getSpec();
+ spec.setPassword(passwordEncoder.encode(signUpData.getPassword()));
+ spec.setEmailVerified(false);
+ spec.setRegisteredAt(clock.instant());
+ spec.setEmail(signUpData.getEmail());
+ spec.setDisplayName(signUpData.getDisplayName());
+ Mono verifyEmail = Mono.empty();
+ if (setting.isMustVerifyEmailOnRegistration()) {
+ if (!StringUtils.hasText(signUpData.getEmailCode())) {
+ return Mono.error(
+ new EmailVerificationFailed("Email captcha is required", null)
+ );
+ }
+ verifyEmail = emailVerificationService.verifyRegisterVerificationCode(
+ signUpData.getEmail(), signUpData.getEmailCode()
+ )
+ .filter(Boolean::booleanValue)
+ .switchIfEmpty(Mono.error(() ->
+ new EmailVerificationFailed("Invalid email captcha.", null)
+ ))
+ .doOnNext(spec::setEmailVerified)
+ .then();
}
- String defaultRole = userSetting.getDefaultRole();
- if (!StringUtils.hasText(defaultRole)) {
- return Mono.error(new AccessDeniedException(
- "Default registration role is not configured by admin",
- "problemDetail.user.signUpFailed.disallowed",
- null));
- }
- String encodedPassword = passwordEncoder.encode(password);
- user.getSpec().setPassword(encodedPassword);
- return createUser(user, Set.of(defaultRole));
+ return verifyEmail.then(Mono.defer(() -> {
+ var defaultRole = setting.getDefaultRole();
+ return createUser(user, Set.of(defaultRole));
+ }));
});
}
@@ -225,9 +246,7 @@ public class UserServiceImpl implements UserService {
public Flux 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"))
- );
+ return client.listAll(User.class, listOptions, defaultSort());
}
@Override
diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java
index ac66225ee..290acb085 100644
--- a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java
+++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java
@@ -1,30 +1,28 @@
package run.halo.app.core.user.service.impl;
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
+import java.time.Clock;
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.security.core.token.Sha512DigestUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
+import org.springframework.web.util.UriComponentsBuilder;
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.user.service.EmailPasswordRecoveryService;
+import run.halo.app.core.user.service.InvalidResetTokenException;
+import run.halo.app.core.user.service.ResetToken;
+import run.halo.app.core.user.service.ResetTokenRepository;
import run.halo.app.core.user.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;
@@ -38,17 +36,21 @@ import run.halo.app.notification.UserIdentity;
@Component
@RequiredArgsConstructor
public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService {
+
public static final int MAX_ATTEMPTS = 5;
public static final long LINK_EXPIRATION_MINUTES = 30;
+ private static final Duration RESET_TOKEN_LIFE_TIME =
+ Duration.ofMinutes(LINK_EXPIRATION_MINUTES);
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;
+ private final ResetTokenRepository resetTokenRepository;
+
+ private Clock clock = Clock.systemDefaultZone();
@Override
public Mono sendPasswordResetEmail(String username, String email) {
@@ -66,22 +68,35 @@ public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoverySe
}
@Override
- public Mono changePassword(String username, String newPassword, String token) {
- Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
+ public Mono sendPasswordResetEmail(String email) {
+ if (StringUtils.isBlank(email)) {
+ return Mono.empty();
+ }
+ return userService.listByEmail(email)
+ .filter(user -> user.getSpec().isEmailVerified())
+ .next()
+ .flatMap(user -> sendResetPasswordNotification(user.getMetadata().getName(), email));
+ }
+
+ @Override
+ public Mono changePassword(String newPassword, String token) {
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();
+ var tokenHash = hashToken(token);
+ return getValidResetToken(token).flatMap(resetToken ->
+ userService.updateWithRawPassword(resetToken.username(), newPassword)
+ .flatMap(user -> unSubscribeResetPasswordEmailNotification(
+ user.getSpec().getEmail())
+ )
+ .then(resetTokenRepository.removeByTokenHash(tokenHash))
+ );
+ }
+
+ @Override
+ public Mono getValidResetToken(String token) {
+ return resetTokenRepository.findByTokenHash(hashToken(token))
+ .filter(resetToken -> clock.instant().isBefore(resetToken.expiresAt()))
+ .switchIfEmpty(Mono.error(InvalidResetTokenException::new));
}
Mono unSubscribeResetPasswordEmailNotification(String email) {
@@ -95,26 +110,33 @@ public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoverySe
.filter(OptimisticLockingFailureException.class::isInstance));
}
- Mono 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);
+ private Mono sendResetPasswordNotification(String username, String email) {
+ var token = generateToken();
+ var tokenHash = hashToken(token);
+ var expiresAt = clock.instant().plus(RESET_TOKEN_LIFE_TIME);
+ var uri = UriComponentsBuilder.fromUriString("/")
+ .pathSegment("password-reset", token)
+ .build(true)
+ .toUri();
+ var resetToken = new ResetToken(tokenHash, username, expiresAt);
+ return resetTokenRepository.save(resetToken)
+ .then(externalLinkProcessor.processLink(uri).flatMap(link -> {
+ 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 autoSubscribeResetPasswordEmailNotification(email).then(emitReasonMono);
+ }));
}
Mono autoSubscribeResetPasswordEmailNotification(String email) {
@@ -136,73 +158,12 @@ public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoverySe
return interestReason;
}
- private String getResetPasswordLink(String username, String token) {
- return externalLinkProcessor.processLink(
- "/uc/reset-password/" + username + "?reset_password_token=" + token);
+ private static String hashToken(String token) {
+ return Sha512DigestUtils.shaHex(token);
}
- static class ResetPasswordVerificationManager {
- private final Cache userTokenCache =
- CacheBuilder.newBuilder()
- .expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES)
- .maximumSize(10000)
- .build();
-
- private final Cache
- 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;
- }
+ private static String generateToken() {
+ return RandomStringUtils.secure().nextAlphanumeric(64);
}
+
}
diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java
index b362a96c8..01dfeff7c 100644
--- a/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java
+++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java
@@ -15,15 +15,18 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
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.user.service.EmailVerificationService;
-import run.halo.app.core.user.service.UserService;
+import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GroupVersion;
+import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.notification.NotificationCenter;
import run.halo.app.notification.NotificationReasonEmitter;
@@ -47,7 +50,6 @@ public class EmailVerificationServiceImpl implements EmailVerificationService {
private final ReactiveExtensionClient client;
private final NotificationReasonEmitter reasonEmitter;
private final NotificationCenter notificationCenter;
- private final UserService userService;
@Override
public Mono sendVerificationCode(String username, String email) {
@@ -121,7 +123,10 @@ public class EmailVerificationServiceImpl implements EmailVerificationService {
}
Mono isEmailInUse(String username, String emailToVerify) {
- return userService.listByEmail(emailToVerify)
+ var listOptions = ListOptions.builder()
+ .andQuery(QueryFactory.equal("spec.email", emailToVerify))
+ .build();
+ return client.listAll(User.class, listOptions, ExtensionUtil.defaultSort())
.filter(user -> user.getSpec().isEmailVerified())
.filter(user -> !user.getMetadata().getName().equals(username))
.hasElements();
@@ -137,7 +142,9 @@ public class EmailVerificationServiceImpl implements EmailVerificationService {
public Mono 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));
+ return Mono.fromSupplier(() -> emailVerificationManager.verifyCode(email, email, code))
+ // Why use boundedElastic? Because the verification uses synchronized block.
+ .subscribeOn(Schedulers.boundedElastic());
}
Mono sendVerificationNotification(String username, String email) {
diff --git a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java
index 8aa230435..67238b982 100644
--- a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java
+++ b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java
@@ -3,14 +3,15 @@ package run.halo.app.extension;
import static org.openapi4j.core.validation.ValidationSeverity.ERROR;
import static org.springframework.util.StringUtils.arrayToCommaDelimitedString;
import static run.halo.app.extension.ExtensionStoreUtil.buildStoreName;
+import static run.halo.app.extension.Unstructured.OBJECT_MAPPER;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.swagger.v3.core.util.Json;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Optional;
+import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.openapi4j.core.exception.ResolutionException;
import org.openapi4j.core.model.v3.OAI3;
@@ -36,17 +37,14 @@ import run.halo.app.extension.store.ExtensionStore;
@Component
public class JSONExtensionConverter implements ExtensionConverter {
+ @Getter
public final ObjectMapper objectMapper;
private final SchemeManager schemeManager;
public JSONExtensionConverter(SchemeManager schemeManager) {
this.schemeManager = schemeManager;
- this.objectMapper = Json.mapper();
- }
-
- public ObjectMapper getObjectMapper() {
- return objectMapper;
+ this.objectMapper = OBJECT_MAPPER;
}
@Override
diff --git a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java
index 50f7bc7d0..c1c0bd963 100644
--- a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java
+++ b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java
@@ -1,8 +1,14 @@
package run.halo.app.infra;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
+import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter;
+import org.springframework.web.util.UriComponentsBuilder;
+import reactor.core.publisher.Mono;
import run.halo.app.infra.utils.PathUtils;
/**
@@ -25,6 +31,34 @@ public class DefaultExternalLinkProcessor implements ExternalLinkProcessor {
return append(externalLink.toString(), link);
}
+ @Override
+ public Mono processLink(URI uri) {
+ if (uri.isAbsolute()) {
+ return Mono.just(uri);
+ }
+ return Mono.deferContextual(contextView -> Mono.fromSupplier(
+ () -> ServerWebExchangeContextFilter.getExchange(contextView)
+ .map(exchange -> externalUrlSupplier.getURL(exchange.getRequest()))
+ .or(() -> Optional.ofNullable(externalUrlSupplier.getRaw()))
+ .map(externalUrl -> {
+ try {
+ var uriComponents = UriComponentsBuilder.fromUriString(uri.toASCIIString())
+ .build(true);
+ return UriComponentsBuilder.fromUri(externalUrl.toURI())
+ .pathSegment(uriComponents.getPathSegments().toArray(new String[0]))
+ .queryParams(uriComponents.getQueryParams())
+ .fragment(uriComponents.getFragment())
+ .build(true)
+ .toUri();
+ } catch (URISyntaxException e) {
+ // should never happen
+ return uri;
+ }
+ })
+ .orElse(uri)
+ ));
+ }
+
String append(String externalLink, String link) {
return StringUtils.removeEnd(externalLink, "/")
+ StringUtils.prependIfMissing(link, "/");
diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java
new file mode 100644
index 000000000..30c5c36fe
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java
@@ -0,0 +1,62 @@
+package run.halo.app.infra.actuator;
+
+import java.net.URL;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+import lombok.Data;
+
+/**
+ * Global info.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Data
+public class GlobalInfo {
+
+ private URL externalUrl;
+
+ private boolean useAbsolutePermalink;
+
+ private TimeZone timeZone;
+
+ private Locale locale;
+
+ private boolean allowComments;
+
+ private boolean allowAnonymousComments;
+
+ private boolean allowRegistration;
+
+ private String favicon;
+
+ private boolean userInitialized;
+
+ private boolean dataInitialized;
+
+ private String postSlugGenerationStrategy;
+
+ private List socialAuthProviders;
+
+ private Boolean mustVerifyEmailOnRegistration;
+
+ private String siteTitle;
+
+ @Data
+ public static class SocialAuthProvider {
+ private String name;
+
+ private String displayName;
+
+ private String description;
+
+ private String logo;
+
+ private String website;
+
+ private String authenticationUrl;
+
+ private String bindingUrl;
+ }
+}
diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java
index 9d53a2d52..dec60c0cc 100644
--- a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java
+++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java
@@ -1,171 +1,26 @@
package run.halo.app.infra.actuator;
-import static org.apache.commons.lang3.BooleanUtils.isTrue;
-
-import java.net.URL;
-import java.util.List;
-import java.util.Locale;
-import java.util.TimeZone;
-import lombok.Data;
-import lombok.RequiredArgsConstructor;
-import org.apache.commons.lang3.StringUtils;
-import org.springframework.beans.factory.ObjectProvider;
+import java.time.Duration;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint;
import org.springframework.stereotype.Component;
-import run.halo.app.extension.ConfigMap;
-import run.halo.app.infra.InitializationStateGetter;
-import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
-import run.halo.app.infra.SystemSetting;
-import run.halo.app.infra.SystemSetting.Basic;
-import run.halo.app.infra.SystemSetting.Comment;
-import run.halo.app.infra.SystemSetting.User;
-import run.halo.app.infra.properties.HaloProperties;
-import run.halo.app.security.AuthProviderService;
+/**
+ * Global info endpoint.
+ */
@WebEndpoint(id = "globalinfo")
@Component
-@RequiredArgsConstructor
public class GlobalInfoEndpoint {
- private final ObjectProvider systemConfigFetcher;
+ private final GlobalInfoService globalInfoService;
- private final HaloProperties haloProperties;
-
- private final AuthProviderService authProviderService;
-
- private final InitializationStateGetter initializationStateGetter;
+ public GlobalInfoEndpoint(GlobalInfoService globalInfoService) {
+ this.globalInfoService = globalInfoService;
+ }
@ReadOperation
public GlobalInfo globalInfo() {
- final var info = new GlobalInfo();
- info.setExternalUrl(haloProperties.getExternalUrl());
- info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink());
- info.setLocale(Locale.getDefault());
- info.setTimeZone(TimeZone.getDefault());
- info.setUserInitialized(initializationStateGetter.userInitialized()
- .blockOptional().orElse(false));
- info.setDataInitialized(initializationStateGetter.dataInitialized()
- .blockOptional().orElse(false));
- handleSocialAuthProvider(info);
- systemConfigFetcher.ifAvailable(fetcher -> fetcher.getConfigMapBlocking()
- .ifPresent(configMap -> {
- handleCommentSetting(info, configMap);
- handleUserSetting(info, configMap);
- handleBasicSetting(info, configMap);
- handlePostSlugGenerationStrategy(info, configMap);
- }));
- return info;
- }
-
- @Data
- public static class GlobalInfo {
- private URL externalUrl;
-
- private boolean useAbsolutePermalink;
-
- private TimeZone timeZone;
-
- private Locale locale;
-
- private boolean allowComments;
-
- private boolean allowAnonymousComments;
-
- private boolean allowRegistration;
-
- private String favicon;
-
- private boolean userInitialized;
-
- private boolean dataInitialized;
-
- private String postSlugGenerationStrategy;
-
- private List socialAuthProviders;
-
- private Boolean mustVerifyEmailOnRegistration;
-
- private String siteTitle;
- }
-
- @Data
- public static class SocialAuthProvider {
- private String name;
-
- private String displayName;
-
- private String description;
-
- private String logo;
-
- private String website;
-
- private String authenticationUrl;
-
- private String bindingUrl;
- }
-
- private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) {
- var comment = SystemSetting.get(configMap, Comment.GROUP, Comment.class);
- if (comment == null) {
- info.setAllowComments(true);
- info.setAllowAnonymousComments(true);
- } else {
- info.setAllowComments(comment.getEnable() != null && comment.getEnable());
- info.setAllowAnonymousComments(
- comment.getSystemUserOnly() == null || !comment.getSystemUserOnly());
- }
- }
-
- private void handleUserSetting(GlobalInfo info, ConfigMap configMap) {
- var userSetting = SystemSetting.get(configMap, User.GROUP, User.class);
- if (userSetting == null) {
- info.setAllowRegistration(false);
- info.setMustVerifyEmailOnRegistration(false);
- } else {
- info.setAllowRegistration(
- userSetting.getAllowRegistration() != null && userSetting.getAllowRegistration());
- info.setMustVerifyEmailOnRegistration(userSetting.getMustVerifyEmailOnRegistration());
- }
- }
-
- private void handlePostSlugGenerationStrategy(GlobalInfo info, ConfigMap configMap) {
- var post = SystemSetting.get(configMap, SystemSetting.Post.GROUP, SystemSetting.Post.class);
- if (post != null) {
- info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy());
- }
- }
-
- private void handleBasicSetting(GlobalInfo info, ConfigMap configMap) {
- var basic = SystemSetting.get(configMap, Basic.GROUP, Basic.class);
- if (basic != null) {
- info.setFavicon(basic.getFavicon());
- info.setSiteTitle(basic.getTitle());
- }
- }
-
- private void handleSocialAuthProvider(GlobalInfo info) {
- List providers = authProviderService.listAll()
- .map(listedAuthProviders -> listedAuthProviders.stream()
- .filter(provider -> isTrue(provider.getEnabled()))
- .filter(provider -> StringUtils.isNotBlank(provider.getBindingUrl()))
- .map(provider -> {
- SocialAuthProvider socialAuthProvider = new SocialAuthProvider();
- socialAuthProvider.setName(provider.getName());
- socialAuthProvider.setDisplayName(provider.getDisplayName());
- socialAuthProvider.setDescription(provider.getDescription());
- socialAuthProvider.setLogo(provider.getLogo());
- socialAuthProvider.setWebsite(provider.getWebsite());
- socialAuthProvider.setAuthenticationUrl(provider.getAuthenticationUrl());
- socialAuthProvider.setBindingUrl(provider.getBindingUrl());
- return socialAuthProvider;
- })
- .toList()
- )
- .block();
-
- info.setSocialAuthProviders(providers);
+ return globalInfoService.getGlobalInfo().block(Duration.ofMinutes(1));
}
}
diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java
new file mode 100644
index 000000000..7f5ef15d1
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java
@@ -0,0 +1,20 @@
+package run.halo.app.infra.actuator;
+
+import reactor.core.publisher.Mono;
+
+/**
+ * Global info service.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+public interface GlobalInfoService {
+
+ /**
+ * Get global info.
+ *
+ * @return global info
+ */
+ Mono getGlobalInfo();
+
+}
diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java
new file mode 100644
index 000000000..f671f0c97
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java
@@ -0,0 +1,147 @@
+package run.halo.app.infra.actuator;
+
+import static org.apache.commons.lang3.BooleanUtils.isTrue;
+
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.TimeZone;
+import org.apache.commons.lang3.StringUtils;
+import org.reactivestreams.Publisher;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+import run.halo.app.extension.ConfigMap;
+import run.halo.app.infra.InitializationStateGetter;
+import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
+import run.halo.app.infra.SystemSetting;
+import run.halo.app.infra.properties.HaloProperties;
+import run.halo.app.security.AuthProviderService;
+
+/**
+ * Global info service implementation.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Service
+public class GlobalInfoServiceImpl implements GlobalInfoService {
+
+ private final HaloProperties haloProperties;
+
+ private final AuthProviderService authProviderService;
+
+ private final InitializationStateGetter initializationStateGetter;
+
+ private final ObjectProvider
+ systemConfigFetcher;
+
+ public GlobalInfoServiceImpl(HaloProperties haloProperties,
+ AuthProviderService authProviderService,
+ InitializationStateGetter initializationStateGetter,
+ ObjectProvider systemConfigFetcher) {
+ this.haloProperties = haloProperties;
+ this.authProviderService = authProviderService;
+ this.initializationStateGetter = initializationStateGetter;
+ this.systemConfigFetcher = systemConfigFetcher;
+ }
+
+ @Override
+ public Mono getGlobalInfo() {
+ final var info = new GlobalInfo();
+ info.setExternalUrl(haloProperties.getExternalUrl());
+ info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink());
+ info.setLocale(Locale.getDefault());
+ info.setTimeZone(TimeZone.getDefault());
+
+ var publishers = new ArrayList>(4);
+ publishers.add(
+ initializationStateGetter.userInitialized().doOnNext(info::setUserInitialized)
+ );
+ publishers.add(
+ initializationStateGetter.dataInitialized().doOnNext(info::setDataInitialized)
+ );
+ publishers.add(handleSocialAuthProvider(info));
+ publishers.add(handleSettings(info));
+ return Mono.when(publishers).then(Mono.just(info));
+ }
+
+ private Mono handleSettings(GlobalInfo info) {
+ return Optional.ofNullable(systemConfigFetcher.getIfUnique())
+ .map(fetcher -> fetcher.getConfigMap()
+ .doOnNext(configMap -> {
+ handleCommentSetting(info, configMap);
+ handleUserSetting(info, configMap);
+ handleBasicSetting(info, configMap);
+ handlePostSlugGenerationStrategy(info, configMap);
+ })
+ .then()
+ )
+ .orElseGet(Mono::empty);
+ }
+
+ private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) {
+ var comment =
+ SystemSetting.get(configMap, SystemSetting.Comment.GROUP, SystemSetting.Comment.class);
+ if (comment == null) {
+ info.setAllowComments(true);
+ info.setAllowAnonymousComments(true);
+ } else {
+ info.setAllowComments(comment.getEnable() != null && comment.getEnable());
+ info.setAllowAnonymousComments(
+ comment.getSystemUserOnly() == null || !comment.getSystemUserOnly());
+ }
+ }
+
+ private void handleUserSetting(GlobalInfo info, ConfigMap configMap) {
+ var userSetting =
+ SystemSetting.get(configMap, SystemSetting.User.GROUP, SystemSetting.User.class);
+ if (userSetting == null) {
+ info.setAllowRegistration(false);
+ info.setMustVerifyEmailOnRegistration(false);
+ } else {
+ info.setAllowRegistration(userSetting.isAllowRegistration());
+ info.setMustVerifyEmailOnRegistration(userSetting.isMustVerifyEmailOnRegistration());
+ }
+ }
+
+ private void handlePostSlugGenerationStrategy(GlobalInfo info,
+ ConfigMap configMap) {
+ var post = SystemSetting.get(configMap, SystemSetting.Post.GROUP, SystemSetting.Post.class);
+ if (post != null) {
+ info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy());
+ }
+ }
+
+ private void handleBasicSetting(GlobalInfo info, ConfigMap configMap) {
+ var basic =
+ SystemSetting.get(configMap, SystemSetting.Basic.GROUP, SystemSetting.Basic.class);
+ if (basic != null) {
+ info.setFavicon(basic.getFavicon());
+ info.setSiteTitle(basic.getTitle());
+ }
+ }
+
+ private Mono handleSocialAuthProvider(GlobalInfo info) {
+ return authProviderService.listAll()
+ .map(listedAuthProviders -> listedAuthProviders.stream()
+ .filter(provider -> isTrue(provider.getEnabled()))
+ .filter(provider -> StringUtils.isNotBlank(provider.getBindingUrl()))
+ .map(provider -> {
+ GlobalInfo.SocialAuthProvider socialAuthProvider =
+ new GlobalInfo.SocialAuthProvider();
+ socialAuthProvider.setName(provider.getName());
+ socialAuthProvider.setDisplayName(provider.getDisplayName());
+ socialAuthProvider.setDescription(provider.getDescription());
+ socialAuthProvider.setLogo(provider.getLogo());
+ socialAuthProvider.setWebsite(provider.getWebsite());
+ socialAuthProvider.setAuthenticationUrl(provider.getAuthenticationUrl());
+ socialAuthProvider.setBindingUrl(provider.getBindingUrl());
+ return socialAuthProvider;
+ })
+ .toList()
+ )
+ .doOnNext(info::setSocialAuthProviders)
+ .then();
+ }
+}
diff --git a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java
index 79f3c014e..9de837d9c 100644
--- a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java
+++ b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java
@@ -11,6 +11,7 @@ import org.springframework.boot.autoconfigure.session.SessionProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
@@ -18,10 +19,10 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
+import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
import org.springframework.session.MapSession;
import org.springframework.session.config.annotation.web.server.EnableSpringWebSession;
-import org.springframework.web.reactive.function.server.RouterFunction;
-import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.user.service.RoleService;
import run.halo.app.core.user.service.UserService;
import run.halo.app.extension.ReactiveExtensionClient;
@@ -31,7 +32,6 @@ import run.halo.app.security.DefaultUserDetailService;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.impl.RsaKeyService;
-import run.halo.app.security.authentication.login.PublicKeyRouteBuilder;
import run.halo.app.security.authentication.pat.PatAuthenticationManager;
import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager;
@@ -59,20 +59,33 @@ public class WebServerSecurityConfig {
CryptoService cryptoService,
HaloProperties haloProperties) {
- http.securityMatcher(pathMatchers("/**"))
+ var pathMatcher = pathMatchers("/**");
+ var staticResourcesMatcher = pathMatchers(HttpMethod.GET,
+ "/themes/{themeName}/assets/{*resourcePaths}",
+ "/plugins/{pluginName}/assets/**",
+ "/console/**",
+ "/uc/**",
+ "/upload/**",
+ "/webjars/**",
+ "/js/**",
+ "/styles/**",
+ "/halo-tracker.js",
+ "/images/**"
+ );
+
+ var securityMatcher = new AndServerWebExchangeMatcher(pathMatcher,
+ new NegatedServerWebExchangeMatcher(staticResourcesMatcher));
+
+ http.securityMatcher(securityMatcher)
.authorizeExchange(spec -> spec.pathMatchers(
"/api/**",
"/apis/**",
"/oauth2/**",
- "/login/**",
- "/logout",
"/actuator/**"
)
- .access(
- new TwoFactorAuthorizationManager(
- new RequestInfoAuthorizationManager(roleService)
- )
- )
+ .access(new RequestInfoAuthorizationManager(roleService))
+ .pathMatchers("/challenges/two-factor/**")
+ .access(new TwoFactorAuthorizationManager())
.anyExchange().permitAll())
.anonymous(spec -> {
spec.authorities(AnonymousUserConst.Role);
@@ -140,11 +153,6 @@ public class WebServerSecurityConfig {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
- @Bean
- RouterFunction publicKeyRoute(CryptoService cryptoService) {
- return new PublicKeyRouteBuilder(cryptoService).build();
- }
-
@Bean
CryptoService cryptoService(HaloProperties haloProperties) {
return new RsaKeyService(haloProperties.getWorkDir().resolve("keys"));
diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java
index 353f2fb99..4bcdbefce 100644
--- a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java
+++ b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java
@@ -3,6 +3,7 @@ package run.halo.app.infra.exception;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
+import lombok.Getter;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.http.ProblemDetail;
@@ -11,6 +12,7 @@ import org.springframework.validation.Errors;
import org.springframework.web.server.ServerWebInputException;
import org.springframework.web.util.BindErrorUtils;
+@Getter
public class RequestBodyValidationException extends ServerWebInputException {
private final Errors errors;
diff --git a/application/src/main/java/run/halo/app/security/AuthProviderService.java b/application/src/main/java/run/halo/app/security/AuthProviderService.java
index c996db4d3..6bd8093dd 100644
--- a/application/src/main/java/run/halo/app/security/AuthProviderService.java
+++ b/application/src/main/java/run/halo/app/security/AuthProviderService.java
@@ -1,6 +1,7 @@
package run.halo.app.security;
import java.util.List;
+import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.AuthProvider;
@@ -17,4 +18,7 @@ public interface AuthProviderService {
Mono disable(String name);
Mono> listAll();
+
+ Flux getEnabledProviders();
+
}
diff --git a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java
index 4b318ed56..d6838bd57 100644
--- a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java
+++ b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java
@@ -19,9 +19,12 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.AuthProvider;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.extension.ConfigMap;
+import run.halo.app.extension.ExtensionUtil;
+import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
@@ -56,10 +59,10 @@ public class AuthProviderServiceImpl implements AuthProviderService {
@Override
public Mono> listAll() {
- return client.list(AuthProvider.class, provider ->
- provider.getMetadata().getDeletionTimestamp() == null,
- defaultComparator()
- )
+ var listOptions = ListOptions.builder()
+ .andQuery(ExtensionUtil.notDeleting())
+ .build();
+ return client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort())
.map(this::convertTo)
.collectList()
.flatMap(providers -> listMyConnections()
@@ -86,6 +89,17 @@ public class AuthProviderServiceImpl implements AuthProviderService {
);
}
+ @Override
+ public Flux getEnabledProviders() {
+ return fetchEnabledAuthProviders().flatMapMany(enabledNames -> {
+ var listOptions = ListOptions.builder()
+ .andQuery(QueryFactory.in("metadata.name", enabledNames))
+ .andQuery(ExtensionUtil.notDeleting())
+ .build();
+ return client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort());
+ });
+ }
+
private Mono> fetchEnabledAuthProviders() {
return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
.map(configMap -> {
@@ -97,12 +111,14 @@ public class AuthProviderServiceImpl implements AuthProviderService {
Flux listMyConnections() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> securityContext.getAuthentication().getName())
- .flatMapMany(username -> client.list(UserConnection.class,
- persisted -> persisted.getSpec().getUsername().equals(username),
- Comparator.comparing(item -> item.getMetadata()
- .getCreationTimestamp())
- )
- );
+ .flatMapMany(username -> {
+ var listOptions = ListOptions.builder()
+ .andQuery(QueryFactory.equal("spec.username", username))
+ .andQuery(ExtensionUtil.notDeleting())
+ .build();
+ return client.listAll(UserConnection.class, listOptions,
+ ExtensionUtil.defaultSort());
+ });
}
private static Comparator defaultComparator() {
diff --git a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java
index 9e0af48e5..869e5fa42 100644
--- a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java
+++ b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java
@@ -4,6 +4,9 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@@ -17,15 +20,37 @@ import reactor.core.publisher.Mono;
*/
public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
+ private final ServerWebExchangeMatcher xhrMatcher = exchange -> {
+ if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
+ .contains("XMLHttpRequest")) {
+ return MatchResult.match();
+ }
+ return MatchResult.notMatch();
+ };
+
+ private final RedirectServerAuthenticationEntryPoint redirectEntryPoint;
+
+ public DefaultServerAuthenticationEntryPoint() {
+ this.redirectEntryPoint =
+ new RedirectServerAuthenticationEntryPoint("/login?authentication_required");
+ }
+
@Override
public Mono commence(ServerWebExchange exchange, AuthenticationException ex) {
- return Mono.defer(() -> {
- var response = exchange.getResponse();
- var wwwAuthenticate = "FormLogin realm=\"console\"";
- response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
- response.setStatusCode(HttpStatus.UNAUTHORIZED);
- return response.setComplete();
- });
+ return xhrMatcher.matches(exchange)
+ .filter(MatchResult::isMatch)
+ .switchIfEmpty(
+ Mono.defer(() -> this.redirectEntryPoint.commence(exchange, ex)).then(Mono.empty())
+ )
+ .flatMap(match -> Mono.defer(
+ () -> {
+ var response = exchange.getResponse();
+ var wwwAuthenticate = "FormLogin realm=\"console\"";
+ response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
+ response.setStatusCode(HttpStatus.UNAUTHORIZED);
+ return response.setComplete();
+ }).then(Mono.empty())
+ );
}
}
diff --git a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java
index bb72c7ca0..c4497927f 100644
--- a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java
+++ b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java
@@ -6,7 +6,9 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
+import run.halo.app.security.authentication.rememberme.RememberMeRequestCache;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
+import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache;
import run.halo.app.security.device.DeviceService;
/**
@@ -24,11 +26,15 @@ public class LoginHandlerEnhancerImpl implements LoginHandlerEnhancer {
private final DeviceService deviceService;
+ private final RememberMeRequestCache rememberMeRequestCache =
+ new WebSessionRememberMeRequestCache();
+
@Override
public Mono onLoginSuccess(ServerWebExchange exchange,
Authentication successfulAuthentication) {
return rememberMeServices.loginSuccess(exchange, successfulAuthentication)
- .then(deviceService.loginSuccess(exchange, successfulAuthentication));
+ .then(deviceService.loginSuccess(exchange, successfulAuthentication))
+ .then(rememberMeRequestCache.removeRememberMe(exchange));
}
@Override
diff --git a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java
index 79c3974d6..1cf8effe5 100644
--- a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java
+++ b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java
@@ -1,11 +1,12 @@
package run.halo.app.security;
-import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
import java.net.URI;
+import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.web.server.ServerHttpSecurity;
@@ -15,8 +16,10 @@ import org.springframework.security.web.server.authentication.logout.DelegatingS
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
-import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
@@ -31,8 +34,8 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
public void configure(ServerHttpSecurity http) {
var serverLogoutHandlers = getLogoutHandlers();
http.logout(
- logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers)));
- http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING);
+ logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers))
+ );
}
private class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
@@ -42,7 +45,7 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
public LogoutSuccessHandler(ServerLogoutHandler... logoutHandler) {
var defaultHandler = new RedirectServerLogoutSuccessHandler();
- defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout"));
+ defaultHandler.setLogoutSuccessUrl(URI.create("/login?logout"));
this.defaultHandler = defaultHandler;
if (logoutHandler.length == 1) {
this.logoutHandler = logoutHandler[0];
@@ -51,6 +54,19 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
}
}
+ @Bean
+ RouterFunction logoutPage() {
+ return RouterFunctions.route()
+ .GET("/logout", request -> {
+ var exchange = request.exchange();
+ var contextPath = exchange.getRequest().getPath().contextPath().value();
+ return ServerResponse.ok().render("logout", Map.of(
+ "action", contextPath + "/logout"
+ ));
+ })
+ .build();
+ }
+
@Override
public Mono onLogoutSuccess(WebFilterExchange exchange,
Authentication authentication) {
diff --git a/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java b/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java
new file mode 100644
index 000000000..9e0f4382e
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java
@@ -0,0 +1,21 @@
+package run.halo.app.security.authentication.exception;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.core.AuthenticationException;
+import run.halo.app.infra.exception.RateLimitExceededException;
+
+/**
+ * Too many requests exception while authenticating. Because
+ * {@link RateLimitExceededException} is not a subclass of
+ * {@link AuthenticationException}, we need to create a new exception class to map it.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+public class TooManyRequestsException extends AuthenticationException {
+
+ public TooManyRequestsException(@Nullable Throwable throwable) {
+ super("Too many requests.", throwable);
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java b/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java
new file mode 100644
index 000000000..fb37664b1
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java
@@ -0,0 +1,15 @@
+package run.halo.app.security.authentication.exception;
+
+import org.springframework.security.core.AuthenticationException;
+
+public class TwoFactorAuthException extends AuthenticationException {
+
+ public TwoFactorAuthException(String msg, Throwable cause) {
+ super(msg, cause);
+ }
+
+ public TwoFactorAuthException(String msg) {
+ super(msg);
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java
index 9a3bfb7e1..26df92cbf 100644
--- a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java
+++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java
@@ -13,9 +13,9 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
-import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.infra.utils.IpAddressUtils;
import run.halo.app.security.authentication.CryptoService;
+import run.halo.app.security.authentication.exception.TooManyRequestsException;
@Slf4j
public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter {
@@ -35,6 +35,9 @@ public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationC
return super.convert(exchange)
// validate the password
.flatMap(token -> {
+ if (token.getCredentials() == null) {
+ return Mono.error(new BadCredentialsException("Empty credentials."));
+ }
var credentials = (String) token.getCredentials();
byte[] credentialsBytes;
try {
@@ -51,7 +54,9 @@ public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationC
new String(decryptedCredentials, UTF_8)));
})
.transformDeferred(createIpBasedRateLimiter(exchange))
- .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
+ // We have to remap the exception to an AuthenticationException
+ // for using in failure handler
+ .onErrorMap(RequestNotPermitted.class, TooManyRequestsException::new);
}
private RateLimiterOperator createIpBasedRateLimiter(ServerWebExchange exchange) {
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java
index 8fff49645..fb49212d8 100644
--- a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java
+++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java
@@ -9,18 +9,23 @@ import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.core.publisher.Mono;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
+import run.halo.app.security.HaloUserDetails;
import run.halo.app.security.LoginHandlerEnhancer;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authentication.SecurityConfigurer;
+import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
@Component
public class LoginSecurityConfigurer implements SecurityConfigurer {
@@ -66,10 +71,20 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
@Override
public void configure(ServerHttpSecurity http) {
- var filter = new AuthenticationWebFilter(authenticationManager());
+ var filter = new AuthenticationWebFilter(authenticationManager()) {
+ @Override
+ protected Mono onAuthenticationSuccess(Authentication authentication,
+ WebFilterExchange webFilterExchange) {
+ // check if 2FA is enabled after authenticating successfully.
+ if (authentication.getPrincipal() instanceof HaloUserDetails userDetails
+ && userDetails.isTwoFactorAuthEnabled()) {
+ authentication = new TwoFactorAuthentication(authentication);
+ }
+ return super.onAuthenticationSuccess(authentication, webFilterExchange);
+ }
+ };
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
- var handler =
- new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer);
+ var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer);
var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry);
filter.setRequiresAuthenticationMatcher(requiresMatcher);
filter.setAuthenticationFailureHandler(handler);
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java
index 912d9a526..5ddf5e78c 100644
--- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java
+++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java
@@ -6,8 +6,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
-import run.halo.app.security.HaloUserDetails;
-import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
@Slf4j
public class UsernamePasswordDelegatingAuthenticationManager
@@ -40,14 +38,6 @@ public class UsernamePasswordDelegatingAuthenticationManager
)
.switchIfEmpty(
Mono.defer(() -> defaultAuthenticationManager.authenticate(authentication))
- )
- // check if MFA is enabled after authenticated
- .map(a -> {
- if (a.getPrincipal() instanceof HaloUserDetails user
- && user.isTwoFactorAuthEnabled()) {
- a = new TwoFactorAuthentication(a);
- }
- return a;
- });
+ );
}
}
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java
index 33ce5b04d..a04a90208 100644
--- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java
+++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java
@@ -5,13 +5,17 @@ import static org.springframework.http.MediaType.APPLICATION_JSON;
import static run.halo.app.infra.exception.Exceptions.createErrorResponse;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
+import java.net.URI;
+import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
+import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.CredentialsContainer;
+import org.springframework.security.web.server.DefaultServerRedirectStrategy;
+import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.security.web.server.WebFilterExchange;
-import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
@@ -21,6 +25,9 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.security.LoginHandlerEnhancer;
+import run.halo.app.security.authentication.exception.TooManyRequestsException;
+import run.halo.app.security.authentication.rememberme.RememberMeRequestCache;
+import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
@Slf4j
@@ -33,8 +40,10 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
private final LoginHandlerEnhancer loginHandlerEnhancer;
- private final ServerAuthenticationFailureHandler defaultFailureHandler =
- new RedirectServerAuthenticationFailureHandler("/console?error#/login");
+ private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+
+ @Setter
+ private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache();
private final ServerAuthenticationSuccessHandler defaultSuccessHandler =
new RedirectServerAuthenticationSuccessHandler("/console/");
@@ -54,10 +63,17 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
.then(ignoringMediaTypeAll(APPLICATION_JSON)
.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
- .switchIfEmpty(
- defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception)
- // Skip the handleAuthenticationException.
- .then(Mono.empty())
+ .switchIfEmpty(Mono.defer(
+ () -> {
+ URI location = URI.create("/login?error");
+ if (exception instanceof BadCredentialsException) {
+ location = URI.create("/login?error=invalid-credential");
+ }
+ if (exception instanceof TooManyRequestsException) {
+ location = URI.create("/login?error=rate-limit-exceeded");
+ }
+ return redirectStrategy.sendRedirect(exchange, location);
+ }).then(Mono.empty())
)
.flatMap(matchResult -> handleAuthenticationException(exception, exchange)));
}
@@ -66,10 +82,12 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
if (authentication instanceof TwoFactorAuthentication) {
- // continue filtering for authorization
- return loginHandlerEnhancer.onLoginSuccess(webFilterExchange.getExchange(),
- authentication)
- .then(webFilterExchange.getChain().filter(webFilterExchange.getExchange()));
+ return rememberMeRequestCache.saveRememberMe(webFilterExchange.getExchange())
+ // Do not use RedirectServerAuthenticationSuccessHandler to redirect
+ // because it will use request cache to redirect
+ .then(redirectStrategy.sendRedirect(webFilterExchange.getExchange(),
+ URI.create("/challenges/two-factor/totp"))
+ );
}
if (authentication instanceof CredentialsContainer container) {
diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java
new file mode 100644
index 000000000..846afb821
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java
@@ -0,0 +1,39 @@
+package run.halo.app.security.authentication.rememberme;
+
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+/**
+ * An interface for caching remember-me parameter in request for further handling. Especially
+ * useful for two-factor authentication.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+public interface RememberMeRequestCache {
+
+ /**
+ * Save remember-me parameter or form into cache.
+ *
+ * @param exchange exchange
+ * @return empty to return
+ */
+ Mono saveRememberMe(ServerWebExchange exchange);
+
+ /**
+ * Check if remember-me parameter exists in cache.
+ *
+ * @param exchange exchange
+ * @return true if remember-me exists, false otherwise
+ */
+ Mono isRememberMe(ServerWebExchange exchange);
+
+ /**
+ * Remove remember-me parameter from cache.
+ *
+ * @param exchange exchange
+ * @return empty to return
+ */
+ Mono removeRememberMe(ServerWebExchange exchange);
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java
index 17e67308f..4f1e9c0da 100644
--- a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java
+++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java
@@ -1,8 +1,5 @@
package run.halo.app.security.authentication.rememberme;
-import static org.apache.commons.lang3.BooleanUtils.isTrue;
-import static org.apache.commons.lang3.BooleanUtils.toBoolean;
-
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -58,17 +55,13 @@ import reactor.core.publisher.Mono;
@RequiredArgsConstructor
public class TokenBasedRememberMeServices implements ServerLogoutHandler, RememberMeServices {
- public static final int TWO_WEEKS_S = 1209600;
-
- public static final String DEFAULT_PARAMETER = "remember-me";
-
public static final String DEFAULT_ALGORITHM = "SHA-256";
private static final String DELIMITER = ":";
protected final CookieSignatureKeyResolver cookieSignatureKeyResolver;
- protected final ReactiveUserDetailsService userDetailsService;
+ private final ReactiveUserDetailsService userDetailsService;
protected final RememberMeCookieResolver rememberMeCookieResolver;
@@ -76,6 +69,8 @@ public class TokenBasedRememberMeServices implements ServerLogoutHandler, Rememb
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
+ private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache();
+
private static boolean equals(String expected, String actual) {
byte[] expectedBytes = bytesUtf8(expected);
byte[] actualBytes = bytesUtf8(actual);
@@ -214,11 +209,12 @@ public class TokenBasedRememberMeServices implements ServerLogoutHandler, Rememb
@Override
public Mono loginSuccess(ServerWebExchange exchange,
Authentication successfulAuthentication) {
- if (!rememberMeRequested(exchange)) {
- log.debug("Remember-me login not requested.");
- return Mono.empty();
- }
- return onLoginSuccess(exchange, successfulAuthentication);
+ return rememberMeRequestCache.isRememberMe(exchange)
+ .filter(Boolean::booleanValue)
+ .switchIfEmpty(Mono.fromRunnable(() -> {
+ log.debug("Remember-me login not requested.");
+ }))
+ .flatMap(rememberMe -> onLoginSuccess(exchange, successfulAuthentication));
}
protected Mono onLoginSuccess(ServerWebExchange exchange,
@@ -282,18 +278,6 @@ public class TokenBasedRememberMeServices implements ServerLogoutHandler, Rememb
return Instant.now().plusSeconds(tokenLifetime).toEpochMilli();
}
- protected boolean rememberMeRequested(ServerWebExchange exchange) {
- String rememberMe = exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER);
- if (isTrue(toBoolean(rememberMe))) {
- return true;
- }
- if (log.isDebugEnabled()) {
- log.debug("Did not send remember-me cookie (principal did not set parameter '{}')",
- DEFAULT_PARAMETER);
- }
- return false;
- }
-
protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
int paddingCount = 4 - (cookieValue.length() % 4);
if (paddingCount < 4) {
diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java
new file mode 100644
index 000000000..5c6f629a0
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java
@@ -0,0 +1,71 @@
+package run.halo.app.security.authentication.rememberme;
+
+import static java.lang.Boolean.parseBoolean;
+
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebSession;
+import reactor.core.publisher.Mono;
+
+/**
+ * An implementation of {@link RememberMeRequestCache} that stores remember-me parameter in
+ * {@link WebSession}.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+public class WebSessionRememberMeRequestCache implements RememberMeRequestCache {
+
+ private static final String SESSION_ATTRIBUTE_NAME =
+ RememberMeRequestCache.class + ".REMEMBER_ME";
+
+ private static final String DEFAULT_PARAMETER = "remember-me";
+
+ @Override
+ public Mono saveRememberMe(ServerWebExchange exchange) {
+ return resolveFromQuery(exchange)
+ .filter(Boolean::booleanValue)
+ .switchIfEmpty(resolveFromForm(exchange))
+ .filter(Boolean::booleanValue)
+ .flatMap(rememberMe -> exchange.getSession().doOnNext(
+ session -> session.getAttributes().put(SESSION_ATTRIBUTE_NAME, rememberMe))
+ )
+ .then();
+ }
+
+ @Override
+ public Mono isRememberMe(ServerWebExchange exchange) {
+ return resolveFromQuery(exchange)
+ .filter(Boolean::booleanValue)
+ .switchIfEmpty(resolveFromForm(exchange))
+ .filter(Boolean::booleanValue)
+ .switchIfEmpty(resolveFromSession(exchange))
+ .defaultIfEmpty(false);
+ }
+
+ @Override
+ public Mono removeRememberMe(ServerWebExchange exchange) {
+ return exchange.getSession()
+ .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_NAME))
+ .then();
+ }
+
+ private Mono resolveFromQuery(ServerWebExchange exchange) {
+ return Mono.just(
+ parseBoolean(exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER))
+ );
+ }
+
+ private Mono resolveFromForm(ServerWebExchange exchange) {
+ return exchange.getFormData()
+ .map(form -> parseBoolean(form.getFirst(DEFAULT_PARAMETER)))
+ .filter(Boolean::booleanValue);
+ }
+
+ private Mono resolveFromSession(ServerWebExchange exchange) {
+ return exchange.getSession()
+ .map(session -> {
+ var rememberMeObject = session.getAttribute(SESSION_ATTRIBUTE_NAME);
+ return rememberMeObject instanceof Boolean rememberMe ? rememberMe : false;
+ });
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java
deleted file mode 100644
index 9de2be00f..000000000
--- a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package run.halo.app.security.authentication.twofactor;
-
-import java.net.URI;
-import org.springframework.context.MessageSource;
-import org.springframework.security.web.server.DefaultServerRedirectStrategy;
-import org.springframework.security.web.server.ServerRedirectStrategy;
-import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
-import org.springframework.stereotype.Component;
-import org.springframework.web.reactive.function.server.ServerResponse;
-import org.springframework.web.server.ServerWebExchange;
-import reactor.core.publisher.Mono;
-import run.halo.app.infra.exception.Exceptions;
-
-@Component
-public class DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthResponseHandler {
-
- private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
-
- private static final String REDIRECT_LOCATION = "/console/login/mfa";
-
- private final MessageSource messageSource;
-
- private final ServerResponse.Context context;
-
- private static final ServerWebExchangeMatcher XHR_MATCHER = exchange -> {
- if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
- .contains("XMLHttpRequest")) {
- return ServerWebExchangeMatcher.MatchResult.match();
- }
- return ServerWebExchangeMatcher.MatchResult.notMatch();
- };
-
- public DefaultTwoFactorAuthResponseHandler(MessageSource messageSource,
- ServerResponse.Context context) {
- this.messageSource = messageSource;
- this.context = context;
- }
-
- @Override
- public Mono handle(ServerWebExchange exchange) {
- return XHR_MATCHER.matches(exchange)
- .filter(ServerWebExchangeMatcher.MatchResult::isMatch)
- .switchIfEmpty(Mono.defer(
- () -> redirectStrategy.sendRedirect(exchange, URI.create(REDIRECT_LOCATION))
- .then(Mono.empty())))
- .flatMap(isXhr -> {
- var errorResponse = Exceptions.createErrorResponse(
- new TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)),
- null, exchange, messageSource);
- return ServerResponse.status(errorResponse.getStatusCode())
- .bodyValue(errorResponse.getBody())
- .flatMap(response -> response.writeTo(exchange, context));
- });
- }
-
-}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java
new file mode 100644
index 000000000..391bf16e9
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java
@@ -0,0 +1,29 @@
+package run.halo.app.security.authentication.twofactor;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.server.WebFilterExchange;
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
+import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
+import reactor.core.publisher.Mono;
+import run.halo.app.security.LoginHandlerEnhancer;
+
+@Slf4j
+public class TotpAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
+
+ private final LoginHandlerEnhancer loginEnhancer;
+
+ private final ServerAuthenticationSuccessHandler successHandler =
+ new RedirectServerAuthenticationSuccessHandler("/uc");
+
+ public TotpAuthenticationSuccessHandler(LoginHandlerEnhancer loginEnhancer) {
+ this.loginEnhancer = loginEnhancer;
+ }
+
+ @Override
+ public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange,
+ Authentication authentication) {
+ return loginEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication)
+ .then(successHandler.onAuthenticationSuccess(webFilterExchange, authentication));
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java
deleted file mode 100644
index a4216a483..000000000
--- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package run.halo.app.security.authentication.twofactor;
-
-import org.springframework.web.server.ServerWebExchange;
-import reactor.core.publisher.Mono;
-
-public interface TwoFactorAuthResponseHandler {
-
- Mono handle(ServerWebExchange exchange);
-
-}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java
index d8dd6a770..1799e12da 100644
--- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java
+++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java
@@ -1,15 +1,19 @@
package run.halo.app.security.authentication.twofactor;
-import org.springframework.context.MessageSource;
+import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
+
+import org.springframework.http.HttpMethod;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.stereotype.Component;
-import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.security.LoginHandlerEnhancer;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
-import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFilter;
+import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationManager;
+import run.halo.app.security.authentication.twofactor.totp.TotpCodeAuthenticationConverter;
@Component
public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
@@ -18,30 +22,33 @@ public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
private final TotpAuthService totpAuthService;
- private final ServerResponse.Context context;
-
- private final MessageSource messageSource;
-
private final LoginHandlerEnhancer loginHandlerEnhancer;
public TwoFactorAuthSecurityConfigurer(
ServerSecurityContextRepository securityContextRepository,
- TotpAuthService totpAuthService,
- ServerResponse.Context context,
- MessageSource messageSource,
- LoginHandlerEnhancer loginHandlerEnhancer
+ TotpAuthService totpAuthService, LoginHandlerEnhancer loginHandlerEnhancer
) {
this.securityContextRepository = securityContextRepository;
this.totpAuthService = totpAuthService;
- this.context = context;
- this.messageSource = messageSource;
this.loginHandlerEnhancer = loginHandlerEnhancer;
}
@Override
public void configure(ServerHttpSecurity http) {
- var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
- context, messageSource, loginHandlerEnhancer);
- http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
+ var authManager = new TotpAuthenticationManager(totpAuthService);
+ var filter = new AuthenticationWebFilter(authManager);
+ filter.setRequiresAuthenticationMatcher(
+ pathMatchers(HttpMethod.POST, "/challenges/two-factor/totp")
+ );
+ filter.setSecurityContextRepository(securityContextRepository);
+ filter.setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
+ filter.setAuthenticationSuccessHandler(
+ new TotpAuthenticationSuccessHandler(loginHandlerEnhancer)
+ );
+ filter.setAuthenticationFailureHandler(
+ new RedirectServerAuthenticationFailureHandler("/challenges/two-factor/totp?error")
+ );
+ http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION);
}
+
}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java
index 45a27e66b..b9da3183e 100644
--- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java
+++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java
@@ -38,8 +38,8 @@ public class TwoFactorAuthentication extends AbstractAuthenticationToken {
@Override
public boolean isAuthenticated() {
- // return true for accessing anonymous resources
- return true;
+ // for further authentication
+ return false;
}
public Authentication getPrevious() {
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java
index f716717a4..f61cb9390 100644
--- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java
+++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java
@@ -1,6 +1,5 @@
package run.halo.app.security.authentication.twofactor;
-import java.net.URI;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
@@ -10,27 +9,12 @@ import reactor.core.publisher.Mono;
public class TwoFactorAuthorizationManager
implements ReactiveAuthorizationManager {
- private final ReactiveAuthorizationManager delegate;
-
- private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp");
-
- public TwoFactorAuthorizationManager(
- ReactiveAuthorizationManager delegate) {
- this.delegate = delegate;
- }
-
@Override
public Mono check(Mono authentication,
AuthorizationContext context) {
- return authentication.flatMap(a -> {
- Mono checked = delegate.check(Mono.just(a), context);
- if (a instanceof TwoFactorAuthentication) {
- checked = checked.filter(AuthorizationDecision::isGranted)
- .switchIfEmpty(
- Mono.error(() -> new TwoFactorAuthRequiredException(REDIRECT_LOCATION)));
- }
- return checked;
- });
+ return authentication.map(TwoFactorAuthentication.class::isInstance)
+ .defaultIfEmpty(false)
+ .map(AuthorizationDecision::new);
}
}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java
deleted file mode 100644
index b2140007d..000000000
--- a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package run.halo.app.security.authentication.twofactor.totp;
-
-import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
-
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
-import org.springframework.context.MessageSource;
-import org.springframework.http.HttpMethod;
-import org.springframework.security.authentication.ReactiveAuthenticationManager;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.AuthenticationException;
-import org.springframework.security.core.CredentialsContainer;
-import org.springframework.security.core.context.ReactiveSecurityContextHolder;
-import org.springframework.security.core.context.SecurityContext;
-import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
-import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
-import org.springframework.security.web.server.context.ServerSecurityContextRepository;
-import org.springframework.web.reactive.function.server.ServerResponse;
-import org.springframework.web.server.ServerWebExchange;
-import reactor.core.publisher.Mono;
-import run.halo.app.security.HaloUserDetails;
-import run.halo.app.security.LoginHandlerEnhancer;
-import run.halo.app.security.authentication.login.UsernamePasswordHandler;
-import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
-
-@Slf4j
-public class TotpAuthenticationFilter extends AuthenticationWebFilter {
-
- public TotpAuthenticationFilter(
- ServerSecurityContextRepository securityContextRepository,
- TotpAuthService totpAuthService,
- ServerResponse.Context context,
- MessageSource messageSource,
- LoginHandlerEnhancer loginHandlerEnhancer
- ) {
- super(new TwoFactorAuthManager(totpAuthService));
-
- setSecurityContextRepository(securityContextRepository);
- setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
- setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
-
- var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer);
- setAuthenticationSuccessHandler(handler);
- setAuthenticationFailureHandler(handler);
- }
-
- private static class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter {
-
- private final String codeParameter = "code";
-
- @Override
- public Mono convert(ServerWebExchange exchange) {
- // Check the request is authenticated before.
- return ReactiveSecurityContextHolder.getContext()
- .map(SecurityContext::getAuthentication)
- .filter(TwoFactorAuthentication.class::isInstance)
- .switchIfEmpty(Mono.error(
- () -> new TwoFactorAuthException("MFA Authentication required.")))
- .flatMap(authentication -> exchange.getFormData())
- .handle((formData, sink) -> {
- var codeStr = formData.getFirst(codeParameter);
- if (StringUtils.isBlank(codeStr)) {
- sink.error(new TwoFactorAuthException("Empty code parameter."));
- return;
- }
- try {
- var code = Integer.parseInt(codeStr);
- sink.next(new TotpAuthenticationToken(code));
- } catch (NumberFormatException e) {
- sink.error(
- new TwoFactorAuthException("Invalid code parameter " + codeStr + '.'));
- }
- });
- }
- }
-
- private static class TwoFactorAuthException extends AuthenticationException {
-
- public TwoFactorAuthException(String msg, Throwable cause) {
- super(msg, cause);
- }
-
- public TwoFactorAuthException(String msg) {
- super(msg);
- }
-
- }
-
- private static class TwoFactorAuthManager implements ReactiveAuthenticationManager {
-
- private final TotpAuthService totpAuthService;
-
- private TwoFactorAuthManager(TotpAuthService totpAuthService) {
- this.totpAuthService = totpAuthService;
- }
-
- @Override
- public Mono authenticate(Authentication authentication) {
- // it should be TotpAuthenticationToken
- var code = (Integer) authentication.getCredentials();
- log.debug("Got TOTP code {}", code);
-
- // get user details
- return ReactiveSecurityContextHolder.getContext()
- .map(SecurityContext::getAuthentication)
- .cast(TwoFactorAuthentication.class)
- .map(TwoFactorAuthentication::getPrevious)
- .flatMap(previousAuth -> {
- var principal = previousAuth.getPrincipal();
- if (!(principal instanceof HaloUserDetails user)) {
- return Mono.error(
- new TwoFactorAuthException("Invalid authentication principal.")
- );
- }
- var totpEncryptedSecret = user.getTotpEncryptedSecret();
- if (StringUtils.isBlank(totpEncryptedSecret)) {
- return Mono.error(
- new TwoFactorAuthException("TOTP secret not configured.")
- );
- }
- var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret);
- var validated = totpAuthService.validateTotp(rawSecret, code);
- if (!validated) {
- return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code));
- }
- if (log.isDebugEnabled()) {
- log.debug("TOTP authentication for {} with code {} successfully.",
- previousAuth.getName(), code);
- }
- if (previousAuth instanceof CredentialsContainer container) {
- container.eraseCredentials();
- }
- return Mono.just(previousAuth);
- });
- }
- }
-}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java
new file mode 100644
index 000000000..e9fffb96f
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java
@@ -0,0 +1,69 @@
+package run.halo.app.security.authentication.twofactor.totp;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.CredentialsContainer;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import reactor.core.publisher.Mono;
+import run.halo.app.security.HaloUserDetails;
+import run.halo.app.security.authentication.exception.TwoFactorAuthException;
+import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
+
+/**
+ * TOTP authentication manager.
+ *
+ * @author johnniang
+ */
+@Slf4j
+public class TotpAuthenticationManager implements ReactiveAuthenticationManager {
+
+ private final TotpAuthService totpAuthService;
+
+ public TotpAuthenticationManager(TotpAuthService totpAuthService) {
+ this.totpAuthService = totpAuthService;
+ }
+
+ @Override
+ public Mono authenticate(Authentication authentication) {
+ // it should be TotpAuthenticationToken
+ var code = (Integer) authentication.getCredentials();
+ log.debug("Got TOTP code {}", code);
+
+ // get user details
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .cast(TwoFactorAuthentication.class)
+ .map(TwoFactorAuthentication::getPrevious)
+ .flatMap(previousAuth -> {
+ var principal = previousAuth.getPrincipal();
+ if (!(principal instanceof HaloUserDetails user)) {
+ return Mono.error(
+ new TwoFactorAuthException("Invalid authentication principal.")
+ );
+ }
+ var totpEncryptedSecret = user.getTotpEncryptedSecret();
+ if (StringUtils.isBlank(totpEncryptedSecret)) {
+ return Mono.error(
+ new TwoFactorAuthException("TOTP secret not configured.")
+ );
+ }
+ var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret);
+ var validated = totpAuthService.validateTotp(rawSecret, code);
+ if (!validated) {
+ return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code));
+ }
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "TOTP authentication for {} with code {} successfully.",
+ previousAuth.getName(), code);
+ }
+ if (previousAuth instanceof CredentialsContainer container) {
+ container.eraseCredentials();
+ }
+ return Mono.just(previousAuth);
+ });
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java
new file mode 100644
index 000000000..9adc9061f
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java
@@ -0,0 +1,52 @@
+package run.halo.app.security.authentication.twofactor.totp;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import run.halo.app.security.authentication.exception.TwoFactorAuthException;
+import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
+
+/**
+ * TOTP code authentication converter.
+ *
+ * @author johnniang
+ */
+public class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter {
+
+ private final String codeParameter = "code";
+
+ @Override
+ public Mono convert(ServerWebExchange exchange) {
+ // Check the request is authenticated before.
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .filter(TwoFactorAuthentication.class::isInstance)
+ .switchIfEmpty(Mono.error(
+ () -> new TwoFactorAuthException(
+ "MFA Authentication required."
+ ))
+ )
+ .flatMap(authentication -> exchange.getFormData())
+ .handle((formData, sink) -> {
+ var codeStr = formData.getFirst(codeParameter);
+ if (StringUtils.isBlank(codeStr)) {
+ sink.error(new TwoFactorAuthException(
+ "Empty code parameter."
+ ));
+ return;
+ }
+ try {
+ var code = Integer.parseInt(codeStr);
+ sink.next(new TotpAuthenticationToken(code));
+ } catch (NumberFormatException e) {
+ sink.error(new TwoFactorAuthException(
+ "Invalid code parameter " + codeStr + '.')
+ );
+ }
+ });
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java
index d01fef7da..d55fafe44 100644
--- a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java
+++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java
@@ -2,7 +2,6 @@ package run.halo.app.security.authorization;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
@@ -12,7 +11,7 @@ import run.halo.app.core.user.service.RoleService;
@Slf4j
public class RequestInfoAuthorizationManager
- implements ReactiveAuthorizationManager {
+ implements ReactiveAuthorizationManager {
private final AuthorizationRuleResolver ruleResolver;
@@ -22,19 +21,19 @@ public class RequestInfoAuthorizationManager
@Override
public Mono check(Mono authentication,
- AuthorizationContext context) {
- ServerHttpRequest request = context.getExchange().getRequest();
- RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
+ AuthorizationContext context) {
+ var request = context.getExchange().getRequest();
+ var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
- return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo)
- .doOnNext(visitor -> showErrorMessage(visitor.getErrors()))
- .filter(AuthorizingVisitor::isAllowed)
- .map(visitor -> new AuthorizationDecision(isGranted(auth)))
- .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))));
- }
-
- private boolean isGranted(Authentication authentication) {
- return authentication != null && authentication.isAuthenticated();
+ // We allow anonymous user to access some resources
+ // so we don't invoke AuthenticationTrustResolver.isAuthenticated
+ // to check if the user is authenticated
+ return authentication.filter(Authentication::isAuthenticated)
+ .flatMap(auth -> ruleResolver.visitRules(auth, requestInfo))
+ .doOnNext(visitor -> showErrorMessage(visitor.getErrors()))
+ .map(AuthorizingVisitor::isAllowed)
+ .defaultIfEmpty(false)
+ .map(AuthorizationDecision::new);
}
private void showErrorMessage(List errors) {
diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java
new file mode 100644
index 000000000..aaa5e8e76
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java
@@ -0,0 +1,94 @@
+package run.halo.app.security.preauth;
+
+import static org.springframework.web.reactive.function.server.RequestPredicates.path;
+
+import java.util.Base64;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import org.springframework.web.server.ServerWebInputException;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.AuthProvider;
+import run.halo.app.infra.actuator.GlobalInfoService;
+import run.halo.app.plugin.PluginConst;
+import run.halo.app.security.AuthProviderService;
+import run.halo.app.security.authentication.CryptoService;
+
+/**
+ * Pre-auth login endpoints.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Component
+class PreAuthLoginEndpoint {
+
+ private final CryptoService cryptoService;
+
+ private final GlobalInfoService globalInfoService;
+
+ private final AuthProviderService authProviderService;
+
+ PreAuthLoginEndpoint(CryptoService cryptoService, GlobalInfoService globalInfoService,
+ AuthProviderService authProviderService) {
+ this.cryptoService = cryptoService;
+ this.globalInfoService = globalInfoService;
+ this.authProviderService = authProviderService;
+ }
+
+ @Bean
+ RouterFunction preAuthLoginEndpoints() {
+ return RouterFunctions.nest(path("/login"), RouterFunctions.route()
+ .GET("", request -> {
+ var exchange = request.exchange();
+ var contextPath = exchange.getRequest().getPath().contextPath().value();
+ var publicKey = cryptoService.readPublicKey()
+ .map(key -> Base64.getEncoder().encodeToString(key));
+ var globalInfo = globalInfoService.getGlobalInfo().cache();
+ var loginMethod = request.queryParam("method").orElse("local");
+ var authProviders = authProviderService.getEnabledProviders().cache();
+ var authProvider = authProviders
+ .filter(ap -> Objects.equals(loginMethod, ap.getMetadata().getName()))
+ .next()
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException(
+ "Invalid login method " + loginMethod)
+ ))
+ .cache();
+
+ var fragmentTemplateName = authProvider.map(ap -> {
+ var templateName = "login_" + ap.getMetadata().getName();
+ return Optional.ofNullable(ap.getMetadata().getLabels())
+ .map(labels -> labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME))
+ .filter(StringUtils::isNotBlank)
+ .map(pluginName -> String.join(":", "plugin", pluginName, templateName))
+ .orElse(templateName);
+ });
+
+ var socialAuthProviders = authProviders
+ .filter(ap -> !AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType()))
+ .cache();
+ var formAuthProviders = authProviders
+ .filter(ap -> AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType()))
+ .filter(ap -> !Objects.equals(loginMethod, ap.getMetadata().getName()))
+ .cache();
+
+ return ServerResponse.ok().render("login", Map.of(
+ "action", contextPath + "/login",
+ "publicKey", publicKey,
+ "globalInfo", globalInfo,
+ "authProvider", authProvider,
+ "fragmentTemplateName", fragmentTemplateName,
+ "socialAuthProviders", socialAuthProviders,
+ "formAuthProviders", formAuthProviders
+ // TODO Add more models here
+ ));
+ })
+ .build());
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java
new file mode 100644
index 000000000..dc49b05c9
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java
@@ -0,0 +1,186 @@
+package run.halo.app.security.preauth;
+
+import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
+import static org.springframework.web.reactive.function.server.RequestPredicates.path;
+
+import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
+import io.github.resilience4j.ratelimiter.RequestNotPermitted;
+import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
+import java.net.URI;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.context.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import run.halo.app.core.user.service.EmailPasswordRecoveryService;
+import run.halo.app.core.user.service.InvalidResetTokenException;
+import run.halo.app.infra.exception.RateLimitExceededException;
+import run.halo.app.infra.utils.IpAddressUtils;
+
+/**
+ * Pre-auth password reset endpoint.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Component
+class PreAuthPasswordResetEndpoint {
+
+ private final EmailPasswordRecoveryService emailPasswordRecoveryService;
+
+ private final MessageSource messageSource;
+
+ private final RateLimiterRegistry rateLimiterRegistry;
+
+ public PreAuthPasswordResetEndpoint(EmailPasswordRecoveryService emailPasswordRecoveryService,
+ MessageSource messageSource,
+ RateLimiterRegistry rateLimiterRegistry
+ ) {
+ this.emailPasswordRecoveryService = emailPasswordRecoveryService;
+ this.messageSource = messageSource;
+ this.rateLimiterRegistry = rateLimiterRegistry;
+ }
+
+ @Bean
+ RouterFunction preAuthPasswordResetEndpoints() {
+ return RouterFunctions.nest(path("/password-reset"), RouterFunctions.route()
+ .GET("", request -> ServerResponse.ok().render("password-reset"))
+ .GET("/{resetToken}",
+ request -> {
+ var token = request.pathVariable("resetToken");
+ return emailPasswordRecoveryService.getValidResetToken(token)
+ .flatMap(resetToken -> {
+ // TODO Check the 2FA of the user
+ return ServerResponse.ok().render("password-reset-link", Map.of(
+ "username", resetToken.username()
+ ));
+ })
+ .onErrorResume(InvalidResetTokenException.class,
+ e -> ServerResponse.status(HttpStatus.FOUND)
+ .location(URI.create("/password-reset"))
+ .build()
+ .transformDeferred(rateLimiterForPasswordResetVerification(
+ request.exchange().getRequest()
+ ))
+ .onErrorMap(
+ RequestNotPermitted.class, RateLimitExceededException::new
+ )
+ );
+ }
+ )
+ .POST("/{resetToken}", request -> {
+ var token = request.pathVariable("resetToken");
+ return request.formData()
+ .flatMap(formData -> {
+ var locale = Optional.ofNullable(
+ request.exchange().getLocaleContext().getLocale()
+ )
+ .orElseGet(Locale::getDefault);
+ var password = formData.getFirst("password");
+ var confirmPassword = formData.getFirst("confirmPassword");
+ if (StringUtils.isBlank(password)) {
+ var error = messageSource.getMessage(
+ "passwordReset.password.blank",
+ null,
+ "Password can't be blank",
+ locale
+ );
+ return ServerResponse.ok().render("password-reset-link", Map.of(
+ "error", error
+ ));
+ }
+ if (!Objects.equals(password, confirmPassword)) {
+ var error = messageSource.getMessage(
+ "passwordReset.confirmPassword.mismatch",
+ null,
+ "Password and confirm password mismatch",
+ locale
+ );
+ return ServerResponse.ok().render("password-reset-link", Map.of(
+ "error", error
+ ));
+ }
+ return emailPasswordRecoveryService.changePassword(password, token)
+ .then(ServerResponse.status(HttpStatus.FOUND)
+ .location(URI.create("/login?passwordReset"))
+ .build()
+ )
+ .onErrorResume(InvalidResetTokenException.class, e -> {
+ var error = messageSource.getMessage(
+ "passwordReset.resetToken.invalid",
+ null,
+ "Invalid reset token",
+ locale
+ );
+ return ServerResponse.ok().render("password-reset-link", Map.of(
+ "error", error
+ )).transformDeferred(rateLimiterForPasswordResetVerification(
+ request.exchange().getRequest()
+ )).onErrorMap(
+ RequestNotPermitted.class, RateLimitExceededException::new
+ );
+ });
+ });
+ })
+ .POST("", contentType(MediaType.APPLICATION_FORM_URLENCODED),
+ request -> {
+ // get username and email
+ return request.formData()
+ .flatMap(formData -> {
+ var locale = Optional.ofNullable(
+ request.exchange().getLocaleContext().getLocale()
+ )
+ .orElseGet(Locale::getDefault);
+ var email = formData.getFirst("email");
+ if (StringUtils.isBlank(email)) {
+ var error = messageSource.getMessage(
+ "passwordReset.email.blank",
+ null,
+ "Email can't be blank",
+ locale
+ );
+ return ServerResponse.ok().render("password-reset", Map.of(
+ "error", error
+ ));
+ }
+ return emailPasswordRecoveryService.sendPasswordResetEmail(email)
+ .then(ServerResponse.ok().render("password-reset", Map.of(
+ "sent", true
+ )))
+ .transformDeferred(rateLimiterForSendPasswordResetEmail(
+ request.exchange().getRequest()
+ ))
+ .onErrorMap(
+ RequestNotPermitted.class, RateLimitExceededException::new
+ );
+ });
+ })
+ .build());
+ }
+
+
+ RateLimiterOperator rateLimiterForSendPasswordResetEmail(ServerHttpRequest request) {
+ var clientIp = IpAddressUtils.getClientIp(request);
+ var rateLimiterKey = "send-password-reset-email-from-" + clientIp;
+ var rateLimiter =
+ rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-password-reset-email");
+ return RateLimiterOperator.of(rateLimiter);
+ }
+
+ RateLimiterOperator rateLimiterForPasswordResetVerification(ServerHttpRequest request) {
+ var clientIp = IpAddressUtils.getClientIp(request);
+ var rateLimiterKey = "password-reset-email-verify-from-" + clientIp;
+ var rateLimiter =
+ rateLimiterRegistry.rateLimiter(rateLimiterKey, "password-reset-verification");
+ return RateLimiterOperator.of(rateLimiter);
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java
new file mode 100644
index 000000000..39c0bfc43
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java
@@ -0,0 +1,157 @@
+package run.halo.app.security.preauth;
+
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
+import static org.springframework.web.reactive.function.server.RequestPredicates.path;
+
+import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
+import io.github.resilience4j.ratelimiter.RequestNotPermitted;
+import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import java.net.URI;
+import lombok.Data;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BeanPropertyBindingResult;
+import org.springframework.validation.FieldError;
+import org.springframework.validation.Validator;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.user.service.EmailVerificationService;
+import run.halo.app.core.user.service.SignUpData;
+import run.halo.app.core.user.service.UserService;
+import run.halo.app.infra.actuator.GlobalInfoService;
+import run.halo.app.infra.exception.DuplicateNameException;
+import run.halo.app.infra.exception.EmailVerificationFailed;
+import run.halo.app.infra.exception.RateLimitExceededException;
+import run.halo.app.infra.exception.RequestBodyValidationException;
+import run.halo.app.infra.utils.IpAddressUtils;
+
+/**
+ * Pre-auth sign up endpoint.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Component
+class PreAuthSignUpEndpoint {
+
+ private final GlobalInfoService globalInfoService;
+
+ private final Validator validator;
+
+ private final UserService userService;
+
+ private final EmailVerificationService emailVerificationService;
+
+ private final RateLimiterRegistry rateLimiterRegistry;
+
+ PreAuthSignUpEndpoint(GlobalInfoService globalInfoService,
+ Validator validator,
+ UserService userService,
+ EmailVerificationService emailVerificationService,
+ RateLimiterRegistry rateLimiterRegistry) {
+ this.globalInfoService = globalInfoService;
+ this.validator = validator;
+ this.userService = userService;
+ this.emailVerificationService = emailVerificationService;
+ this.rateLimiterRegistry = rateLimiterRegistry;
+ }
+
+ @Bean
+ RouterFunction preAuthSignUpEndpoints() {
+ return RouterFunctions.nest(path("/signup"), RouterFunctions.route()
+ .GET("", request -> {
+ var signUpData = new SignUpData();
+ var bindingResult = new BeanPropertyBindingResult(signUpData, "form");
+ var model = bindingResult.getModel();
+ model.put("globalInfo", globalInfoService.getGlobalInfo());
+ return ServerResponse.ok().render("signup", model);
+ })
+ .POST(
+ "",
+ contentType(APPLICATION_FORM_URLENCODED),
+ request -> request.formData()
+ .map(SignUpData::of)
+ .flatMap(signUpData -> {
+ // sign up
+ var bindingResult = new BeanPropertyBindingResult(signUpData, "form");
+ var model = bindingResult.getModel();
+ model.put("globalInfo", globalInfoService.getGlobalInfo());
+ validator.validate(signUpData, bindingResult);
+ if (bindingResult.hasErrors()) {
+ return ServerResponse.ok().render("signup", model);
+ }
+ return userService.signUp(signUpData)
+ .flatMap(user -> ServerResponse.status(HttpStatus.FOUND)
+ .location(URI.create("/login?signup"))
+ .build()
+ )
+ .doOnError(t -> {
+ model.put("error", "unknown");
+ model.put("errorMessage", t.getMessage());
+ })
+ .doOnError(EmailVerificationFailed.class,
+ e -> {
+ bindingResult.addError(new FieldError("form",
+ "emailCode",
+ signUpData.getEmailCode(),
+ true,
+ // TODO Refine i18n
+ new String[] {"signup.error.email-captcha.invalid"},
+ null,
+ "Invalid Email Code"));
+ }
+ )
+ .doOnError(RateLimitExceededException.class,
+ e -> model.put("error", "rate-limit-exceeded")
+ )
+ .doOnError(DuplicateNameException.class,
+ e -> model.put("error", "duplicate-username")
+ )
+ .onErrorResume(e -> ServerResponse.ok().render("signup", model));
+ })
+ )
+ .POST("/send-email-code", contentType(APPLICATION_JSON),
+ request -> request.bodyToMono(SendEmailCodeBody.class)
+ .flatMap(body -> {
+ var bindingResult = new BeanPropertyBindingResult(body, "body");
+ validator.validate(body, bindingResult);
+ if (bindingResult.hasErrors()) {
+ return Mono.error(new RequestBodyValidationException(bindingResult));
+ }
+ var email = body.getEmail();
+ return emailVerificationService.sendRegisterVerificationCode(email)
+ .transformDeferred(
+ rateLimiterForSendingEmailCode(request.exchange().getRequest())
+ )
+ .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
+ })
+ .then(ServerResponse.accepted().build()))
+ .build());
+ }
+
+ private RateLimiterOperator rateLimiterForSendingEmailCode(ServerHttpRequest request) {
+ var clientIp = IpAddressUtils.getClientIp(request);
+ var rateLimiterKey = "send-email-code-for-signing-up-from-" + clientIp;
+ var rateLimiter =
+ rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code");
+ return RateLimiterOperator.of(rateLimiter);
+ }
+
+
+ @Data
+ public static class SendEmailCodeBody {
+
+ @Email
+ @NotBlank
+ String email;
+
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java
new file mode 100644
index 000000000..8fea7bdd1
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java
@@ -0,0 +1,27 @@
+package run.halo.app.security.preauth;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerResponse;
+
+/**
+ * Pre-auth two-factor endpoints.
+ *
+ * @author johnniang
+ * @since 2.20.0
+ */
+@Component
+class PreAuthTwoFactorEndpoint {
+
+ @Bean
+ RouterFunction preAuthTwoFactorEndpoints() {
+ return RouterFunctions.route()
+ .GET("/challenges/two-factor/totp",
+ request -> ServerResponse.ok().render("challenges/two-factor/totp")
+ )
+ .build();
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java
index 558dbb3c5..de56c2b43 100644
--- a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java
+++ b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java
@@ -73,7 +73,7 @@ public class SiteSettingVo {
.subtitle(basicSetting.getSubtitle())
.logo(basicSetting.getLogo())
.favicon(basicSetting.getFavicon())
- .allowRegistration(userSetting.getAllowRegistration())
+ .allowRegistration(userSetting.isAllowRegistration())
.post(PostSetting.builder()
.postPageSize(postSetting.getPostPageSize())
.archivePageSize(postSetting.getArchivePageSize())
diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java
index d6943dfb9..db89e286b 100644
--- a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java
+++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java
@@ -9,7 +9,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
-import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -32,7 +31,6 @@ public class ThemeMessageResolutionUtils {
private static final Map EMPTY_MESSAGES = Collections.emptyMap();
private static final String PROPERTIES_FILE_EXTENSION = ".properties";
private static final String LOCATION = "i18n";
- private static final Object[] EMPTY_MESSAGE_PARAMETERS = new Object[0];
@Nullable
private static Reader messageReader(String messageResourceName, ThemeContext theme)
@@ -96,91 +94,6 @@ public class ThemeMessageResolutionUtils {
return Collections.unmodifiableMap(combinedMessages);
}
- public static Map resolveMessagesForOrigin(final Class> origin,
- final Locale locale) {
-
- final Map combinedMessages = new HashMap<>(20);
-
- Class> currentClass = origin;
- combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale));
-
- while (!currentClass.getSuperclass().equals(Object.class)) {
-
- currentClass = currentClass.getSuperclass();
- final Map messagesForCurrentClass =
- resolveMessagesForSpecificClass(currentClass, locale);
- for (final String messageKey : messagesForCurrentClass.keySet()) {
- if (!combinedMessages.containsKey(messageKey)) {
- combinedMessages.put(messageKey, messagesForCurrentClass.get(messageKey));
- }
- }
- }
-
- return Collections.unmodifiableMap(combinedMessages);
-
- }
-
-
- private static Map resolveMessagesForSpecificClass(
- final Class> originClass, final Locale locale) {
-
-
- final ClassLoader originClassLoader = originClass.getClassLoader();
-
- // Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES
- // .properties, _gl.properties...
- // The order here is important: as we will let values from more specific files
- // overwrite those in less specific,
- // (e.g. a value for gl_ES will have more precedence than a value for gl). So we will
- // iterate these resource
- // names from less specific to more specific.
- final List messageResourceNames =
- computeMessageResourceNamesFromBase(locale);
-
- // Build the combined messages
- Map combinedMessages = null;
- for (final String messageResourceName : messageResourceNames) {
-
- final InputStream inputStream =
- originClassLoader.getResourceAsStream(messageResourceName);
- if (inputStream != null) {
-
- // At this point we cannot be specified a character encoding (that's only for
- // template resolution),
- // so we will use the standard character encoding for .properties files,
- // which is ISO-8859-1
- // (see Properties#load(InputStream) javadoc).
- final InputStreamReader messageResourceReader =
- new InputStreamReader(inputStream);
-
- final Properties messageProperties =
- readMessagesResource(messageResourceReader);
- if (messageProperties != null && !messageProperties.isEmpty()) {
-
- if (combinedMessages == null) {
- combinedMessages = new HashMap<>(20);
- }
-
- for (final Map.Entry propertyEntry :
- messageProperties.entrySet()) {
- combinedMessages.put((String) propertyEntry.getKey(),
- (String) propertyEntry.getValue());
- }
-
- }
-
- }
-
- }
-
- if (combinedMessages == null) {
- return EMPTY_MESSAGES;
- }
-
- return Collections.unmodifiableMap(combinedMessages);
- }
-
-
private static List computeMessageResourceNamesFromBase(final Locale locale) {
final List resourceNames = new ArrayList<>(5);
@@ -229,33 +142,4 @@ public class ThemeMessageResolutionUtils {
return properties;
}
- public static String formatMessage(final Locale locale, final String message,
- final Object[] messageParameters) {
- if (message == null) {
- return null;
- }
- if (!isFormatCandidate(message)) {
- // trying to avoid creating MessageFormat if not needed
- return message;
- }
- final MessageFormat messageFormat = new MessageFormat(message, locale);
- return messageFormat.format(
- (messageParameters != null ? messageParameters : EMPTY_MESSAGE_PARAMETERS));
- }
-
- /*
- * This will allow us to determine whether a message might actually contain parameter
- * placeholders.
- */
- private static boolean isFormatCandidate(final String message) {
- char c;
- int n = message.length();
- while (n-- != 0) {
- c = message.charAt(n);
- if (c == '}' || c == '\'') {
- return true;
- }
- }
- return false;
- }
}
diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java
index 18c9819fc..17f141108 100644
--- a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java
+++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java
@@ -33,13 +33,4 @@ public class ThemeMessageResolver extends StandardMessageResolver {
return Collections.unmodifiableMap(properties);
}
- @Override
- protected Map resolveMessagesForOrigin(Class> origin, Locale locale) {
- return ThemeMessageResolutionUtils.resolveMessagesForOrigin(origin, locale);
- }
-
- @Override
- protected String formatMessage(Locale locale, String message, Object[] messageParameters) {
- return ThemeMessageResolutionUtils.formatMessage(locale, message, messageParameters);
- }
}
diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml
index 681a94adc..b2869a3e1 100644
--- a/application/src/main/resources/application.yaml
+++ b/application/src/main/resources/application.yaml
@@ -96,7 +96,11 @@ resilience4j.ratelimiter:
limitForPeriod: 3
limitRefreshPeriod: 1h
timeoutDuration: 0s
- send-reset-password-email:
- limitForPeriod: 2
+ send-password-reset-email:
+ limitForPeriod: 10
+ limitRefreshPeriod: 1m
+ timeoutDuration: 0s
+ password-reset-verification:
+ limitForPeriod: 10
limitRefreshPeriod: 1m
timeoutDuration: 0s
diff --git a/application/src/main/resources/extensions/authproviders.yaml b/application/src/main/resources/extensions/authproviders.yaml
index 58e3369c6..87cad00e2 100644
--- a/application/src/main/resources/extensions/authproviders.yaml
+++ b/application/src/main/resources/extensions/authproviders.yaml
@@ -9,8 +9,10 @@ metadata:
- system-protection
spec:
displayName: Local
- enabled: true
description: Built-in authentication for Halo.
- logo: https://www.halo.run/logo
+ logo: /images/logo.png
website: https://www.halo.run
authenticationUrl: /login
+ method: post
+ rememberMeSupport: true
+ authType: form
diff --git a/application/src/main/resources/static/images/logo.png b/application/src/main/resources/static/images/logo.png
new file mode 100644
index 000000000..135bb98e5
Binary files /dev/null and b/application/src/main/resources/static/images/logo.png differ
diff --git a/application/src/main/resources/static/images/wordmark.svg b/application/src/main/resources/static/images/wordmark.svg
new file mode 100644
index 000000000..be7572154
--- /dev/null
+++ b/application/src/main/resources/static/images/wordmark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/application/src/main/resources/static/js/main.js b/application/src/main/resources/static/js/main.js
new file mode 100644
index 000000000..2db2e606b
--- /dev/null
+++ b/application/src/main/resources/static/js/main.js
@@ -0,0 +1,154 @@
+const Toast = (function () {
+ let container;
+
+ function getContainer() {
+ if (container) return container;
+
+ container = document.createElement("div");
+ container.style.cssText = `
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 9999;
+ `;
+
+ if (document.body) {
+ document.body.appendChild(container);
+ } else {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.body.appendChild(container);
+ });
+ }
+
+ return container;
+ }
+
+ class ToastMessage {
+ constructor(message, type) {
+ this.message = message;
+ this.type = type;
+ this.element = null;
+ this.create();
+ }
+
+ create() {
+ this.element = document.createElement("div");
+ this.element.textContent = this.message;
+ this.element.style.cssText = `
+ background-color: ${this.type === "success" ? "#4CAF50" : "#F44336"};
+ color: white;
+ padding: 12px 24px;
+ border-radius: 4px;
+ margin-bottom: 10px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ opacity: 0;
+ transition: opacity 0.3s ease-in-out;
+ `;
+ getContainer().appendChild(this.element);
+
+ setTimeout(() => {
+ this.element.style.opacity = "1";
+ }, 10);
+
+ setTimeout(() => {
+ this.remove();
+ }, 3000);
+ }
+
+ remove() {
+ this.element.style.opacity = "0";
+ setTimeout(() => {
+ const parent = this.element.parentNode;
+ if (parent) {
+ parent.removeChild(this.element);
+ }
+ }, 300);
+ }
+ }
+
+ function showToast(message, type) {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => {
+ new ToastMessage(message, type);
+ });
+ } else {
+ new ToastMessage(message, type);
+ }
+ }
+
+ return {
+ success: function (message) {
+ showToast(message, "success");
+ },
+ error: function (message) {
+ showToast(message, "error");
+ },
+ };
+})();
+
+function sendVerificationCode(button, sendRequest) {
+ let timer;
+ const countdown = 60;
+
+ button.addEventListener("click", () => {
+ button.disabled = true;
+ sendRequest()
+ .then(() => {
+ startCountdown();
+ Toast.success("发送成功");
+ })
+ .catch((e) => {
+ button.disabled = false;
+ if (e instanceof Error) {
+ Toast.error(e.message);
+ } else {
+ Toast.error("发送失败,请稍后再试");
+ }
+ });
+ });
+
+ function startCountdown() {
+ let remainingTime = countdown;
+ button.disabled = true;
+ button.classList.add("disabled");
+
+ timer = setInterval(() => {
+ if (remainingTime > 0) {
+ button.textContent = `${remainingTime}s`;
+ remainingTime--;
+ } else {
+ clearInterval(timer);
+ button.textContent = "Send";
+ button.disabled = false;
+ button.classList.remove("disabled");
+ }
+ }, 1000);
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ const passwordContainers = document.querySelectorAll(".toggle-password-display-flag");
+
+ passwordContainers.forEach((container) => {
+ const passwordInput = container.querySelector('input[type="password"]');
+ const toggleButton = container.querySelector(".toggle-password-button");
+ const displayIcon = container.querySelector(".password-display-icon");
+ const hiddenIcon = container.querySelector(".password-hidden-icon");
+
+ if (passwordInput && toggleButton && displayIcon && hiddenIcon) {
+ toggleButton.addEventListener("click", () => {
+ if (passwordInput.type === "password") {
+ passwordInput.type = "text";
+ displayIcon.style.display = "none";
+ hiddenIcon.style.display = "block";
+ } else {
+ passwordInput.type = "password";
+ displayIcon.style.display = "block";
+ hiddenIcon.style.display = "none";
+ }
+ });
+ }
+ });
+});
+
diff --git a/application/src/main/resources/static/styles/main.css b/application/src/main/resources/static/styles/main.css
new file mode 100644
index 000000000..9d062b000
--- /dev/null
+++ b/application/src/main/resources/static/styles/main.css
@@ -0,0 +1,410 @@
+/* Base */
+.gateway-page {
+ width: 100vw;
+ height: 100vh;
+ background-color: #f5f5f5;
+ overflow: auto;
+}
+
+.gateway-wrapper,
+.gateway-wrapper:before,
+.gateway-wrapper:after {
+ box-sizing: border-box;
+ border-width: 0;
+ border-style: solid;
+}
+
+.gateway-wrapper *,
+.gateway-wrapper *:before,
+.gateway-wrapper *:after {
+ box-sizing: border-box;
+ border-width: 0;
+ border-style: solid;
+}
+
+.gateway-wrapper {
+ --color-primary: #4ccba0;
+ --color-secondary: #0e1731;
+ --color-link: #1f75cb;
+ --color-text: #374151;
+ --color-border: #d1d5db;
+ --rounded-sm: 0.125em;
+ --rounded-base: 0.25em;
+ --rounded-lg: 0.5em;
+ --spacing-xl: 1.25em;
+ --spacing-lg: 1em;
+ --spacing-md: 0.875em;
+ --spacing-sm: 0.5em;
+ --text-md: 0.875em;
+}
+
+.gateway-wrapper {
+ margin: 0 auto;
+ max-width: 28em;
+ padding: 5% 1em;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ Segoe UI,
+ Roboto,
+ Helvetica Neue,
+ Arial,
+ Noto Sans,
+ sans-serif,
+ "Apple Color Emoji",
+ "Segoe UI Emoji",
+ Segoe UI Symbol,
+ "Noto Color Emoji";
+}
+
+/* Form */
+.halo-form-wrapper {
+ border-radius: var(--rounded-lg);
+ background: #fff;
+ padding: 1.5em;
+}
+
+.form-title {
+ all: unset;
+ margin-bottom: 1em;
+ display: block;
+ font-weight: 500;
+ font-size: 1.75em;
+}
+
+.halo-form .form-item {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 1.3em;
+ width: 100%;
+}
+
+.halo-form .form-item:last-child {
+ margin-bottom: 0;
+}
+
+.halo-form .form-item-group {
+ gap: var(--spacing-lg);
+ display: flex;
+ align-items: center;
+ margin-bottom: 1.3em;
+}
+
+.halo-form .form-item-group .form-item {
+ margin-bottom: 0;
+}
+
+.halo-form .form-input {
+ border-radius: var(--rounded-base);
+ border: 1px solid var(--color-border);
+ height: 2.5em;
+ background: #fff;
+ padding: 0 0.75rem;
+}
+
+.halo-form .form-input:focus-within {
+ border-color: var(--color-primary);
+ outline: 2px solid transparent;
+ outline-offset: "2px";
+}
+
+.halo-form .form-item input {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ display: block;
+ font-size: 1em;
+ box-shadow: none;
+ width: 100%;
+ height: 100%;
+ background: transparent;
+}
+
+.halo-form .form-item input:focus {
+ outline: none;
+}
+
+.halo-form .form-input-stack {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
+
+.halo-form .form-input-stack-icon {
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text);
+ cursor: pointer;
+}
+
+.halo-form .form-input-stack-select {
+ all: unset;
+ color: var(--color-text);
+ font-size: var(--text-md);
+ padding-right: 1.85em;
+ display: inline-flex;
+ align-items: center;
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.3em center;
+}
+
+.halo-form .form-input-stack-text {
+ color: var(--color-text);
+ font-size: var(--text-md);
+}
+
+.halo-form .form-item label {
+ color: var(--color-text);
+ margin-bottom: 0.5em;
+}
+
+.halo-form .form-item .form-label-group {
+ margin-bottom: 0.5em;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.halo-form .form-item .form-label-group label {
+ margin-bottom: 0;
+}
+
+.halo-form .form-item-extra-link {
+ color: var(--color-link);
+ font-size: var(--text-md);
+ text-decoration: none;
+}
+
+.halo-form .form-item-compact {
+ gap: var(--spacing-sm);
+ margin-bottom: 1.5em;
+ display: flex;
+ align-items: center;
+}
+
+.halo-form .form-item-compact label {
+ color: var(--color-text);
+ font-size: var(--text-md);
+}
+
+.halo-form button[type="submit"] {
+ background: var(--color-secondary);
+ border-radius: var(--rounded-base);
+ height: 2.5em;
+ color: #fff;
+ border: none;
+ cursor: pointer;
+}
+
+.halo-form button[type="submit"]:hover {
+ opacity: 0.8;
+}
+
+.halo-form button[type="submit"]:active {
+ opacity: 0.9;
+}
+
+.halo-form button[disabled] {
+ cursor: not-allowed !important;
+}
+
+.halo-form input[type="checkbox"] {
+ border: 1px solid var(--color-border);
+ border-radius: var(--rounded-sm);
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ padding: 0;
+ print-color-adjust: exact;
+ display: inline-block;
+ vertical-align: middle;
+ background-origin: border-box;
+ user-select: none;
+ flex-shrink: 0;
+ height: 1em;
+ width: 1em;
+ color: #2563eb;
+ background-color: #fff;
+}
+
+.halo-form input[type="checkbox"]:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ box-shadow:
+ rgb(255, 255, 255) 0px 0px 0px 2px,
+ rgb(37, 99, 235) 0px 0px 0px 4px,
+ rgba(0, 0, 0, 0) 0px 0px 0px 0px;
+}
+
+.halo-form input[type="checkbox"]:checked {
+ border-color: transparent;
+ background-color: currentColor;
+ background-size: 100% 100%;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
+}
+
+.halo-form .form-input-group {
+ gap: var(--spacing-sm);
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: center;
+}
+
+.halo-form .form-input {
+ grid-column: span 2 / span 2;
+}
+
+.halo-form .form-input-group button {
+ border-radius: var(--rounded-base);
+ border: 1px solid var(--color-border);
+ color: var(--color-text);
+ font-size: var(--text-md);
+ grid-column: span 1 / span 1;
+ height: 100%;
+ cursor: pointer;
+ background: #fff;
+}
+
+.halo-form .form-input-group button:hover {
+ color: #333;
+ background: #f3f4f6;
+}
+
+.halo-form .form-input-group button:active {
+ background: #f9fafb;
+}
+
+.auth-provider-items {
+ all: unset;
+ gap: var(--spacing-md);
+ margin: 0;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.auth-provider-items li {
+ all: unset;
+ border-radius: var(--rounded-lg);
+ overflow: hidden;
+ border: 1px solid #e5e7eb;
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 0.15s;
+}
+
+.auth-provider-items li a {
+ gap: var(--spacing-sm);
+ padding: 0.7em 1em;
+ display: flex;
+ align-items: center;
+ color: #1f2937;
+ text-decoration: none;
+ font-size: 0.8em;
+}
+
+.auth-provider-items li img {
+ width: 1.5em;
+ height: 1.5em;
+}
+
+.auth-provider-items li:hover {
+ border-color: var(--color-primary);
+ background: #f3f4f6;
+}
+
+.auth-provider-items li:hover a {
+ color: #111827;
+}
+
+.auth-provider-items li:focus-within {
+ border-color: var(--color-primary);
+}
+
+.divider-wrapper {
+ color: var(--color-text);
+ font-size: var(--text-md);
+ gap: var(--spacing-lg);
+ display: flex;
+ align-items: center;
+ margin: 1.5em 0;
+}
+
+.divider-wrapper hr {
+ flex-grow: 1;
+ overflow: hidden;
+ border: 0;
+ border-top: 1px solid #f3f4f6;
+}
+
+.alert {
+ border: 1px solid #e5e7eb;
+ border-radius: var(--rounded-base);
+ margin-bottom: var(--spacing-xl);
+ padding: var(--spacing-md) var(--spacing-xl);
+ font-size: var(--text-md);
+ overflow: hidden;
+ position: relative;
+ color: var(--color-text);
+}
+
+.alert::before {
+ content: "";
+ position: absolute;
+ height: 100%;
+ left: 0;
+ background: #d1d5db;
+ width: 0.25em;
+ top: 0;
+}
+
+.alert-warning {
+ border-color: #fde047;
+}
+
+.alert-warning::before {
+ background: #ea580c;
+}
+
+.alert-error {
+ border-color: #fca5a5;
+}
+
+.alert-error::before {
+ background: #dc2626;
+}
+
+.alert-success {
+ border-color: #86efac;
+}
+
+.alert-success::before {
+ background: #16a34a;
+}
+
+.alert-info {
+ border-color: #7dd3fc;
+}
+
+.alert-info::before {
+ background: #0284c7;
+}
+
+@media (forced-colors: active) {
+ .halo-form input[type="checkbox"]:checked {
+ -webkit-appearance: auto;
+ -moz-appearance: auto;
+ appearance: auto;
+ }
+}
+
+@media only screen and (max-width: 768px) {
+ .halo-form .form-item-group {
+ flex-direction: column;
+ }
+}
diff --git a/application/src/main/resources/templates/challenges/two-factor/totp.html b/application/src/main/resources/templates/challenges/two-factor/totp.html
new file mode 100644
index 000000000..96baa8d8c
--- /dev/null
+++ b/application/src/main/resources/templates/challenges/two-factor/totp.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
diff --git a/application/src/main/resources/templates/challenges/two-factor/totp.properties b/application/src/main/resources/templates/challenges/two-factor/totp.properties
new file mode 100644
index 000000000..46f96ed97
--- /dev/null
+++ b/application/src/main/resources/templates/challenges/two-factor/totp.properties
@@ -0,0 +1,4 @@
+title=两步验证
+messages.invalidError=错误的验证码
+form.code.label=验证码
+form.submit=验证
diff --git a/application/src/main/resources/templates/challenges/two-factor/totp_en.properties b/application/src/main/resources/templates/challenges/two-factor/totp_en.properties
new file mode 100644
index 000000000..bffc96863
--- /dev/null
+++ b/application/src/main/resources/templates/challenges/two-factor/totp_en.properties
@@ -0,0 +1,4 @@
+title=Two-Factor Authentication
+messages.invalidError=Invalid TOTP code
+form.code.label=TOTP Code
+form.submit=Verify
diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments.html b/application/src/main/resources/templates/gateway_modules/common_fragments.html
new file mode 100644
index 000000000..75d718ca3
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/common_fragments.html
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ English
+ Español
+ 简体中文
+ 繁体中文
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments.properties b/application/src/main/resources/templates/gateway_modules/common_fragments.properties
new file mode 100644
index 000000000..6617dd0d7
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/common_fragments.properties
@@ -0,0 +1 @@
+socialLogin.label=社交登录
\ No newline at end of file
diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments_en.properties b/application/src/main/resources/templates/gateway_modules/common_fragments_en.properties
new file mode 100644
index 000000000..a10abbb87
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/common_fragments_en.properties
@@ -0,0 +1 @@
+socialLogin.label=Social Login
\ No newline at end of file
diff --git a/application/src/main/resources/templates/gateway_modules/input_fragments.html b/application/src/main/resources/templates/gateway_modules/input_fragments.html
new file mode 100644
index 000000000..ffffb5cd6
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/input_fragments.html
@@ -0,0 +1,31 @@
+
diff --git a/application/src/main/resources/templates/gateway_modules/layout.html b/application/src/main/resources/templates/gateway_modules/layout.html
new file mode 100644
index 000000000..126838d12
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/layout.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments.html b/application/src/main/resources/templates/gateway_modules/login_fragments.html
new file mode 100644
index 000000000..87e8fe64c
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/login_fragments.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+
diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments.properties b/application/src/main/resources/templates/gateway_modules/login_fragments.properties
new file mode 100644
index 000000000..5752dae82
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/login_fragments.properties
@@ -0,0 +1,13 @@
+messages.loginError=无效的凭证。
+messages.logoutSuccess=登出成功。
+messages.signupSuccess=恭喜!注册成功,请立即登录。
+
+error.invalid-credential=无效的凭证。
+error.rate-limit-exceeded=请求过于频繁,请稍后再试。
+
+form.rememberMe.label=保持登录会话
+form.submit=登录
+otherLogin.label=其他登录方式
+signup.description=没有账号?
+signup.link=立即注册
+returnToSite=返回网站
\ No newline at end of file
diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties b/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties
new file mode 100644
index 000000000..d3b5b33f4
--- /dev/null
+++ b/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties
@@ -0,0 +1,13 @@
+messages.loginError=Invalid credentials.
+messages.logoutSuccess=Logout successfully.
+messages.signupSuccess=Congratulations! Sign up successfully, please sign in now.
+
+error.invalid-credential=Invalid credentials.
+error.rate-limit-exceeded=Too many requests, please try again later.
+
+form.rememberMe.label=Remember me
+form.submit=Login
+otherLogin.label=Other Login
+signup.description=Don't have an account?
+signup.link=Sign up
+returnToSite=Return to site
\ No newline at end of file
diff --git a/application/src/main/resources/templates/login.html b/application/src/main/resources/templates/login.html
new file mode 100644
index 000000000..47d808459
--- /dev/null
+++ b/application/src/main/resources/templates/login.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/application/src/main/resources/templates/login.properties b/application/src/main/resources/templates/login.properties
new file mode 100644
index 000000000..26367c07c
--- /dev/null
+++ b/application/src/main/resources/templates/login.properties
@@ -0,0 +1 @@
+title=登录
\ No newline at end of file
diff --git a/application/src/main/resources/templates/login_email.html b/application/src/main/resources/templates/login_email.html
new file mode 100644
index 000000000..cbdda6d64
--- /dev/null
+++ b/application/src/main/resources/templates/login_email.html
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/application/src/main/resources/templates/login_en.properties b/application/src/main/resources/templates/login_en.properties
new file mode 100644
index 000000000..eb0443eed
--- /dev/null
+++ b/application/src/main/resources/templates/login_en.properties
@@ -0,0 +1 @@
+title=Login
diff --git a/application/src/main/resources/templates/login_local.html b/application/src/main/resources/templates/login_local.html
new file mode 100644
index 000000000..3ae24d400
--- /dev/null
+++ b/application/src/main/resources/templates/login_local.html
@@ -0,0 +1,56 @@
+
diff --git a/application/src/main/resources/templates/login_local.properties b/application/src/main/resources/templates/login_local.properties
new file mode 100644
index 000000000..8163bfd74
--- /dev/null
+++ b/application/src/main/resources/templates/login_local.properties
@@ -0,0 +1,3 @@
+form.username.label=用户名
+form.password.label=密码
+form.password.forgot=忘记密码?
diff --git a/application/src/main/resources/templates/login_local_en.properties b/application/src/main/resources/templates/login_local_en.properties
new file mode 100644
index 000000000..d0fbb0901
--- /dev/null
+++ b/application/src/main/resources/templates/login_local_en.properties
@@ -0,0 +1,3 @@
+form.username.label=Username
+form.password.label=Password
+form.password.forgot=Forgot your password?
diff --git a/application/src/main/resources/templates/logout.html b/application/src/main/resources/templates/logout.html
new file mode 100644
index 000000000..cafcd1b7f
--- /dev/null
+++ b/application/src/main/resources/templates/logout.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/application/src/main/resources/templates/logout.properties b/application/src/main/resources/templates/logout.properties
new file mode 100644
index 000000000..4ff553242
--- /dev/null
+++ b/application/src/main/resources/templates/logout.properties
@@ -0,0 +1,3 @@
+title=退出登录
+form.title=确定要退出登录吗?
+form.submit=退出登录
diff --git a/application/src/main/resources/templates/logout_en.properties b/application/src/main/resources/templates/logout_en.properties
new file mode 100644
index 000000000..3ad1587f0
--- /dev/null
+++ b/application/src/main/resources/templates/logout_en.properties
@@ -0,0 +1,3 @@
+title=Logout
+form.title=Are you sure want to log out?
+form.submit=Logout
\ No newline at end of file
diff --git a/application/src/main/resources/templates/password-reset-link.html b/application/src/main/resources/templates/password-reset-link.html
new file mode 100644
index 000000000..2a40d4d81
--- /dev/null
+++ b/application/src/main/resources/templates/password-reset-link.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/application/src/main/resources/templates/password-reset-link.properties b/application/src/main/resources/templates/password-reset-link.properties
new file mode 100644
index 000000000..a401763b3
--- /dev/null
+++ b/application/src/main/resources/templates/password-reset-link.properties
@@ -0,0 +1,5 @@
+title=为 {0} 修改密码
+form.password.label=密码
+form.confirmPassword.label=确认密码
+form.password.tips=密码必须至少包含 8 个字符,并且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。
+form.submit=修改密码
diff --git a/application/src/main/resources/templates/password-reset-link_en.properties b/application/src/main/resources/templates/password-reset-link_en.properties
new file mode 100644
index 000000000..3075f3b83
--- /dev/null
+++ b/application/src/main/resources/templates/password-reset-link_en.properties
@@ -0,0 +1,5 @@
+title=Change password for @{0}
+form.password.label=Password
+form.confirmPassword.label=Confirm Password
+form.password.tips=Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.
+form.submit=Change password
diff --git a/application/src/main/resources/templates/password-reset.html b/application/src/main/resources/templates/password-reset.html
new file mode 100644
index 000000000..3e198e142
--- /dev/null
+++ b/application/src/main/resources/templates/password-reset.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
diff --git a/application/src/main/resources/templates/password-reset.properties b/application/src/main/resources/templates/password-reset.properties
new file mode 100644
index 000000000..f008656fc
--- /dev/null
+++ b/application/src/main/resources/templates/password-reset.properties
@@ -0,0 +1,6 @@
+title=重置密码
+form.email.label=电子邮箱
+form.submit=提交
+sent.form.submit=返回到登录页面
+sent.form.message=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
+sent.title=已发送重置密码的邮件
\ No newline at end of file
diff --git a/application/src/main/resources/templates/password-reset_en.properties b/application/src/main/resources/templates/password-reset_en.properties
new file mode 100644
index 000000000..36555ac69
--- /dev/null
+++ b/application/src/main/resources/templates/password-reset_en.properties
@@ -0,0 +1,6 @@
+title=Reset password
+form.email.label=Email
+form.submit=Submit
+sent.form.submit=Return to login
+sent.form.message=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.
+sent.title=Password reset email has been sent
\ No newline at end of file
diff --git a/application/src/main/resources/templates/signup.html b/application/src/main/resources/templates/signup.html
new file mode 100644
index 000000000..01186b5ae
--- /dev/null
+++ b/application/src/main/resources/templates/signup.html
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/application/src/main/resources/templates/signup.properties b/application/src/main/resources/templates/signup.properties
new file mode 100644
index 000000000..ce6a56c6d
--- /dev/null
+++ b/application/src/main/resources/templates/signup.properties
@@ -0,0 +1,13 @@
+title=注册
+form.username.label=用户名
+form.displayName.label=名称
+form.email.label=电子邮箱
+form.emailCode.label=邮箱验证码
+form.emailCode.sendButton=发送
+form.password.label=密码
+form.confirmPassword.label=确认密码
+form.submit=注册
+
+error.invalid-email-code=无效的邮箱验证码
+error.duplicate-name=用户名已经被注册
+error.rate-limit-exceeded=请求过于频繁,请稍后再试
diff --git a/application/src/main/resources/templates/signup_en.properties b/application/src/main/resources/templates/signup_en.properties
new file mode 100644
index 000000000..bdbb22206
--- /dev/null
+++ b/application/src/main/resources/templates/signup_en.properties
@@ -0,0 +1,9 @@
+title=Sign up
+form.username.label=Username
+form.displayName.label=Display name
+form.email.label=Email
+form.emailCode.label=Email Code
+form.emailCode.sendButton=Send
+form.password.label=Password
+form.confirmPassword.label=Confirm password
+form.submit=Sign up
\ No newline at end of file
diff --git a/application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java
deleted file mode 100644
index b37a20da9..000000000
--- a/application/src/test/java/run/halo/app/core/endpoint/theme/PublicUserEndpointTest.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package run.halo.app.core.endpoint.theme;
-
-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;
-
-import io.github.resilience4j.ratelimiter.RateLimiter;
-import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
-import org.springframework.security.web.server.context.ServerSecurityContextRepository;
-import org.springframework.test.web.reactive.server.WebTestClient;
-import reactor.core.publisher.Mono;
-import run.halo.app.core.extension.User;
-import run.halo.app.core.user.service.UserService;
-import run.halo.app.extension.Metadata;
-import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
-import run.halo.app.infra.SystemSetting;
-
-/**
- * Tests for {@link PublicUserEndpoint}.
- *
- * @author guqing
- * @since 2.4.0
- */
-@ExtendWith(MockitoExtension.class)
-class PublicUserEndpointTest {
- @Mock
- private UserService userService;
- @Mock
- private ServerSecurityContextRepository securityContextRepository;
- @Mock
- private ReactiveUserDetailsService reactiveUserDetailsService;
- @Mock
- SystemConfigurableEnvironmentFetcher environmentFetcher;
- @Mock
- RateLimiterRegistry rateLimiterRegistry;
-
- @InjectMocks
- private PublicUserEndpoint publicUserEndpoint;
-
- private WebTestClient webClient;
-
- @BeforeEach
- void setUp() {
- webClient = WebTestClient.bindToRouterFunction(publicUserEndpoint.endpoint())
- .build();
- }
-
- @Test
- void signUp() {
- User user = new User();
- user.setMetadata(new Metadata());
- user.getMetadata().setName("fake-user");
- user.setSpec(new User.UserSpec());
- user.getSpec().setDisplayName("hello");
- user.getSpec().setBio("bio");
-
- when(userService.signUp(any(User.class), anyString())).thenReturn(Mono.just(user));
- when(securityContextRepository.save(any(), any())).thenReturn(Mono.empty());
- when(reactiveUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(
- org.springframework.security.core.userdetails.User.withUsername("fake-user")
- .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", ""))
- .exchange()
- .expectStatus().isOk();
-
- verify(userService).signUp(any(User.class), anyString());
- verify(securityContextRepository).save(any(), any());
- verify(reactiveUserDetailsService).findByUsername(eq("fake-user"));
- }
-}
\ No newline at end of file
diff --git a/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java
index 08a400a7a..93a93da7d 100644
--- a/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java
+++ b/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java
@@ -2,6 +2,8 @@ package run.halo.app.core.user.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.anyString;
@@ -27,6 +29,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -34,15 +37,12 @@ import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.User;
-import run.halo.app.core.user.service.RoleService;
-import run.halo.app.core.user.service.UserServiceImpl;
import run.halo.app.event.user.PasswordChangedEvent;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
-import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.DuplicateNameException;
import run.halo.app.infra.exception.UserNotFoundException;
@@ -302,11 +302,14 @@ class UserServiceImplTest {
eq(SystemSetting.User.class)))
.thenReturn(Mono.just(userSetting));
- User fakeUser = fakeSignUpUser("fake-user", "fake-password");
+ var signUpData = createSignUpData("fake-user", "fake-password");
- userService.signUp(fakeUser, "fake-password")
+ userService.signUp(signUpData)
.as(StepVerifier::create)
- .expectError(AccessDeniedException.class)
+ .consumeErrorWith(e -> {
+ assertInstanceOf(ServerWebInputException.class, e);
+ assertTrue(e.getMessage().contains("registration is not allowed"));
+ })
.verify();
}
@@ -318,11 +321,14 @@ class UserServiceImplTest {
eq(SystemSetting.User.class)))
.thenReturn(Mono.just(userSetting));
- User fakeUser = fakeSignUpUser("fake-user", "fake-password");
+ var signUpData = createSignUpData("fake-user", "fake-password");
- userService.signUp(fakeUser, "fake-password")
+ userService.signUp(signUpData)
.as(StepVerifier::create)
- .expectError(AccessDeniedException.class)
+ .consumeErrorWith(e -> {
+ assertInstanceOf(ServerWebInputException.class, e);
+ assertTrue(e.getMessage().contains("default role is not configured"));
+ })
.verify();
}
@@ -336,11 +342,10 @@ class UserServiceImplTest {
.thenReturn(Mono.just(userSetting));
when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password");
when(client.fetch(eq(User.class), eq("fake-user")))
- .thenReturn(Mono.just(fakeSignUpUser("test", "test")));
+ .thenReturn(Mono.just(createFakeUser("test", "test")));
- User fakeUser = fakeSignUpUser("fake-user", "fake-password");
-
- userService.signUp(fakeUser, "fake-password")
+ var signUpData = createSignUpData("fake-user", "fake-password");
+ userService.signUp(signUpData)
.as(StepVerifier::create)
.expectError(DuplicateNameException.class)
.verify();
@@ -358,7 +363,8 @@ class UserServiceImplTest {
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Mono.empty());
- User fakeUser = fakeSignUpUser("fake-user", "fake-password");
+ User fakeUser = createFakeUser("fake-user", "fake-password");
+ var signUpData = createSignUpData("fake-user", "fake-password");
when(client.fetch(eq(Role.class), anyString())).thenReturn(Mono.just(new Role()));
when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser));
@@ -366,7 +372,7 @@ class UserServiceImplTest {
doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"),
anySet());
- spyUserService.signUp(fakeUser, "fake-password")
+ spyUserService.signUp(signUpData)
.as(StepVerifier::create)
.consumeNextWith(user -> {
assertThat(user.getMetadata().getName()).isEqualTo("fake-user");
@@ -378,7 +384,7 @@ class UserServiceImplTest {
verify(spyUserService).grantRoles(eq("fake-user"), anySet());
}
- User fakeSignUpUser(String name, String password) {
+ User createFakeUser(String name, String password) {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName(name);
@@ -386,6 +392,13 @@ class UserServiceImplTest {
user.getSpec().setPassword(password);
return user;
}
+
+ SignUpData createSignUpData(String name, String password) {
+ SignUpData signUpData = new SignUpData();
+ signUpData.setUsername(name);
+ signUpData.setPassword(password);
+ return signUpData;
+ }
}
@Test
diff --git a/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java
index 37804408e..589312724 100644
--- a/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java
+++ b/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java
@@ -1,14 +1,7 @@
package run.halo.app.core.user.service.impl;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
-import run.halo.app.core.user.service.impl.EmailPasswordRecoveryServiceImpl;
-import run.halo.app.infra.exception.RateLimitExceededException;
/**
* Tests for {@link EmailPasswordRecoveryServiceImpl}.
@@ -19,66 +12,4 @@ import run.halo.app.infra.exception.RateLimitExceededException;
@ExtendWith(MockitoExtension.class)
class EmailPasswordRecoveryServiceImplTest {
- @Nested
- class ResetPasswordVerificationManagerTest {
- @Test
- public void generateTokenTest() {
- var verificationManager =
- new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
- verificationManager.generateToken("fake-user");
- var result = verificationManager.contains("fake-user");
- assertThat(result).isTrue();
-
- verificationManager.generateToken("guqing");
- result = verificationManager.contains("guqing");
- assertThat(result).isTrue();
-
- result = verificationManager.contains("123");
- assertThat(result).isFalse();
- }
- }
-
- @Test
- public void removeTest() {
- var verificationManager =
- new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
- verificationManager.generateToken("fake-user");
- var result = verificationManager.contains("fake-user");
-
- verificationManager.removeToken("fake-user");
- result = verificationManager.contains("fake-user");
- assertThat(result).isFalse();
- }
-
- @Test
- void verifyTokenTestNormal() {
- String username = "guqing";
- var verificationManager =
- new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
- var result = verificationManager.verifyToken(username, "fake-code");
- assertThat(result).isFalse();
-
- var token = verificationManager.generateToken(username);
- result = verificationManager.verifyToken(username, "fake-code");
- assertThat(result).isFalse();
-
- result = verificationManager.verifyToken(username, token);
- assertThat(result).isTrue();
- }
-
- @Test
- void verifyTokenFailedAfterMaxAttempts() {
- String username = "guqing";
- var verificationManager =
- new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
- var token = verificationManager.generateToken(username);
- for (int i = 0; i <= EmailPasswordRecoveryServiceImpl.MAX_ATTEMPTS; i++) {
- var result = verificationManager.verifyToken(username, "fake-code");
- assertThat(result).isFalse();
- }
-
- assertThatThrownBy(() -> verificationManager.verifyToken(username, token))
- .isInstanceOf(RateLimitExceededException.class)
- .hasMessage("429 TOO_MANY_REQUESTS \"You have exceeded your quota\"");
- }
}
\ No newline at end of file
diff --git a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java
index 98b09cf0e..357dacb48 100644
--- a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java
+++ b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java
@@ -1,15 +1,26 @@
package run.halo.app.infra;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import static org.springframework.web.filter.reactive.ServerWebExchangeContextFilter.EXCHANGE_CONTEXT_ATTRIBUTE;
import java.net.MalformedURLException;
import java.net.URI;
+import java.net.URL;
+import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.test.StepVerifier;
/**
* Tests for {@link DefaultExternalLinkProcessor}.
@@ -26,9 +37,10 @@ class DefaultExternalLinkProcessorTest {
@InjectMocks
DefaultExternalLinkProcessor externalLinkProcessor;
+
@Test
void processWhenLinkIsEmpty() {
- assertThat(externalLinkProcessor.processLink(null)).isNull();
+ assertThat(externalLinkProcessor.processLink((String) null)).isNull();
assertThat(externalLinkProcessor.processLink("")).isEmpty();
}
@@ -48,4 +60,66 @@ class DefaultExternalLinkProcessorTest {
assertThat(externalLinkProcessor.processLink("https://halo.run/test"))
.isEqualTo("https://halo.run/test");
}
+
+ @ParameterizedTest
+ @MethodSource("processUriTestWithoutServerWebExchangeArguments")
+ void processUriWithoutServerWebExchange(String link, String expectedLink)
+ throws MalformedURLException {
+ lenient().when(externalUrlSupplier.getRaw())
+ .thenReturn(new URL("https://www.halo.run/context-path"));
+ externalLinkProcessor.processLink(URI.create(link))
+ .as(StepVerifier::create)
+ .expectNext(URI.create(expectedLink))
+ .verifyComplete();
+ }
+
+ static Stream processUriTestWithoutServerWebExchangeArguments() {
+ return Stream.of(
+ Arguments.of("http://localhost:8090/halo", "http://localhost:8090/halo"),
+ Arguments.of("/halo", "https://www.halo.run/context-path/halo"),
+ Arguments.of("halo", "https://www.halo.run/context-path/halo"),
+ Arguments.of("/halo?query", "https://www.halo.run/context-path/halo?query"),
+ Arguments.of(
+ "/halo?query#fragment", "https://www.halo.run/context-path/halo?query#fragment"
+ ),
+ Arguments.of("/halo/subpath", "https://www.halo.run/context-path/halo/subpath"),
+ Arguments.of("/halo/中文", "https://www.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"),
+ Arguments.of("/halo/ooo%2Fooo", "https://www.halo.run/context-path/halo/ooo%2Fooo")
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("processUriTestWithServerWebExchangeArguments")
+ void processUriWithServerWebExchange(String link, String expectLink)
+ throws MalformedURLException {
+ lenient().when(externalUrlSupplier.getRaw())
+ .thenReturn(URI.create("https://www.halo.run").toURL());
+ var request = mock(ServerHttpRequest.class);
+ var exchange = mock(ServerWebExchange.class);
+ lenient().when(exchange.getRequest()).thenReturn(request);
+ lenient().when(externalUrlSupplier.getURL(request)).thenReturn(
+ new URL("https://antoher.halo.run/context-path"));
+ externalLinkProcessor.processLink(URI.create(link))
+ .contextWrite(context -> context.put(EXCHANGE_CONTEXT_ATTRIBUTE, exchange))
+ .as(StepVerifier::create)
+ .expectNext(URI.create(expectLink))
+ .verifyComplete();
+ }
+
+ static Stream processUriTestWithServerWebExchangeArguments() {
+ return Stream.of(
+ Arguments.of("http://localhost:8090/halo?query#fragment",
+ "http://localhost:8090/halo?query#fragment"),
+ Arguments.of("/halo", "https://antoher.halo.run/context-path/halo"),
+ Arguments.of("halo", "https://antoher.halo.run/context-path/halo"),
+ Arguments.of("/halo?query", "https://antoher.halo.run/context-path/halo?query"),
+ Arguments.of("/halo?query#fragment",
+ "https://antoher.halo.run/context-path/halo?query#fragment"),
+ Arguments.of("/halo/subpath", "https://antoher.halo.run/context-path/halo/subpath"),
+ Arguments.of("/halo/中文",
+ "https://antoher.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"),
+ Arguments.of("/halo/ooo%2Fooo", "https://antoher.halo.run/context-path/halo/ooo%2Fooo")
+ );
+ }
+
}
diff --git a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java
index 5b41cce60..3c25919ed 100644
--- a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java
+++ b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -16,6 +17,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.skyscreamer.jsonassert.JSONAssert;
+import org.springframework.data.domain.Sort;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import reactor.core.publisher.Flux;
@@ -24,6 +26,7 @@ import reactor.test.StepVerifier;
import run.halo.app.core.extension.AuthProvider;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.extension.ConfigMap;
+import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemSetting;
@@ -121,9 +124,10 @@ class AuthProviderServiceImplTest {
AuthProvider gitee = createAuthProvider("gitee");
- when(client.list(eq(AuthProvider.class), any(), any()))
+ when(client.listAll(same(AuthProvider.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(github, gitlab, gitee));
- when(client.list(eq(UserConnection.class), any(), any())).thenReturn(Flux.empty());
+ when(client.listAll(same(UserConnection.class), any(ListOptions.class), any(Sort.class)))
+ .thenReturn(Flux.empty());
ConfigMap configMap = new ConfigMap();
configMap.setData(new HashMap<>());
diff --git a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java
index f473bfca6..7ede071f7 100644
--- a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java
+++ b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java
@@ -3,6 +3,7 @@ package run.halo.app.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.http.HttpHeaders.WWW_AUTHENTICATE;
+import java.net.URI;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -19,8 +20,9 @@ class DefaultServerAuthenticationEntryPointTest {
DefaultServerAuthenticationEntryPoint entryPoint;
@Test
- void commence() {
+ void commenceForXhrRequest() {
var mockReq = MockServerHttpRequest.get("/protected")
+ .header("X-Requested-With", "XMLHttpRequest")
.build();
var mockExchange = MockServerWebExchange.builder(mockReq)
.build();
@@ -32,4 +34,17 @@ class DefaultServerAuthenticationEntryPointTest {
assertEquals("FormLogin realm=\"console\"", headers.getFirst(WWW_AUTHENTICATE));
}
+ @Test
+ void commenceForNormalRequest() {
+ var mockReq = MockServerHttpRequest.get("/protected")
+ .build();
+ var mockExchange = MockServerWebExchange.builder(mockReq)
+ .build();
+ var commenceMono = entryPoint.commence(mockExchange,
+ new AuthenticationCredentialsNotFoundException("Not Found"));
+ StepVerifier.create(commenceMono)
+ .verifyComplete();
+ assertEquals(URI.create("/login?authentication_required"),
+ mockExchange.getResponse().getHeaders().getLocation());
+ }
}
\ No newline at end of file
diff --git a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java b/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java
deleted file mode 100644
index f646ad040..000000000
--- a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package run.halo.app.security;
-
-import static org.mockito.ArgumentMatchers.argThat;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
-import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
-import org.springframework.test.web.reactive.server.WebTestClient;
-import run.halo.app.core.extension.Role;
-import run.halo.app.core.extension.RoleBinding;
-import run.halo.app.core.extension.User;
-import run.halo.app.extension.ReactiveExtensionClient;
-
-@Disabled
-@SpringBootTest(properties = {"halo.security.initializer.disabled=false",
- "halo.security.initializer.super-admin-username=fake-admin",
- "halo.security.initializer.super-admin-password=fake-password",
- "halo.required-extension-disabled=true",
- "halo.theme.initializer.disabled=true"})
-@AutoConfigureWebTestClient
-@AutoConfigureTestDatabase
-class SuperAdminInitializerTest {
-
- @MockitoSpyBean
- ReactiveExtensionClient client;
-
- @Autowired
- WebTestClient webClient;
-
- @Autowired
- PasswordEncoder encoder;
-
- @Test
- void checkSuperAdminInitialization() {
- verify(client, times(1)).create(argThat(extension -> {
- if (extension instanceof User user) {
- return "fake-admin".equals(user.getMetadata().getName())
- && encoder.matches("fake-password", user.getSpec().getPassword());
- }
- return false;
- }));
- verify(client, times(1)).create(argThat(extension -> {
- if (extension instanceof Role role) {
- return "super-role".equals(role.getMetadata().getName());
- }
- return false;
- }));
- verify(client, times(1)).create(argThat(extension -> {
- if (extension instanceof RoleBinding roleBinding) {
- return "fake-admin-super-role-binding".equals(roleBinding.getMetadata().getName());
- }
- return false;
- }));
- }
-}
diff --git a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java
index a40207b96..445895a34 100644
--- a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java
+++ b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java
@@ -27,8 +27,8 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
-import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.security.authentication.CryptoService;
+import run.halo.app.security.authentication.exception.TooManyRequestsException;
@ExtendWith(MockitoExtension.class)
class LoginAuthenticationConverterTest {
@@ -77,7 +77,7 @@ class LoginAuthenticationConverterTest {
when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication"))
.thenReturn(rateLimiter);
StepVerifier.create(converter.convert(exchange))
- .expectError(RateLimitExceededException.class)
+ .expectError(TooManyRequestsException.class)
.verify();
verify(cryptoService, never()).decrypt(password.getBytes());
diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java
index 49912b316..2dce90adc 100644
--- a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java
+++ b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java
@@ -70,12 +70,18 @@ class AuthorizationTest {
void anonymousUserAccessProtectedApi() {
when(userDetailsService.findByUsername(eq(AnonymousUserConst.PRINCIPAL)))
.thenReturn(Mono.empty());
- when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.empty());
- webClient.get().uri("/apis/fake.halo.run/v1/posts").exchange().expectStatus()
- .isUnauthorized();
+ webClient.get().uri("/apis/fake.halo.run/v1/posts")
+ .header("X-Requested-With", "XMLHttpRequest")
+ .exchange()
+ .expectStatus().isUnauthorized();
- verify(roleService).listDependenciesFlux(anySet());
+ webClient.get().uri("/apis/fake.halo.run/v1/posts")
+ .exchange()
+ .expectStatus().isFound()
+ .expectHeader().location("/login?authentication_required");
+
+ verify(roleService, times(2)).listDependenciesFlux(anySet());
}
@Test
@@ -97,13 +103,19 @@ class AuthorizationTest {
.isOk()
.expectBody(String.class).isEqualTo("returned posts");
- verify(roleService).listDependenciesFlux(anySet());
-
- webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo").exchange()
+ webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo")
+ .header("X-Requested-With", "XMLHttpRequest")
+ .exchange()
.expectStatus()
.isUnauthorized();
- verify(roleService, times(2)).listDependenciesFlux(anySet());
+ webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo")
+ .exchange()
+ .expectStatus()
+ .isFound()
+ .expectHeader().location("/login?authentication_required");
+
+ verify(roleService, times(3)).listDependenciesFlux(anySet());
}
@Test
diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java
index b7966673b..f504f5904 100644
--- a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java
+++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java
@@ -41,14 +41,6 @@ class ThemeMessageResolutionUtilsTest {
"title", "这是来自 i18n/default.properties 的标题"));
}
- @Test
- void messageFormat() {
- String s =
- ThemeMessageResolutionUtils.formatMessage(Locale.ENGLISH, "Welcome {0} to the index",
- new Object[] {"Halo"});
- assertThat(s).isEqualTo("Welcome Halo to the index");
- }
-
ThemeContext getTheme() throws URISyntaxException {
return ThemeContext.builder()
.name("default")
diff --git a/ui/console-src/layouts/BasicLayout.vue b/ui/console-src/layouts/BasicLayout.vue
index b1fc71be7..1e6ffcbf6 100644
--- a/ui/console-src/layouts/BasicLayout.vue
+++ b/ui/console-src/layouts/BasicLayout.vue
@@ -57,7 +57,7 @@ const handleLogout = () => {
document.cookie =
"XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
- router.replace({ name: "Login" });
+ window.location.href = "/login";
} catch (error) {
console.error("Failed to logout", error);
}
diff --git a/ui/console-src/main.ts b/ui/console-src/main.ts
index e05d17c4d..45ee18818 100644
--- a/ui/console-src/main.ts
+++ b/ui/console-src/main.ts
@@ -14,6 +14,7 @@ import { setupVueQuery } from "@/setup/setupVueQuery";
import { useGlobalInfoStore } from "@/stores/global-info";
import { useRoleStore } from "@/stores/role";
import { useUserStore } from "@/stores/user";
+import { getCookie } from "@/utils/cookie";
import { hasPermission } from "@/utils/permission";
import {
setupCoreModules,
@@ -78,8 +79,7 @@ async function initApp() {
await userStore.fetchCurrentUser();
// set locale
- i18n.global.locale.value =
- localStorage.getItem("locale") || getBrowserLanguage();
+ i18n.global.locale.value = getCookie("language") || getBrowserLanguage();
const globalInfoStore = useGlobalInfoStore();
await globalInfoStore.fetchGlobalInfo();
diff --git a/ui/src/utils/cookie.ts b/ui/src/utils/cookie.ts
new file mode 100644
index 000000000..5b376aa44
--- /dev/null
+++ b/ui/src/utils/cookie.ts
@@ -0,0 +1,4 @@
+export function getCookie(name: string) {
+ const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
+ return match ? match[2] : null;
+}
diff --git a/ui/uc-src/layouts/BasicLayout.vue b/ui/uc-src/layouts/BasicLayout.vue
index 5db0136d3..be079413a 100644
--- a/ui/uc-src/layouts/BasicLayout.vue
+++ b/ui/uc-src/layouts/BasicLayout.vue
@@ -53,7 +53,7 @@ const handleLogout = () => {
document.cookie =
"XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
- window.location.href = "/console/login";
+ window.location.href = "/login";
} catch (error) {
console.error("Failed to logout", error);
}
diff --git a/ui/uc-src/main.ts b/ui/uc-src/main.ts
index 071543843..d79ac8536 100644
--- a/ui/uc-src/main.ts
+++ b/ui/uc-src/main.ts
@@ -6,6 +6,7 @@ import { setupVueQuery } from "@/setup/setupVueQuery";
import { useGlobalInfoStore } from "@/stores/global-info";
import { useRoleStore } from "@/stores/role";
import { useUserStore } from "@/stores/user";
+import { getCookie } from "@/utils/cookie";
import { hasPermission } from "@/utils/permission";
import { consoleApiClient } from "@halo-dev/api-client";
import router from "@uc/router";
@@ -66,8 +67,7 @@ async function initApp() {
await userStore.fetchCurrentUser();
// set locale
- i18n.global.locale.value =
- localStorage.getItem("locale") || getBrowserLanguage();
+ i18n.global.locale.value = getCookie("language") || getBrowserLanguage();
const globalInfoStore = useGlobalInfoStore();
await globalInfoStore.fetchGlobalInfo();
diff --git a/ui/uc-src/router/guards/auth-check.ts b/ui/uc-src/router/guards/auth-check.ts
index 322a7be26..9ff1c02d5 100644
--- a/ui/uc-src/router/guards/auth-check.ts
+++ b/ui/uc-src/router/guards/auth-check.ts
@@ -13,7 +13,7 @@ export function setupAuthCheckGuard(router: Router) {
const userStore = useUserStore();
if (userStore.isAnonymous) {
- window.location.href = `/console/login?redirect_uri=${encodeURIComponent(
+ window.location.href = `/login?redirect_uri=${encodeURIComponent(
window.location.href
)}`;
return;