mirror of https://github.com/halo-dev/halo
Merge pull request #6488 from JohnNiang/feat/login-logout-pages
Add support for customizing login and logout pagespull/6712/head^2
commit
45a08bd676
|
@ -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,
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -80,6 +80,10 @@ dependencies {
|
|||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
|
||||
// webjars
|
||||
runtimeOnly 'org.webjars.npm:jsencrypt:3.3.2'
|
||||
runtimeOnly 'org.webjars.npm:normalize.css:8.0.1'
|
||||
}
|
||||
|
||||
tasks.register('createChecksums', Checksum) {
|
||||
|
@ -164,4 +168,4 @@ tasks.named('generateOpenApiDocs') {
|
|||
outputs.upToDateWhen {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -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) {
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, "/");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 + '.')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
title=两步验证
|
||||
messages.invalidError=错误的验证码
|
||||
form.code.label=验证码
|
||||
form.submit=验证
|
|
@ -0,0 +1,4 @@
|
|||
title=Two-Factor Authentication
|
||||
messages.invalidError=Invalid TOTP code
|
||||
form.code.label=TOTP Code
|
||||
form.submit=Verify
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
socialLogin.label=社交登录
|
|
@ -0,0 +1 @@
|
|||
socialLogin.label=Social Login
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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=返回网站
|
|
@ -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
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
title=登录
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
title=Login
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
form.username.label=用户名
|
||||
form.password.label=密码
|
||||
form.password.forgot=忘记密码?
|
|
@ -0,0 +1,3 @@
|
|||
form.username.label=Username
|
||||
form.password.label=Password
|
||||
form.password.forgot=Forgot your password?
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
title=退出登录
|
||||
form.title=确定要退出登录吗?
|
||||
form.submit=退出登录
|
|
@ -0,0 +1,3 @@
|
|||
title=Logout
|
||||
form.title=Are you sure want to log out?
|
||||
form.submit=Logout
|
|
@ -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>
|
|
@ -0,0 +1,5 @@
|
|||
title=为 {0} 修改密码
|
||||
form.password.label=密码
|
||||
form.confirmPassword.label=确认密码
|
||||
form.password.tips=密码必须至少包含 8 个字符,并且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。
|
||||
form.submit=修改密码
|
|
@ -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
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
title=重置密码
|
||||
form.email.label=电子邮箱
|
||||
form.submit=提交
|
||||
sent.form.submit=返回到登录页面
|
||||
sent.form.message=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
|
||||
sent.title=已发送重置密码的邮件
|
|
@ -0,0 +1,6 @@
|
|||
title=Reset password
|
||||
form.email.label=Email
|
||||
form.submit=Submit
|
||||
sent.form.submit=Return to login
|
||||
sent.form.message=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.
|
||||
sent.title=Password reset email has been sent
|
|
@ -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>
|
|
@ -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=请求过于频繁,请稍后再试
|
|
@ -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
|
|
@ -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"));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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\"");
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<>());
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue