Merge pull request #6488 from JohnNiang/feat/login-logout-pages

Add support for customizing login and logout pages
pull/6712/head^2
Ryan Wang 2024-09-28 17:56:54 +08:00 committed by GitHub
commit 45a08bd676
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 3161 additions and 1338 deletions

View File

@ -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,
;
}
}

View File

@ -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;

View File

@ -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.
* <p>
* 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.
* </p>
*
* @param uri uri to process
* @return processed URI or original URI
*/
Mono<URI> processLink(URI uri);
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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<ServerResponse> 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<ServerResponse> resetPasswordByToken(ServerRequest request) {
var username = request.pathVariable("name");
return request.bodyToMono(ResetPasswordRequest.class)
.doOnNext(resetReq -> {
if (StringUtils.isBlank(resetReq.token())) {
throw new ServerWebInputException("Token must not be blank");
}
if (StringUtils.isBlank(resetReq.newPassword())) {
throw new ServerWebInputException("New password must not be blank");
}
})
.switchIfEmpty(
Mono.error(() -> new ServerWebInputException("Request body must not be empty"))
)
.flatMap(resetReq -> {
var token = resetReq.token();
var newPassword = resetReq.newPassword();
return emailPasswordRecoveryService.changePassword(username, newPassword, token);
})
.then(ServerResponse.noContent().build());
}
record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username,
@Schema(requiredMode = REQUIRED) String email) {
}
record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword,
@Schema(requiredMode = REQUIRED) String token) {
}
record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) {
}
private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) {
return request.bodyToMono(PasswordResetEmailRequest.class)
.flatMap(passwordResetRequest -> {
var username = passwordResetRequest.username();
var email = passwordResetRequest.email();
return Mono.just(passwordResetRequest)
.transformDeferred(sendResetPasswordEmailRateLimiter(username, email))
.flatMap(
r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
})
.then(ServerResponse.noContent().build());
}
<T> RateLimiterOperator<T> sendResetPasswordEmailRateLimiter(String username, String email) {
String rateLimiterKey = "send-reset-password-email-" + username + ":" + email;
var rateLimiter =
rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email");
return RateLimiterOperator.of(rateLimiter);
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
}
private Mono<ServerResponse> 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<ServerResponse> 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 <T> RateLimiterOperator<T> getRateLimiterForSignUp(ServerWebExchange exchange) {
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp,
"signup");
return RateLimiterOperator.of(rateLimiter);
}
private Mono<Void> 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 <T> RateLimiterOperator<T> 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
) {
}
}

View File

@ -22,17 +22,21 @@ public interface EmailPasswordRecoveryService {
*/
Mono<Void> sendPasswordResetEmail(String username, String email);
Mono<Void> sendPasswordResetEmail(String email);
/**
* <p>Reset password by token.</p>
* if the token is invalid, it will return {@link Mono#error(Throwable)}}
* if the token is valid, but the username is not the same, it will return
* {@link Mono#error(Throwable)}
*
* @param username username to reset password
* @param newPassword new password
* @param token token to validate the user
* @return {@link Mono#empty()} if the token is invalid or the username is not the same.
* @throws AccessDeniedException if the token is invalid
*/
Mono<Void> changePassword(String username, String newPassword, String token);
Mono<Void> changePassword(String newPassword, String token);
Mono<ResetToken> getValidResetToken(String token);
}

View File

@ -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<String, ResetToken> tokens;
public InMemoryResetTokenRepository() {
this.tokens = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofDays(1))
.maximumSize(10000)
.build();
}
@Override
public Mono<Void> 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<ResetToken> findByTokenHash(String tokenHash) {
return Mono.fromSupplier(() -> tokens.getIfPresent(tokenHash));
}
@Override
public Mono<Void> removeByTokenHash(String tokenHash) {
return Mono.fromRunnable(() -> tokens.invalidate(tokenHash));
}
}

View File

@ -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");
}
}

View File

@ -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) {
}

View File

@ -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<Void> save(ResetToken resetToken);
/**
* Find reset token by token hash.
*
* @param tokenHash token hash
* @return reset token if found, or empty mono.
*/
Mono<ResetToken> findByTokenHash(String tokenHash);
/**
* Remove reset token by token hash.
*
* @param tokenHash token hash
* @return empty mono if removed successfully.
*/
Mono<Void> removeByTokenHash(String tokenHash);
}

View File

@ -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<String, String> 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;
}
}

View File

@ -17,7 +17,7 @@ public interface UserService {
Mono<User> grantRoles(String username, Set<String> roles);
Mono<User> signUp(User user, String password);
Mono<User> signUp(SignUpData signUpData);
Mono<User> createUser(User user, Set<String> roles);

View File

@ -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<User> signUp(User user, String password) {
if (!StringUtils.hasText(password)) {
throw new IllegalArgumentException("Password must not be blank");
}
public Mono<User> 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<Void> 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<User> listByEmail(String email) {
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(equal("spec.email", email)));
return client.listAll(User.class, listOptions, Sort.by(desc("metadata.creationTimestamp"),
asc("metadata.name"))
);
return client.listAll(User.class, listOptions, defaultSort());
}
@Override

View File

@ -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<Void> sendPasswordResetEmail(String username, String email) {
@ -66,22 +68,35 @@ public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoverySe
}
@Override
public Mono<Void> changePassword(String username, String newPassword, String token) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
public Mono<Void> 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<Void> 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<ResetToken> getValidResetToken(String token) {
return resetTokenRepository.findByTokenHash(hashToken(token))
.filter(resetToken -> clock.instant().isBefore(resetToken.expiresAt()))
.switchIfEmpty(Mono.error(InvalidResetTokenException::new));
}
Mono<Void> unSubscribeResetPasswordEmailNotification(String email) {
@ -95,26 +110,33 @@ public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoverySe
.filter(OptimisticLockingFailureException.class::isInstance));
}
Mono<Void> sendResetPasswordNotification(String username, String email) {
var token = resetPasswordVerificationManager.generateToken(username);
var link = getResetPasswordLink(username, token);
var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email);
var interestReasonSubject = createInterestReason(email).getSubject();
var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE,
builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES)
.attribute("username", username)
.attribute("link", link)
.author(UserIdentity.of(username))
.subject(Reason.Subject.builder()
.apiVersion(interestReasonSubject.getApiVersion())
.kind(interestReasonSubject.getKind())
.name(interestReasonSubject.getName())
.title("使用邮箱地址重置密码:" + email)
.build()
)
);
return Mono.when(subscribeNotification).then(emitReasonMono);
private Mono<Void> 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<Void> 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<String, Verification> userTokenCache =
CacheBuilder.newBuilder()
.expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
private final Cache<String, Boolean>
blackListCache = CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofHours(2))
.maximumSize(1000)
.build();
public boolean verifyToken(String username, String token) {
var verification = userTokenCache.getIfPresent(username);
if (verification == null) {
// expired or not generated
return false;
}
if (blackListCache.getIfPresent(username) != null) {
// in blacklist
throw new RateLimitExceededException(null);
}
synchronized (verification) {
if (verification.getAttempts().get() >= MAX_ATTEMPTS) {
// add to blacklist to prevent brute force attack
blackListCache.put(username, true);
return false;
}
if (!verification.getToken().equals(token)) {
verification.getAttempts().incrementAndGet();
return false;
}
}
return true;
}
public void removeToken(String username) {
userTokenCache.invalidate(username);
}
public String generateToken(String username) {
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
var verification = new Verification();
verification.setToken(RandomStringUtils.randomAlphanumeric(20));
verification.setAttempts(new AtomicInteger(0));
userTokenCache.put(username, verification);
return verification.getToken();
}
/**
* Only for test.
*/
boolean contains(String username) {
return userTokenCache.getIfPresent(username) != null;
}
@Data
@Accessors(chain = true)
static class Verification {
private String token;
private AtomicInteger attempts;
}
private static String generateToken() {
return RandomStringUtils.secure().nextAlphanumeric(64);
}
}

View File

@ -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<Void> sendVerificationCode(String username, String email) {
@ -121,7 +123,10 @@ public class EmailVerificationServiceImpl implements EmailVerificationService {
}
Mono<Boolean> 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<Boolean> 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<Void> sendVerificationNotification(String username, String email) {

View File

@ -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

View File

@ -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<URI> 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, "/");

View File

@ -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<SocialAuthProvider> 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;
}
}

View File

@ -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<SystemConfigurableEnvironmentFetcher> 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<SocialAuthProvider> 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<SocialAuthProvider> 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));
}
}

View File

@ -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<GlobalInfo> getGlobalInfo();
}

View File

@ -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<SystemConfigurableEnvironmentFetcher>
systemConfigFetcher;
public GlobalInfoServiceImpl(HaloProperties haloProperties,
AuthProviderService authProviderService,
InitializationStateGetter initializationStateGetter,
ObjectProvider<SystemConfigurableEnvironmentFetcher> systemConfigFetcher) {
this.haloProperties = haloProperties;
this.authProviderService = authProviderService;
this.initializationStateGetter = initializationStateGetter;
this.systemConfigFetcher = systemConfigFetcher;
}
@Override
public Mono<GlobalInfo> 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<Publisher<?>>(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<Void> 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<Void> 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();
}
}

View File

@ -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<ServerResponse> publicKeyRoute(CryptoService cryptoService) {
return new PublicKeyRouteBuilder(cryptoService).build();
}
@Bean
CryptoService cryptoService(HaloProperties haloProperties) {
return new RsaKeyService(haloProperties.getWorkDir().resolve("keys"));

View File

@ -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;

View File

@ -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<AuthProvider> disable(String name);
Mono<List<ListedAuthProvider>> listAll();
Flux<AuthProvider> getEnabledProviders();
}

View File

@ -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<List<ListedAuthProvider>> 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<AuthProvider> 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<Set<String>> fetchEnabledAuthProviders() {
return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
.map(configMap -> {
@ -97,12 +111,14 @@ public class AuthProviderServiceImpl implements AuthProviderService {
Flux<UserConnection> 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<AuthProvider> defaultComparator() {

View File

@ -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<Void> 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())
);
}
}

View File

@ -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<Void> 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

View File

@ -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<ServerResponse> 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<Void> onLogoutSuccess(WebFilterExchange exchange,
Authentication authentication) {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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
.<Authentication>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 <T> RateLimiterOperator<T> createIpBasedRateLimiter(ServerWebExchange exchange) {

View File

@ -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<Void> 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);

View File

@ -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;
});
);
}
}

View File

@ -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<Void> 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) {

View File

@ -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<Void> saveRememberMe(ServerWebExchange exchange);
/**
* Check if remember-me parameter exists in cache.
*
* @param exchange exchange
* @return true if remember-me exists, false otherwise
*/
Mono<Boolean> isRememberMe(ServerWebExchange exchange);
/**
* Remove remember-me parameter from cache.
*
* @param exchange exchange
* @return empty to return
*/
Mono<Void> removeRememberMe(ServerWebExchange exchange);
}

View File

@ -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<Void> 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<Void> 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) {

View File

@ -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<Void> 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<Boolean> isRememberMe(ServerWebExchange exchange) {
return resolveFromQuery(exchange)
.filter(Boolean::booleanValue)
.switchIfEmpty(resolveFromForm(exchange))
.filter(Boolean::booleanValue)
.switchIfEmpty(resolveFromSession(exchange))
.defaultIfEmpty(false);
}
@Override
public Mono<Void> removeRememberMe(ServerWebExchange exchange) {
return exchange.getSession()
.doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_NAME))
.then();
}
private Mono<Boolean> resolveFromQuery(ServerWebExchange exchange) {
return Mono.just(
parseBoolean(exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER))
);
}
private Mono<Boolean> resolveFromForm(ServerWebExchange exchange) {
return exchange.getFormData()
.map(form -> parseBoolean(form.getFirst(DEFAULT_PARAMETER)))
.filter(Boolean::booleanValue);
}
private Mono<Boolean> resolveFromSession(ServerWebExchange exchange) {
return exchange.getSession()
.map(session -> {
var rememberMeObject = session.getAttribute(SESSION_ATTRIBUTE_NAME);
return rememberMeObject instanceof Boolean rememberMe ? rememberMe : false;
});
}
}

View File

@ -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<Void> 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));
});
}
}

View File

@ -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<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
return loginEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication)
.then(successHandler.onAuthenticationSuccess(webFilterExchange, authentication));
}
}

View File

@ -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<Void> handle(ServerWebExchange exchange);
}

View File

@ -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);
}
}

View File

@ -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() {

View File

@ -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<AuthorizationContext> {
private final ReactiveAuthorizationManager<AuthorizationContext> delegate;
private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp");
public TwoFactorAuthorizationManager(
ReactiveAuthorizationManager<AuthorizationContext> delegate) {
this.delegate = delegate;
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
AuthorizationContext context) {
return authentication.flatMap(a -> {
Mono<AuthorizationDecision> 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);
}
}

View File

@ -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<Authentication> 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<Authentication> 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);
});
}
}
}

View File

@ -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<Authentication> 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);
});
}
}

View File

@ -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<Authentication> 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 + '.')
);
}
});
}
}

View File

@ -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<AuthorizationContext> {
implements ReactiveAuthorizationManager<AuthorizationContext> {
private final AuthorizationRuleResolver ruleResolver;
@ -22,19 +21,19 @@ public class RequestInfoAuthorizationManager
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> 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<Throwable> errors) {

View File

@ -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<ServerResponse> 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());
}
}

View File

@ -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<ServerResponse> 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());
}
<T> RateLimiterOperator<T> 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);
}
<T> RateLimiterOperator<T> 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);
}
}

View File

@ -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<ServerResponse> 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 <T> RateLimiterOperator<T> 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;
}
}

View File

@ -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<ServerResponse> preAuthTwoFactorEndpoints() {
return RouterFunctions.route()
.GET("/challenges/two-factor/totp",
request -> ServerResponse.ok().render("challenges/two-factor/totp")
)
.build();
}
}

View File

@ -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())

View File

@ -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<String, String> 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<String, String> resolveMessagesForOrigin(final Class<?> origin,
final Locale locale) {
final Map<String, String> combinedMessages = new HashMap<>(20);
Class<?> currentClass = origin;
combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale));
while (!currentClass.getSuperclass().equals(Object.class)) {
currentClass = currentClass.getSuperclass();
final Map<String, String> 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<String, String> 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<String> messageResourceNames =
computeMessageResourceNamesFromBase(locale);
// Build the combined messages
Map<String, String> 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<Object, Object> propertyEntry :
messageProperties.entrySet()) {
combinedMessages.put((String) propertyEntry.getKey(),
(String) propertyEntry.getValue());
}
}
}
}
if (combinedMessages == null) {
return EMPTY_MESSAGES;
}
return Collections.unmodifiableMap(combinedMessages);
}
private static List<String> computeMessageResourceNamesFromBase(final Locale locale) {
final List<String> 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;
}
}

View File

@ -33,13 +33,4 @@ public class ThemeMessageResolver extends StandardMessageResolver {
return Collections.unmodifiableMap(properties);
}
@Override
protected Map<String, String> 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);
}
}

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 2144 877" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="mb-8 flex-none"><linearGradient id="uicons-hepky0qzdn" gradientTransform="matrix(0 -848.921 848.921 0 1308.8 875.397)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#0050b5"></stop><stop offset="1" stop-color="#0b87fd"></stop></linearGradient><linearGradient id="uicons-9a00r6vh1r" gradientTransform="matrix(0 472.459 -473.895 0 587.619 -.861651)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#0048af"></stop><stop offset="1" stop-color="#003580"></stop></linearGradient><linearGradient id="uicons-payvanzieq" gradientTransform="matrix(0 898.506 -901.236 0 162.421 -12.1337)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#0b89ff"></stop><stop offset="1" stop-color="#004eb2"></stop></linearGradient><g fill="url(#uicons-hepky0qzdn)"><path d="m1028.16 339.331c148.249 0 268.609 120.36 268.609 268.609s-120.36 268.608-268.609 268.608-268.608-120.359-268.608-268.608 120.359-268.609 268.608-268.609zm0 119.152c82.488 0 149.457 66.969 149.457 149.457 0 82.487-66.969 149.456-149.457 149.456-82.487 0-149.456-66.969-149.456-149.456 0-82.488 66.969-149.457 149.456-149.457z"></path><path d="m1874.58 339.331c148.249 0 268.608 120.36 268.608 268.609s-120.359 268.608-268.608 268.608-268.609-120.359-268.609-268.608 120.36-268.609 268.609-268.609zm0 119.152c82.487 0 149.456 66.969 149.456 149.457 0 82.487-66.969 149.456-149.456 149.456-82.488 0-149.457-66.969-149.457-149.456 0-82.488 66.969-149.457 149.457-149.457z"></path><path d="m1309.27 377.585c0-10.083-7.222-18.719-17.146-20.504-19.618-3.528-51.9-9.334-74.172-13.34-6.073-1.092-12.318.564-17.052 4.522-4.734 3.959-7.469 9.812-7.469 15.983v491.469c0 5.525 2.195 10.824 6.102 14.731s9.206 6.102 14.731 6.102h74.173c5.525 0 10.824-2.195 14.731-6.102s6.102-9.206 6.102-14.731c0-84.425 0-400.286 0-478.13z"></path><path d="m1542.59 72.033c0-8.288-3.292-16.237-9.153-22.097-5.86-5.861-13.809-9.153-22.097-9.153-23.867 0-56.609 0-80.477 0-8.288 0-16.236 3.292-22.097 9.153-5.86 5.86-9.153 13.809-9.153 22.097v773.265c0 8.288 3.293 16.237 9.153 22.097 5.861 5.861 13.809 9.153 22.097 9.153h80.477c8.288 0 16.237-3.292 22.097-9.153 5.861-5.86 9.153-13.809 9.153-22.097 0-131.79 0-641.475 0-773.265z" fill-rule="nonzero"></path></g><path d="m506.409 822.063c0 13.815 5.494 27.062 15.271 36.821 9.777 9.76 23.034 15.23 36.848 15.206 18.674-.034 39.711-.072 58.369-.105 28.696-.052 51.932-23.329 51.932-52.026v-769.586c0-13.798-5.481-27.031-15.238-36.788-9.756-9.757-22.99-15.238-36.788-15.238h-58.368c-13.798 0-27.031 5.481-36.788 15.238s-15.238 22.99-15.238 36.788z" fill="url(#uicons-9a00r6vh1r)" fill-rule="nonzero"></path><path d="m616.746 322.662c13.813 0 27.061 5.487 36.829 15.255 9.767 9.768 15.254 23.015 15.254 36.829v447.062c0 13.814-5.487 27.061-15.254 36.829-9.768 9.767-23.016 15.255-36.829 15.255-18.632 0-39.622 0-58.254 0-13.813 0-27.061-5.488-36.828-15.255-9.768-9.768-15.255-23.015-15.255-36.829 0-68.223 0-187.159 0-255.383 0-13.813-5.487-27.061-15.255-36.828-9.767-9.768-23.015-15.255-36.828-15.255-129.249 0-454.326 0-454.326 0v-191.68z" fill="#0051b0" fill-rule="nonzero"></path><path d="m0 822.101c0 13.817 5.497 27.067 15.277 36.827 9.781 9.76 23.043 15.229 36.86 15.199 18.675-.04 39.713-.085 58.368-.124 28.69-.062 51.916-23.337 51.916-52.027 0-155.205 0-614.509 0-769.714 0-28.69-23.226-51.965-51.916-52.026-18.655-.04-39.693-.085-58.368-.125-13.817-.029-27.079 5.439-36.86 15.199-9.78 9.76-15.277 23.01-15.277 36.827v769.964z" fill="url(#uicons-payvanzieq)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -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";
}
});
}
});
});

View File

@ -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;
}
}

View File

@ -0,0 +1,43 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout :: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
>
<th:block th:fragment="body">
<div class="gateway-wrapper">
<div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="#{title}"></h1>
<form
class="halo-form"
th:action="@{/challenges/two-factor/totp}"
name="two-factor-form"
id="two-factor-form"
method="post"
>
<div class="alert alert-error" role="alert" th:if="${param.error.size() > 0}">
<strong th:text="#{messages.invalidError}"></strong>
</div>
<div class="form-item">
<label for="code" th:text="#{form.code.label}"></label>
<div class="form-input">
<input
type="text"
inputmode="numeric"
id="code"
name="code"
autocomplete="one-time-code"
pattern="\d{6}"
autofocus
required
/>
</div>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}"></button>
</div>
</form>
</div>
</div>
</th:block>
</html>

View File

@ -0,0 +1,4 @@
title=两步验证
messages.invalidError=错误的验证码
form.code.label=验证码
form.submit=验证

View File

@ -0,0 +1,4 @@
title=Two-Factor Authentication
messages.invalidError=Invalid TOTP code
form.code.label=TOTP Code
form.submit=Verify

View File

@ -0,0 +1,110 @@
<th:block th:fragment="basicStaticResources">
<script th:inline="javascript">
const resources = {
title: `[(#{title})]`,
};
</script>
<link rel="stylesheet" href="/webjars/normalize.css/8.0.1/normalize.css" />
<link rel="stylesheet" th:href="|/styles/main.css?v=${site.version}|" />
<script src="/js/main.js"></script>
</th:block>
<div th:remove="tag" th:fragment="languageSwitcher">
<style>
.language-switcher {
display: flex;
align-items: center;
justify-content: center;
margin: 2em 0;
gap: 0.625rem;
}
.language-switcher label {
color: var(--color-text);
display: inline-flex;
}
.language-switcher select {
all: unset;
border: 1px solid var(--color-border);
font-size: var(--text-md);
height: 2em;
border-radius: var(--rounded-lg);
outline: none;
padding: 0 2em 0 0.5em;
display: inline-flex;
align-items: center;
color: var(--color-text);
background-color: var(--color-text);
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.45rem center;
}
.language-switcher select:focus {
border-color: var(--color-primary);
}
</style>
<div class="language-switcher">
<label>
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path
fill="currentColor"
d="m12.87 15.07l-2.54-2.51l.03-.03A17.5 17.5 0 0 0 14.07 6H17V4h-7V2H8v2H1v2h11.17C11.5 7.92 10.44 9.75 9 11.35C8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5l3.11 3.11zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2zm-2.62 7l1.62-4.33L19.12 17z"
></path>
</svg>
</label>
<select id="language-select" onchange="changeLanguage()">
<option value="en" th:selected="${#locale.toLanguageTag} == 'en'">English</option>
<option value="es" th:selected="${#locale.toLanguageTag} == 'es'">Español</option>
<option value="zh-CN" th:selected="${#locale.toLanguageTag} == 'zh-CN'">简体中文</option>
<option value="zh-TW" th:selected="${#locale.toLanguageTag} == 'zh-TW'">繁体中文</option>
</select>
<script type="text/javascript">
function changeLanguage() {
const selectedLanguage = document.getElementById("language-select").value;
const currentURL = new URL(window.location.href);
currentURL.searchParams.set("language", selectedLanguage);
window.location.href = currentURL.toString();
}
</script>
</div>
</div>
<div th:remove="tag" th:fragment="haloLogo">
<style>
.halo-logo {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 2em;
}
.halo-logo img {
width: 6em;
}
</style>
<div class="halo-logo">
<img src="/images/wordmark.svg" />
</div>
</div>
<div th:remove="tag" th:fragment="socialAuthProviders">
<th:block th:unless="${#lists.isEmpty(socialAuthProviders)}">
<div class="divider-wrapper">
<hr />
<th:block th:text="#{socialLogin.label}"></th:block>
<hr />
</div>
<ul class="auth-provider-items">
<li th:each="provider : ${socialAuthProviders}">
<a th:href="${provider.spec.authenticationUrl}">
<img th:src="${provider.spec.logo}" />
<span th:text="${provider.spec.displayName}"></span>
</a>
</li>
</ul>
</th:block>
</div>

View File

@ -0,0 +1 @@
socialLogin.label=社交登录

View File

@ -0,0 +1 @@
socialLogin.label=Social Login

View File

@ -0,0 +1,31 @@
<div th:remove="tag" th:fragment="password(id,name,required,minlength,enableToggle)">
<div class="form-input" th:classappend="${enableToggle ? 'form-input-stack toggle-password-display-flag' : ''}">
<input
th:id="${id}"
th:name="${name}"
type="password"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
th:required="${required}"
th:minlength="${minlength}"
/>
<div th:if="${enableToggle}" class="form-input-stack-icon toggle-password-button">
<svg class="password-hidden-icon" style="display: none" viewBox="0 0 24 24" width="1em" height="1em">
<path
fill="currentColor"
d="M12 3c5.392 0 9.878 3.88 10.819 9c-.94 5.12-5.427 9-10.819 9c-5.392 0-9.878-3.88-10.818-9C2.122 6.88 6.608 3 12 3Zm0 16a9.005 9.005 0 0 0 8.778-7a9.005 9.005 0 0 0-17.555 0A9.005 9.005 0 0 0 12 19Zm0-2.5a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9Zm0-2a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5Z"
></path>
</svg>
<svg class="password-display-icon" viewBox="0 0 24 24" width="1em" height="1em">
<path
fill="currentColor"
d="M17.883 19.297A10.949 10.949 0 0 1 12 21c-5.392 0-9.878-3.88-10.818-9A10.982 10.982 0 0 1 4.52 5.935L1.394 2.808l1.414-1.414l19.799 19.798l-1.414 1.415l-3.31-3.31ZM5.936 7.35A8.965 8.965 0 0 0 3.223 12a9.005 9.005 0 0 0 13.201 5.838l-2.028-2.028A4.5 4.5 0 0 1 8.19 9.604L5.936 7.35Zm6.978 6.978l-3.242-3.241a2.5 2.5 0 0 0 3.241 3.241Zm7.893 2.265l-1.431-1.431A8.935 8.935 0 0 0 20.778 12A9.005 9.005 0 0 0 9.552 5.338L7.974 3.76C9.221 3.27 10.58 3 12 3c5.392 0 9.878 3.88 10.819 9a10.947 10.947 0 0 1-2.012 4.593Zm-9.084-9.084a4.5 4.5 0 0 1 4.769 4.769l-4.77-4.77Z"
></path>
</svg>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
<!doctype html>
<html th:lang="${#locale.toLanguageTag}" th:fragment="layout (title,head,body)">
<head>
<meta charset="UTF-8" />
<meta content="IE=edge" http-equiv="X-UA-Compatible" />
<meta content="webkit" name="renderer" />
<meta
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
name="viewport"
/>
<meta content="noindex,nofollow" name="robots" />
<title th:text="${title}"></title>
<th:block th:replace="~{gateway_modules/common_fragments::basicStaticResources}"></th:block>
<th:block th:if="${head != null}">
<th:block th:replace="${head}" />
</th:block>
</head>
<body class="gateway-page">
<th:block th:replace="${body}" />
</body>
</html>

View File

@ -0,0 +1,95 @@
<!-- Those fragments are only for login template-->
<form
th:fragment="form"
class="halo-form"
name="login-form"
id="login-form"
th:action="${authProvider.spec.authenticationUrl}"
th:method="${authProvider.spec.method}"
>
<div class="alert alert-error" role="alert" th:if="${param.error.size() > 0}" th:with="error = ${param.error[0]}">
<strong th:if="${error == 'invalid-credential'}">
<span th:text="#{error.invalid-credential}"></span>
</strong>
<strong th:if="${error == 'rate-limit-exceeded'}">
<span th:text="#{error.rate-limit-exceeded}"></span>
</strong>
</div>
<div class="alert" role="alert" th:if="${param.logout.size() > 0}">
<strong th:text="#{messages.logoutSuccess}"></strong>
</div>
<div class="alert" role="alert" th:if="${param.signup.size() > 0}">
<strong th:text="#{messages.signupSuccess}"> </strong>
</div>
<div th:replace="~{__${fragmentTemplateName}__::form}"></div>
<div th:if="${authProvider.spec.rememberMeSupport}" class="form-item-compact">
<input type="checkbox" id="remember-me" name="remember-me" value="true"/>
<label for="remember-me" th:text="#{form.rememberMe.label}"></label>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}"></button>
</div>
</form>
<div th:remove="tag" th:fragment="formAuthProviders">
<th:block th:unless="${#lists.isEmpty(formAuthProviders)}">
<div class="divider-wrapper">
<hr/>
<th:block th:text="#{otherLogin.label}"></th:block>
<hr/>
</div>
<ul class="auth-provider-items">
<li th:each="provider : ${formAuthProviders}">
<a th:href="'/login?method=' + ${provider.metadata.name}">
<img th:src="${provider.spec.logo}"/>
<span th:text="${provider.spec.displayName}"></span>
</a>
</li>
</ul>
</th:block>
</div>
<div th:remove="tag" th:fragment="miscellaneous">
<style>
.signup-notice,
.returntosite-notice {
font-size: var(--text-md);
color: var(--color-text);
text-align: center;
margin: 1em 0;
}
.signup-notice a {
color: var(--color-link);
}
.returntosite-notice a {
color: var(--color-text);
display: inline-flex;
align-items: center;
gap: 0.3em;
}
.returntosite-notice a,
.signup-notice a {
text-decoration: none;
}
</style>
<div th:if="${globalInfo.allowRegistration}" class="signup-notice">
<div>
<th:block th:text="#{signup.description}"></th:block>
<a th:href="@{/signup}" th:text="#{signup.link}"></a>
</div>
</div>
<div class="returntosite-notice">
<a th:href="@{/}">
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 11H6.83l3.58-3.59L9 6l-6 6l6 6l1.41-1.42L6.83 13H21z"></path>
</svg>
<span th:text="#{returnToSite}"></span>
</a>
</div>
</div>

View File

@ -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=返回网站

View File

@ -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

View File

@ -0,0 +1,20 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout :: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
>
<th:block th:fragment="body">
<div class="gateway-wrapper">
<div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
<div class="halo-form-wrapper">
<form th:replace="~{gateway_modules/login_fragments::form}"></form>
<div th:replace="~{gateway_modules/login_fragments::formAuthProviders}"></div>
<div th:replace="~{gateway_modules/common_fragments::socialAuthProviders}"></div>
</div>
<div th:replace="~{gateway_modules/login_fragments::miscellaneous}"></div>
<div th:replace="~{gateway_modules/common_fragments::languageSwitcher}"></div>
</div>
</th:block>
</html>

View File

@ -0,0 +1 @@
title=登录

View File

@ -0,0 +1,37 @@
<div th:remove="tag" th:fragment="form">
<div class="form-item">
<label for="email"> Email </label>
<div class="form-input">
<input
type="email"
id="email"
name="email"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
required
autofocus
/>
</div>
</div>
<div class="form-item">
<label for="code"> Code </label>
<div class="form-input-group">
<div class="form-input">
<input
type="text"
id="code"
name="code"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
required
/>
</div>
<button type="button">Send</button>
</div>
</div>
</div>

View File

@ -0,0 +1 @@
title=Login

View File

@ -0,0 +1,56 @@
<div th:remove="tag" th:fragment="form">
<script src="/webjars/jsencrypt/3.3.2/bin/jsencrypt.min.js" defer></script>
<script th:inline="javascript" type="text/javascript">
const publicKey = /*[[${publicKey}]]*/ "";
// Encrypt function
function encryptPassword(password) {
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
return encrypt.encrypt(password);
}
document.addEventListener("DOMContentLoaded", function () {
const loginForm = document.getElementById("login-form");
loginForm.addEventListener("submit", function (event) {
const passwordInput = document.getElementById("password");
const password = passwordInput.value;
passwordInput.value = encryptPassword(password);
});
});
</script>
<div class="form-item">
<label for="username" th:text="#{form.username.label}"> </label>
<div class="form-input">
<input
id="username"
name="username"
type="text"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
required
autofocus
/>
</div>
</div>
<div class="form-item">
<div class="form-label-group">
<label for="password" th:text="#{form.password.label}"> </label>
<a
class="form-item-extra-link"
tabindex="-1"
th:href="@{/password-reset}"
th:text="#{form.password.forgot}"
>
</a>
</div>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = null, enableToggle = true)}"
></th:block>
</div>
</div>

View File

@ -0,0 +1,3 @@
form.username.label=用户名
form.password.label=密码
form.password.forgot=忘记密码?

View File

@ -0,0 +1,3 @@
form.username.label=Username
form.password.label=Password
form.password.forgot=Forgot your password?

View File

@ -0,0 +1,18 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout:: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
>
<th:block th:fragment="body">
<div class="gateway-wrapper">
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="#{form.title}"></h1>
<form class="halo-form" id="logout-form" name="logout-form" th:action="@{/logout}" method="post">
<div class="form-item">
<button type="submit" th:text="#{form.submit}"></button>
</div>
</form>
</div>
</div>
</th:block>
</html>

View File

@ -0,0 +1,3 @@
title=退出登录
form.title=确定要退出登录吗?
form.submit=退出登录

View File

@ -0,0 +1,3 @@
title=Logout
form.title=Are you sure want to log out?
form.submit=Logout

View File

@ -0,0 +1,39 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout:: layout(title = |#{title(${username})} - ${site.title}|, head = null, body = ~{::body})}"
>
<th:block th:fragment="body">
<div class="gateway-wrapper">
<div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="#{title(${username})}"></h1>
<p style="color: red" role="alert" th:if="${error}" th:text="${error}"></p>
<form
class="halo-form"
th:action="@{/password-reset/{resetToken}(resetToken=${resetToken})}"
method="post"
>
<div class="form-item">
<label for="password" th:text="#{form.password.label}">Password</label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 6, enableToggle = true)}"
></th:block>
</div>
<div class="form-item">
<label for="confirmPassword" th:text="#{form.confirmPassword.label}">Confirm Password</label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 6, enableToggle = true)}"
></th:block>
</div>
<div class="form-item">
<p th:text="#{form.password.tips}"></p>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}">Change password</button>
</div>
</form>
</div>
</div>
</th:block>
</html>

View File

@ -0,0 +1,5 @@
title=为 {0} 修改密码
form.password.label=密码
form.confirmPassword.label=确认密码
form.password.tips=密码必须至少包含 8 个字符,并且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。
form.submit=修改密码

View File

@ -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

View File

@ -0,0 +1,36 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout:: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
>
<th:block th:fragment="body">
<div class="gateway-wrapper">
<div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="${sent} ? #{sent.title} : #{title}"></h1>
<div class="alert alert-error" th:if="${error}">
<strong th:text="${error}"></strong>
</div>
<form th:if="${sent}" method="get" action="/login" class="halo-form">
<div class="form-item">
<div class="alert" th:text="#{sent.form.message}"></div>
</div>
<div class="form-item">
<button type="submit" th:text="#{sent.form.submit}"></button>
</div>
</form>
<form th:unless="${sent}" class="halo-form" th:action="@{/password-reset}" method="post">
<div class="form-item">
<label for="email" th:text="#{form.email.label}"></label>
<div class="form-input">
<input type="email" id="email" name="email" autofocus required />
</div>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}"></button>
</div>
</form>
</div>
</div>
</th:block>
</html>

View File

@ -0,0 +1,6 @@
title=重置密码
form.email.label=电子邮箱
form.submit=提交
sent.form.submit=返回到登录页面
sent.form.message=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
sent.title=已发送重置密码的邮件

View File

@ -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 doesnt appear within a few minutes, check your spam folder.
sent.title=Password reset email has been sent

View File

@ -0,0 +1,202 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout :: layout(title = |#{title} - ${site.title}|, head = ~{::head}, body = ~{::body})}"
>
<th:block th:fragment="head">
<style>
.signup-page-wrapper {
max-width: 35em;
}
</style>
</th:block>
<th:block th:fragment="body">
<div class="gateway-wrapper signup-page-wrapper">
<div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="#{title}"></h1>
<p class="alert alert=erro" role="alert" th:if="${error == 'invalid-email-code'}">
<span th:text="#{error.invalid-email-code}">Invalid email code</span>
</p>
<p class="alert alert=erro" role="alert" th:if="${error == 'rate-limit-exceeded'}">
<span th:text="#{error.rate-limit-exceeded}">Rate limit exceeded</span>
</p>
<p class="alert alert=erro" role="alert" th:if="${error == 'duplicate-name'}">
<span th:text="#{error.duplicate-name}">Duplicate name</span>
</p>
<form class="halo-form" name="signup-form" id="signup-form" th:action="@{/signup}" th:object="${form}"
method="post">
<div class="form-item-group">
<div class="form-item">
<label for="username" th:text="#{form.username.label}"></label>
<div class="form-input">
<input
type="text"
id="username"
name="username"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
autofocus
required
th:field="*{username}"
/>
</div>
<p class="alert alert-error"
th:if="${#fields.hasErrors('username')}"
th:errors="*{username}">
</p>
</div>
<div class="form-item">
<label for="displayName" th:text="#{form.displayName.label}"></label>
<div class="form-input">
<input
type="text"
id="displayName"
name="displayName"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
required
th:field="*{displayName}"
/>
</div>
<p class="alert alert-error"
th:if="${#fields.hasErrors('displayName')}"
th:errors="*{displayName}">
</p>
</div>
</div>
<div class="form-item-group">
<div class="form-item">
<label for="email" th:text="#{form.email.label}"></label>
<div class="form-input">
<input
type="email"
id="email"
name="email"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
required
th:field="*{email}"
/>
</div>
<p class="alert alert-error"
th:if="${#fields.hasErrors('email')}"
th:errors="*{email}">
</p>
</div>
<div class="form-item" th:if="${globalInfo.mustVerifyEmailOnRegistration}">
<label for="emailCode" th:text="#{form.emailCode.label}"></label>
<div class="form-input-group">
<div class="form-input">
<input
type="text"
inputmode="numeric"
pattern="\d*"
id="emailCode"
name="emailCode"
required
/>
</div>
<button
id="emailCodeSendButton"
type="button"
th:text="#{form.emailCode.sendButton}"
></button>
</div>
<p class="alert alert-error"
th:if="${#fields.hasErrors('emailCode')}"
th:errors="*{emailCode}">
</p>
</div>
</div>
<div class="form-item">
<label for="password" th:text="#{form.password.label}"></label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 6, enableToggle = true)}"
></th:block>
<p class="alert alert-error"
th:if="${#fields.hasErrors('password')}"
th:errors="*{password}">
</p>
</div>
<div class="form-item">
<label for="confirmPassword" th:text="#{form.confirmPassword.label}"></label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = null, required = 'true', minlength = 6, enableToggle = true)}"
></th:block>
<p class="alert alert-error"
th:if="${#fields.hasErrors('confirmPassword')}"
th:errors="*{confirmPassword}">
</p>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}"></button>
</div>
</form>
<div th:replace="~{gateway_modules/common_fragments::socialAuthProviders}"></div>
</div>
</div>
<script th:inline="javascript">
document.addEventListener("DOMContentLoaded", function () {
function sendRequest() {
return new Promise((resolve, reject) => {
const email = document.getElementById("email").value;
if (!email) {
throw new Error("请先输入邮箱地址");
}
fetch("/signup/send-email-code", {
method: "POST",
body: JSON.stringify({ email: email }),
headers: {
"Content-Type": "application/json",
[[${_csrf.headerName}]]: [[${_csrf.token}]],
},
})
.then((response) => {
if (response.ok) {
resolve(response);
}
reject(response);
})
.catch((e) => {
reject(e);
});
});
}
const emailCodeSendButton = document.getElementById("emailCodeSendButton");
sendVerificationCode(emailCodeSendButton, sendRequest);
var password = document.getElementById("password"),
confirm_password = document.getElementById("confirmPassword");
function validatePassword() {
if (password.value != confirm_password.value) {
confirm_password.setCustomValidity("Passwords Don't Match");
} else {
confirm_password.setCustomValidity("");
}
}
password.onchange = validatePassword;
confirm_password.onkeyup = validatePassword;
});
</script>
</th:block>
</html>

View File

@ -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=请求过于频繁,请稍后再试

View File

@ -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

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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\"");
}
}

View File

@ -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<Arguments> 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<Arguments> 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")
);
}
}

View File

@ -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<>());

View File

@ -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());
}
}

View File

@ -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;
}));
}
}

View File

@ -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());

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More