mirror of https://github.com/halo-dev/halo
feat: add remember-me mechanism to enhance user login experience (#5929)
#### What type of PR is this? /kind feature /area core /milestone 2.16.x #### What this PR does / why we need it: 为登录增加记住我机制以优化登录体验 how to test it? 1. 勾选记住密码选项后登录 2. 退出浏览器后打开 console 期望依然可以访问而不需要登录 3. 测试修改密码功能,期望修改密码后所有会话需要重新登录包括当前设备和其他设备 #### Which issue(s) this PR fixes: Fixes #2362 #### Does this PR introduce a user-facing change? ```release-note 为登录增加记住我机制以优化登录体验 ```pull/5985/head
parent
69c3a63618
commit
9ec608be3b
|
@ -2,6 +2,7 @@ package run.halo.app.infra.properties;
|
||||||
|
|
||||||
import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
|
import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
|
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
|
||||||
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
|
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
|
||||||
|
@ -13,6 +14,8 @@ public class SecurityProperties {
|
||||||
|
|
||||||
private final ReferrerOptions referrerOptions = new ReferrerOptions();
|
private final ReferrerOptions referrerOptions = new ReferrerOptions();
|
||||||
|
|
||||||
|
private final RememberMeOptions rememberMe = new RememberMeOptions();
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class FrameOptions {
|
public static class FrameOptions {
|
||||||
|
|
||||||
|
@ -27,4 +30,9 @@ public class SecurityProperties {
|
||||||
private ReferrerPolicy policy = STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
|
private ReferrerPolicy policy = STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class RememberMeOptions {
|
||||||
|
private Duration tokenValidity = Duration.ofDays(14);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import static org.springframework.security.config.web.server.SecurityWebFiltersO
|
||||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
|
@ -15,9 +16,12 @@ import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||||
|
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
||||||
|
private final RememberMeServices rememberMeServices;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configure(ServerHttpSecurity http) {
|
public void configure(ServerHttpSecurity http) {
|
||||||
|
@ -25,7 +29,7 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
||||||
http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING);
|
http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
|
private class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
|
||||||
|
|
||||||
private final ServerLogoutSuccessHandler defaultHandler;
|
private final ServerLogoutSuccessHandler defaultHandler;
|
||||||
|
|
||||||
|
@ -38,7 +42,9 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange,
|
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange())
|
return rememberMeServices.loginFail(exchange.getExchange())
|
||||||
|
.then(ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
|
||||||
|
.matches(exchange.getExchange())
|
||||||
.flatMap(matchResult -> {
|
.flatMap(matchResult -> {
|
||||||
if (matchResult.isMatch()) {
|
if (matchResult.isMatch()) {
|
||||||
var response = exchange.getExchange().getResponse();
|
var response = exchange.getExchange().getResponse();
|
||||||
|
@ -46,7 +52,8 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
||||||
return response.setComplete();
|
return response.setComplete();
|
||||||
}
|
}
|
||||||
return defaultHandler.onLogoutSuccess(exchange, authentication);
|
return defaultHandler.onLogoutSuccess(exchange, authentication);
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||||
import run.halo.app.security.authentication.CryptoService;
|
import run.halo.app.security.authentication.CryptoService;
|
||||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||||
|
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class LoginSecurityConfigurer implements SecurityConfigurer {
|
public class LoginSecurityConfigurer implements SecurityConfigurer {
|
||||||
|
@ -41,12 +42,15 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
|
||||||
private final MessageSource messageSource;
|
private final MessageSource messageSource;
|
||||||
private final RateLimiterRegistry rateLimiterRegistry;
|
private final RateLimiterRegistry rateLimiterRegistry;
|
||||||
|
|
||||||
|
private final RememberMeServices rememberMeServices;
|
||||||
|
|
||||||
public LoginSecurityConfigurer(ObservationRegistry observationRegistry,
|
public LoginSecurityConfigurer(ObservationRegistry observationRegistry,
|
||||||
ReactiveUserDetailsService userDetailsService,
|
ReactiveUserDetailsService userDetailsService,
|
||||||
ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder,
|
ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder,
|
||||||
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
|
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
|
||||||
ExtensionGetter extensionGetter, ServerResponse.Context context,
|
ExtensionGetter extensionGetter, ServerResponse.Context context,
|
||||||
MessageSource messageSource, RateLimiterRegistry rateLimiterRegistry) {
|
MessageSource messageSource, RateLimiterRegistry rateLimiterRegistry,
|
||||||
|
RememberMeServices rememberMeServices) {
|
||||||
this.observationRegistry = observationRegistry;
|
this.observationRegistry = observationRegistry;
|
||||||
this.userDetailsService = userDetailsService;
|
this.userDetailsService = userDetailsService;
|
||||||
this.passwordService = passwordService;
|
this.passwordService = passwordService;
|
||||||
|
@ -57,13 +61,14 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.messageSource = messageSource;
|
this.messageSource = messageSource;
|
||||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||||
|
this.rememberMeServices = rememberMeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configure(ServerHttpSecurity http) {
|
public void configure(ServerHttpSecurity http) {
|
||||||
var filter = new AuthenticationWebFilter(authenticationManager());
|
var filter = new AuthenticationWebFilter(authenticationManager());
|
||||||
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
|
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
|
||||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
var handler = new UsernamePasswordHandler(context, messageSource, rememberMeServices);
|
||||||
var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry);
|
var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry);
|
||||||
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
||||||
filter.setAuthenticationFailureHandler(handler);
|
filter.setAuthenticationFailureHandler(handler);
|
||||||
|
|
|
@ -20,6 +20,7 @@ import org.springframework.web.ErrorResponse;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@ -30,22 +31,27 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
||||||
|
|
||||||
private final MessageSource messageSource;
|
private final MessageSource messageSource;
|
||||||
|
|
||||||
|
private final RememberMeServices rememberMeServices;
|
||||||
|
|
||||||
private final ServerAuthenticationFailureHandler defaultFailureHandler =
|
private final ServerAuthenticationFailureHandler defaultFailureHandler =
|
||||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||||
|
|
||||||
private final ServerAuthenticationSuccessHandler defaultSuccessHandler =
|
private final ServerAuthenticationSuccessHandler defaultSuccessHandler =
|
||||||
new RedirectServerAuthenticationSuccessHandler("/console/");
|
new RedirectServerAuthenticationSuccessHandler("/console/");
|
||||||
|
|
||||||
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource) {
|
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource,
|
||||||
|
RememberMeServices rememberMeServices) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.messageSource = messageSource;
|
this.messageSource = messageSource;
|
||||||
|
this.rememberMeServices = rememberMeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||||
AuthenticationException exception) {
|
AuthenticationException exception) {
|
||||||
var exchange = webFilterExchange.getExchange();
|
var exchange = webFilterExchange.getExchange();
|
||||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
return rememberMeServices.loginFail(exchange)
|
||||||
|
.then(ignoringMediaTypeAll(APPLICATION_JSON)
|
||||||
.matches(exchange)
|
.matches(exchange)
|
||||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
.switchIfEmpty(
|
.switchIfEmpty(
|
||||||
|
@ -53,7 +59,7 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
||||||
// Skip the handleAuthenticationException.
|
// Skip the handleAuthenticationException.
|
||||||
.then(Mono.empty())
|
.then(Mono.empty())
|
||||||
)
|
)
|
||||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange));
|
.flatMap(matchResult -> handleAuthenticationException(exception, exchange)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -61,7 +67,8 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
if (authentication instanceof TwoFactorAuthentication) {
|
if (authentication instanceof TwoFactorAuthentication) {
|
||||||
// continue filtering for authorization
|
// continue filtering for authorization
|
||||||
return webFilterExchange.getChain().filter(webFilterExchange.getExchange());
|
return rememberMeServices.loginSuccess(webFilterExchange.getExchange(), authentication)
|
||||||
|
.then(webFilterExchange.getChain().filter(webFilterExchange.getExchange()));
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerWebExchangeMatcher xhrMatcher = exchange -> {
|
ServerWebExchangeMatcher xhrMatcher = exchange -> {
|
||||||
|
@ -73,7 +80,8 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
||||||
};
|
};
|
||||||
|
|
||||||
var exchange = webFilterExchange.getExchange();
|
var exchange = webFilterExchange.getExchange();
|
||||||
return xhrMatcher.matches(exchange)
|
return rememberMeServices.loginSuccess(exchange, authentication)
|
||||||
|
.then(xhrMatcher.matches(exchange)
|
||||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
.switchIfEmpty(Mono.defer(
|
.switchIfEmpty(Mono.defer(
|
||||||
() -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange,
|
() -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange,
|
||||||
|
@ -86,7 +94,7 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
||||||
return ServerResponse.ok()
|
return ServerResponse.ok()
|
||||||
.bodyValue(authentication.getPrincipal())
|
.bodyValue(authentication.getPrincipal())
|
||||||
.flatMap(response -> response.writeTo(exchange, context));
|
.flatMap(response -> response.writeTo(exchange, context));
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> handleAuthenticationException(Throwable exception,
|
private Mono<Void> handleAuthenticationException(Throwable exception,
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CookieSignatureKeyResolver {
|
||||||
|
Mono<String> resolveSigningKey();
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.security.authentication.CryptoService;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DefaultCookieSignatureKeyResolver implements CookieSignatureKeyResolver {
|
||||||
|
private final CryptoService cryptoService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<String> resolveSigningKey() {
|
||||||
|
return Mono.fromSupplier(cryptoService::getKeyId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.core.context.SecurityContextImpl;
|
||||||
|
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.WebFilter;
|
||||||
|
import org.springframework.web.server.WebFilterChain;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RememberMeAuthenticationFilter implements WebFilter {
|
||||||
|
private final ServerSecurityContextRepository securityContextRepository;
|
||||||
|
private final RememberMeServices rememberMeServices;
|
||||||
|
private final RememberMeAuthenticationManager rememberMeAuthenticationManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||||
|
return securityContextRepository.load(exchange)
|
||||||
|
.switchIfEmpty(Mono.defer(() -> rememberMeServices.autoLogin(exchange)
|
||||||
|
.flatMap(rememberMeAuthenticationManager::authenticate)
|
||||||
|
.flatMap(authentication -> {
|
||||||
|
var securityContext = new SecurityContextImpl(authentication);
|
||||||
|
return securityContextRepository.save(exchange, securityContext);
|
||||||
|
})
|
||||||
|
.onErrorResume(AuthenticationException.class,
|
||||||
|
e -> rememberMeServices.loginFail(exchange)
|
||||||
|
)
|
||||||
|
.then(Mono.empty())
|
||||||
|
))
|
||||||
|
.then(chain.filter(exchange));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.context.MessageSource;
|
||||||
|
import org.springframework.context.MessageSourceAware;
|
||||||
|
import org.springframework.context.support.MessageSourceAccessor;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.RememberMeAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.SpringSecurityMessageSource;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RememberMeAuthenticationManager implements ReactiveAuthenticationManager,
|
||||||
|
InitializingBean, MessageSourceAware {
|
||||||
|
|
||||||
|
private final CookieSignatureKeyResolver cookieSignatureKeyResolver;
|
||||||
|
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Authentication> authenticate(Authentication authentication) {
|
||||||
|
if (authentication instanceof RememberMeAuthenticationToken rememberMeAuthenticationToken) {
|
||||||
|
return doAuthenticate(rememberMeAuthenticationToken);
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
Assert.notNull(this.messages, "A message source must be set");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMessageSource(@NonNull MessageSource messageSource) {
|
||||||
|
this.messages = new MessageSourceAccessor(messageSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Authentication> doAuthenticate(RememberMeAuthenticationToken token) {
|
||||||
|
return cookieSignatureKeyResolver.resolveSigningKey()
|
||||||
|
.flatMap(key -> {
|
||||||
|
if (key.hashCode() != token.getKeyHash()) {
|
||||||
|
return Mono.error(new BadCredentialsException(badCredentialMessage()));
|
||||||
|
}
|
||||||
|
return Mono.just(token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String badCredentialMessage() {
|
||||||
|
return this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey",
|
||||||
|
"The presented RememberMeAuthenticationToken does not contain the expected key");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||||
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
|
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RememberMeConfigurer implements SecurityConfigurer {
|
||||||
|
private final RememberMeServices rememberMeServices;
|
||||||
|
private final ServerSecurityContextRepository securityContextRepository;
|
||||||
|
private final CookieSignatureKeyResolver cookieSignatureKeyResolver;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(ServerHttpSecurity http) {
|
||||||
|
http.addFilterAt(
|
||||||
|
new RememberMeAuthenticationFilter(securityContextRepository,
|
||||||
|
rememberMeServices, authenticationManager()),
|
||||||
|
SecurityWebFiltersOrder.AUTHENTICATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
RememberMeAuthenticationManager authenticationManager() {
|
||||||
|
return new RememberMeAuthenticationManager(cookieSignatureKeyResolver);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.springframework.http.HttpCookie;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
public interface RememberMeCookieResolver {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
HttpCookie resolveRememberMeCookie(ServerWebExchange exchange);
|
||||||
|
|
||||||
|
void setRememberMeCookie(ServerWebExchange exchange, String value);
|
||||||
|
|
||||||
|
void expireCookie(ServerWebExchange exchange);
|
||||||
|
|
||||||
|
String getCookieName();
|
||||||
|
|
||||||
|
Duration getCookieMaxAge();
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.http.HttpCookie;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Component
|
||||||
|
public class RememberMeCookieResolverImpl implements RememberMeCookieResolver {
|
||||||
|
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
|
||||||
|
|
||||||
|
private final String cookieName = SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY;
|
||||||
|
|
||||||
|
private final Duration cookieMaxAge;
|
||||||
|
|
||||||
|
public RememberMeCookieResolverImpl(HaloProperties haloProperties) {
|
||||||
|
this.cookieMaxAge = haloProperties.getSecurity().getRememberMe().getTokenValidity();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
public HttpCookie resolveRememberMeCookie(ServerWebExchange exchange) {
|
||||||
|
return exchange.getRequest().getCookies().getFirst(getCookieName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setRememberMeCookie(ServerWebExchange exchange, String value) {
|
||||||
|
Assert.notNull(value, "'value' is required");
|
||||||
|
exchange.getResponse().getCookies()
|
||||||
|
.set(getCookieName(), initCookie(exchange, value).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void expireCookie(ServerWebExchange exchange) {
|
||||||
|
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
|
||||||
|
exchange.getResponse().getCookies().set(this.cookieName, cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
|
||||||
|
String value) {
|
||||||
|
return ResponseCookie.from(this.cookieName, value)
|
||||||
|
.path(exchange.getRequest().getPath().contextPath().value() + "/")
|
||||||
|
.maxAge(getCookieMaxAge())
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
|
||||||
|
.sameSite("Lax");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface RememberMeServices {
|
||||||
|
|
||||||
|
Mono<Authentication> autoLogin(ServerWebExchange exchange);
|
||||||
|
|
||||||
|
Mono<Void> loginFail(ServerWebExchange exchange);
|
||||||
|
|
||||||
|
Mono<Void> loginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication);
|
||||||
|
}
|
|
@ -0,0 +1,377 @@
|
||||||
|
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;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.authentication.AccountStatusException;
|
||||||
|
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
|
||||||
|
import org.springframework.security.authentication.RememberMeAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||||
|
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
|
||||||
|
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsChecker;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.crypto.codec.Hex;
|
||||||
|
import org.springframework.security.crypto.codec.Utf8;
|
||||||
|
import org.springframework.security.web.authentication.rememberme.CookieTheftException;
|
||||||
|
import org.springframework.security.web.authentication.rememberme.InvalidCookieException;
|
||||||
|
import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>An {@link org.springframework.security.core.userdetails.UserDetailsService} is required
|
||||||
|
* by this implementation, so that it can construct a valid <code>Authentication</code>
|
||||||
|
* from the returned {@link org.springframework.security.core.userdetails.UserDetails}.</p>
|
||||||
|
* <p>This is also necessary so that the user's password is available and can be checked as
|
||||||
|
* part of the encoded cookie.</p>
|
||||||
|
* <p>The cookie encoded by this implementation adopts the following form:
|
||||||
|
* <pre>
|
||||||
|
* username + ":" + expiryTime + ":" + algorithmName + ":"
|
||||||
|
* + algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)
|
||||||
|
* </pre>
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @see org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TokenBasedRememberMeServices implements 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 = ":";
|
||||||
|
|
||||||
|
private final CookieSignatureKeyResolver cookieSignatureKeyResolver;
|
||||||
|
|
||||||
|
private final ReactiveUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
private final RememberMeCookieResolver rememberMeCookieResolver;
|
||||||
|
|
||||||
|
private UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();
|
||||||
|
|
||||||
|
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
|
||||||
|
|
||||||
|
private static boolean equals(String expected, String actual) {
|
||||||
|
byte[] expectedBytes = bytesUtf8(expected);
|
||||||
|
byte[] actualBytes = bytesUtf8(actual);
|
||||||
|
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] bytesUtf8(String s) {
|
||||||
|
return (s != null) ? Utf8.encode(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Authentication> autoLogin(ServerWebExchange exchange) {
|
||||||
|
var rememberMeCookie = rememberMeCookieResolver.resolveRememberMeCookie(exchange);
|
||||||
|
if (rememberMeCookie == null) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
log.debug("Remember-me cookie detected");
|
||||||
|
return Mono.defer(
|
||||||
|
() -> {
|
||||||
|
String[] cookieTokens = decodeCookie(rememberMeCookie.getValue());
|
||||||
|
return processAutoLoginCookie(cookieTokens, exchange);
|
||||||
|
})
|
||||||
|
.flatMap(user -> {
|
||||||
|
this.userDetailsChecker.check(user);
|
||||||
|
log.debug("Remember-me cookie accepted");
|
||||||
|
return createSuccessfulAuthentication(exchange, user);
|
||||||
|
})
|
||||||
|
.onErrorResume(ex -> handleError(exchange, ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Authentication> handleError(ServerWebExchange exchange, Throwable ex) {
|
||||||
|
cancelCookie(exchange);
|
||||||
|
if (ex instanceof CookieTheftException) {
|
||||||
|
log.error("Cookie theft detected", ex);
|
||||||
|
return Mono.error(ex);
|
||||||
|
} else if (ex instanceof UsernameNotFoundException) {
|
||||||
|
log.debug("Remember-me login was valid but corresponding user not found.", ex);
|
||||||
|
} else if (ex instanceof InvalidCookieException) {
|
||||||
|
log.debug("Invalid remember-me cookie: {}", ex.getMessage());
|
||||||
|
} else if (ex instanceof AccountStatusException) {
|
||||||
|
log.debug("Invalid UserDetails: {}", ex.getMessage());
|
||||||
|
} else if (ex instanceof RememberMeAuthenticationException) {
|
||||||
|
log.debug(ex.getMessage());
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void cancelCookie(ServerWebExchange exchange) {
|
||||||
|
rememberMeCookieResolver.expireCookie(exchange);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Mono<UserDetails> processAutoLoginCookie(String[] cookieTokens,
|
||||||
|
ServerWebExchange exchange) {
|
||||||
|
if (!isValidCookieTokensLength(cookieTokens)) {
|
||||||
|
throw new InvalidCookieException(
|
||||||
|
"Cookie token did not contain 3 or 4 tokens, but contained '" + Arrays.asList(
|
||||||
|
cookieTokens) + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
|
||||||
|
if (isTokenExpired(tokenExpiryTime)) {
|
||||||
|
throw new InvalidCookieException(
|
||||||
|
"Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
|
||||||
|
+ "'; current time is '" + new Date() + "')");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the user exists. Defer lookup until after expiry time checked, to
|
||||||
|
// possibly avoid expensive database call.
|
||||||
|
return getUserDetailsService().findByUsername(cookieTokens[0])
|
||||||
|
.switchIfEmpty(Mono.error(new UsernameNotFoundException("User '" + cookieTokens[0]
|
||||||
|
+ "' not found")))
|
||||||
|
.flatMap(userDetails -> {
|
||||||
|
// Check signature of token matches remaining details. Must do this after user
|
||||||
|
// lookup, as we need the DAO-derived password. If efficiency was a major issue,
|
||||||
|
// just add in a UserCache implementation, but recall that this method is usually
|
||||||
|
// only called once per HttpSession - if the token is valid, it will cause
|
||||||
|
// SecurityContextHolder population, whilst if invalid, will cause the cookie to
|
||||||
|
// be cancelled.
|
||||||
|
String actualTokenSignature;
|
||||||
|
String actualAlgorithm = DEFAULT_ALGORITHM;
|
||||||
|
// If the cookie value contains the algorithm, we use that algorithm to check the
|
||||||
|
// signature
|
||||||
|
if (cookieTokens.length == 4) {
|
||||||
|
actualTokenSignature = cookieTokens[3];
|
||||||
|
actualAlgorithm = cookieTokens[2];
|
||||||
|
} else {
|
||||||
|
actualTokenSignature = cookieTokens[2];
|
||||||
|
}
|
||||||
|
return makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
|
||||||
|
userDetails.getPassword(), actualAlgorithm)
|
||||||
|
.doOnNext(expectedTokenSignature -> {
|
||||||
|
if (!equals(expectedTokenSignature, actualTokenSignature)) {
|
||||||
|
throw new InvalidCookieException(
|
||||||
|
"Cookie contained signature '" + actualTokenSignature
|
||||||
|
+ "' but expected '"
|
||||||
|
+ expectedTokenSignature + "'");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.thenReturn(userDetails);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean isTokenExpired(long tokenExpiryTime) {
|
||||||
|
return tokenExpiryTime < System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getTokenExpiryTime(String[] cookieTokens) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(cookieTokens[1]);
|
||||||
|
} catch (NumberFormatException nfe) {
|
||||||
|
throw new InvalidCookieException(
|
||||||
|
"Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1]
|
||||||
|
+ "')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Mono<Authentication> createSuccessfulAuthentication(ServerWebExchange exchange,
|
||||||
|
UserDetails user) {
|
||||||
|
return getKey()
|
||||||
|
.map(key -> new RememberMeAuthenticationToken(key, user,
|
||||||
|
this.authoritiesMapper.mapAuthorities(user.getAuthorities()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValidCookieTokensLength(String[] cookieTokens) {
|
||||||
|
return cookieTokens.length == 3 || cookieTokens.length == 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> loginFail(ServerWebExchange exchange) {
|
||||||
|
log.debug("Interactive login attempt was unsuccessful.");
|
||||||
|
cancelCookie(exchange);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> loginSuccess(ServerWebExchange exchange,
|
||||||
|
Authentication successfulAuthentication) {
|
||||||
|
if (!rememberMeRequested(exchange)) {
|
||||||
|
log.debug("Remember-me login not requested.");
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.defer(() -> retrieveUsernamePassword(successfulAuthentication))
|
||||||
|
.flatMap(pair -> {
|
||||||
|
var username = pair.username();
|
||||||
|
var password = pair.password();
|
||||||
|
var expiryTimeMs = calculateExpireTime(exchange, successfulAuthentication);
|
||||||
|
return makeTokenSignature(expiryTimeMs, username, password, DEFAULT_ALGORITHM)
|
||||||
|
.doOnNext(signatureValue -> {
|
||||||
|
setCookie(
|
||||||
|
new String[] {username, Long.toString(expiryTimeMs), DEFAULT_ALGORITHM,
|
||||||
|
signatureValue},
|
||||||
|
exchange);
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("Added remember-me cookie for user '{}', expiry: '{}'",
|
||||||
|
username,
|
||||||
|
new Date(expiryTimeMs));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<UsernamePassword> retrieveUsernamePassword(
|
||||||
|
Authentication successfulAuthentication) {
|
||||||
|
return Mono.defer(() -> {
|
||||||
|
String username = retrieveUserName(successfulAuthentication);
|
||||||
|
String password = retrievePassword(successfulAuthentication);
|
||||||
|
// If unable to find a username and password, just abort as
|
||||||
|
// TokenBasedRememberMeServices is
|
||||||
|
// unable to construct a valid token in this case.
|
||||||
|
if (!StringUtils.hasLength(username)) {
|
||||||
|
log.debug("Unable to retrieve username");
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasLength(password)) {
|
||||||
|
return getUserDetailsService().findByUsername(username)
|
||||||
|
.flatMap(user -> {
|
||||||
|
String existingPassword = user.getPassword();
|
||||||
|
if (!StringUtils.hasLength(existingPassword)) {
|
||||||
|
log.debug("Unable to obtain password for user: {}", username);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.just(new UsernamePassword(username, existingPassword));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Mono.just(new UsernamePassword(username, password));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCookie(String[] cookieTokens, ServerWebExchange exchange) {
|
||||||
|
String cookieValue = encodeCookie(cookieTokens);
|
||||||
|
rememberMeCookieResolver.setRememberMeCookie(exchange, cookieValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected long calculateExpireTime(ServerWebExchange exchange,
|
||||||
|
Authentication authentication) {
|
||||||
|
var tokenLifetime = rememberMeCookieResolver.getCookieMaxAge().toSeconds();
|
||||||
|
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) {
|
||||||
|
char[] padding = new char[paddingCount];
|
||||||
|
Arrays.fill(padding, '=');
|
||||||
|
cookieValue += new String(padding);
|
||||||
|
}
|
||||||
|
String cookieAsPlainText;
|
||||||
|
try {
|
||||||
|
cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes()));
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
throw new InvalidCookieException(
|
||||||
|
"Cookie token was not Base64 encoded; value was '" + cookieValue + "'");
|
||||||
|
}
|
||||||
|
String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER);
|
||||||
|
for (int i = 0; i < tokens.length; i++) {
|
||||||
|
tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse operation of decodeCookie.
|
||||||
|
*
|
||||||
|
* @param cookieTokens the tokens to be encoded.
|
||||||
|
* @return base64 encoding of the tokens concatenated with the ":" delimiter.
|
||||||
|
*/
|
||||||
|
protected String encodeCookie(String[] cookieTokens) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < cookieTokens.length; i++) {
|
||||||
|
sb.append(URLEncoder.encode(cookieTokens[i], StandardCharsets.UTF_8));
|
||||||
|
if (i < cookieTokens.length - 1) {
|
||||||
|
sb.append(DELIMITER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String value = sb.toString();
|
||||||
|
sb = new StringBuilder(new String(Base64.getEncoder().encode(value.getBytes())));
|
||||||
|
while (sb.charAt(sb.length() - 1) == '=') {
|
||||||
|
sb.deleteCharAt(sb.length() - 1);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Mono<String> makeTokenSignature(long tokenExpiryTime, String username,
|
||||||
|
String password, String algorithm) {
|
||||||
|
return getKey()
|
||||||
|
.handle((key, sink) -> {
|
||||||
|
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + key;
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||||
|
sink.next(new String(Hex.encode(digest.digest(data.getBytes()))));
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
sink.error(
|
||||||
|
new IllegalStateException("No " + algorithm + " algorithm available!"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String retrieveUserName(Authentication authentication) {
|
||||||
|
if (isInstanceOfUserDetails(authentication)) {
|
||||||
|
return ((UserDetails) authentication.getPrincipal()).getUsername();
|
||||||
|
}
|
||||||
|
return authentication.getPrincipal().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String retrievePassword(Authentication authentication) {
|
||||||
|
if (isInstanceOfUserDetails(authentication)) {
|
||||||
|
return ((UserDetails) authentication.getPrincipal()).getPassword();
|
||||||
|
}
|
||||||
|
if (authentication.getCredentials() != null) {
|
||||||
|
return authentication.getCredentials().toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInstanceOfUserDetails(Authentication authentication) {
|
||||||
|
return authentication.getPrincipal() instanceof UserDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Mono<String> getKey() {
|
||||||
|
return cookieSignatureKeyResolver.resolveSigningKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
record UsernamePassword(String username, String password) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import org.springframework.security.web.server.context.ServerSecurityContextRepo
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||||
|
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
|
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.TotpAuthenticationFilter;
|
||||||
|
|
||||||
|
@ -20,20 +21,23 @@ public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
|
||||||
|
|
||||||
private final MessageSource messageSource;
|
private final MessageSource messageSource;
|
||||||
|
|
||||||
|
private final RememberMeServices rememberMeServices;
|
||||||
|
|
||||||
public TwoFactorAuthSecurityConfigurer(
|
public TwoFactorAuthSecurityConfigurer(
|
||||||
ServerSecurityContextRepository securityContextRepository,
|
ServerSecurityContextRepository securityContextRepository,
|
||||||
TotpAuthService totpAuthService, ServerResponse.Context context,
|
TotpAuthService totpAuthService, ServerResponse.Context context,
|
||||||
MessageSource messageSource) {
|
MessageSource messageSource, RememberMeServices rememberMeServices) {
|
||||||
this.securityContextRepository = securityContextRepository;
|
this.securityContextRepository = securityContextRepository;
|
||||||
this.totpAuthService = totpAuthService;
|
this.totpAuthService = totpAuthService;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.messageSource = messageSource;
|
this.messageSource = messageSource;
|
||||||
|
this.rememberMeServices = rememberMeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configure(ServerHttpSecurity http) {
|
public void configure(ServerHttpSecurity http) {
|
||||||
var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
|
var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
|
||||||
context, messageSource);
|
context, messageSource, rememberMeServices);
|
||||||
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
|
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ import org.springframework.web.server.ServerWebExchange;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.security.authentication.login.HaloUser;
|
import run.halo.app.security.authentication.login.HaloUser;
|
||||||
import run.halo.app.security.authentication.login.UsernamePasswordHandler;
|
import run.halo.app.security.authentication.login.UsernamePasswordHandler;
|
||||||
|
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@ -27,14 +28,15 @@ public class TotpAuthenticationFilter extends AuthenticationWebFilter {
|
||||||
public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
|
public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
|
||||||
TotpAuthService totpAuthService,
|
TotpAuthService totpAuthService,
|
||||||
ServerResponse.Context context,
|
ServerResponse.Context context,
|
||||||
MessageSource messageSource) {
|
MessageSource messageSource,
|
||||||
|
RememberMeServices rememberMeServices) {
|
||||||
super(new TwoFactorAuthManager(totpAuthService));
|
super(new TwoFactorAuthManager(totpAuthService));
|
||||||
|
|
||||||
setSecurityContextRepository(securityContextRepository);
|
setSecurityContextRepository(securityContextRepository);
|
||||||
setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
|
setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
|
||||||
setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
|
setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
|
||||||
|
|
||||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
var handler = new UsernamePasswordHandler(context, messageSource, rememberMeServices);
|
||||||
setAuthenticationSuccessHandler(handler);
|
setAuthenticationSuccessHandler(handler);
|
||||||
setAuthenticationFailureHandler(handler);
|
setAuthenticationFailureHandler(handler);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
package run.halo.app.security.authentication.rememberme;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link TokenBasedRememberMeServices}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.16.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(SpringExtension.class)
|
||||||
|
class TokenBasedRememberMeServicesTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
CookieSignatureKeyResolver cookieSignatureKeyResolver;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private TokenBasedRememberMeServices tokenBasedRememberMeServices;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retrieveUserName() {
|
||||||
|
var authentication = new TestingAuthenticationToken("fake-user", "test");
|
||||||
|
var username = tokenBasedRememberMeServices.retrieveUserName(authentication);
|
||||||
|
|
||||||
|
var userDetails = new User("zhangsan", "test", List.of());
|
||||||
|
authentication = new TestingAuthenticationToken(userDetails, "test");
|
||||||
|
username = tokenBasedRememberMeServices.retrieveUserName(authentication);
|
||||||
|
assertThat(username).isEqualTo("zhangsan");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void makeTokenSignatureTest() {
|
||||||
|
when(cookieSignatureKeyResolver.resolveSigningKey()).thenReturn(Mono.just("fake-key"));
|
||||||
|
var expireMs = 1716435187323L;
|
||||||
|
tokenBasedRememberMeServices.makeTokenSignature(expireMs, "fake-user", "pwd-1",
|
||||||
|
TokenBasedRememberMeServices.DEFAULT_ALGORITHM)
|
||||||
|
.as(StepVerifier::create)
|
||||||
|
.expectNext("29f1c7ccbb489741392d27ba5c30f30d05c79ee66289b6d6da5b431bba99a0c7")
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encodeCookieTest() {
|
||||||
|
var expireMs = 1716435187323L;
|
||||||
|
var cookieTokens = new String[] {"fake-user", Long.toString(expireMs),
|
||||||
|
TokenBasedRememberMeServices.DEFAULT_ALGORITHM,
|
||||||
|
"29f1c7ccbb489741392d27ba5c30f30d05c79ee66289b6d6da5b431bba99a0c7"};
|
||||||
|
var encode = tokenBasedRememberMeServices.encodeCookie(cookieTokens);
|
||||||
|
assertThat(encode)
|
||||||
|
.isEqualTo("ZmFrZS11c2VyOjE3MTY0MzUxODczMjM6U0hBLTI1NjoyOWYxYzdjY2JiNDg5NzQxMz"
|
||||||
|
+ "kyZDI3YmE1YzMwZjMwZDA1Yzc5ZWU2NjI4OWI2ZDZkYTViNDMxYmJhOTlhMGM3");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodeCookieTest() {
|
||||||
|
var cookieValue = "YWRtaW46MTcxODk2NDE3NDgwODpTSEE"
|
||||||
|
+ "tMjU2OmNkOTM0ZTAyZWQ4NGJmMzc1ZTA4MmE1OWU4YTA3NTNiMzA3ODg1MjZmYzA3Yjgy"
|
||||||
|
+ "YzVmY2Y3YmJiYzdjYzRkNWU";
|
||||||
|
// 123 % 4 = 3, so we need to add 1 '=' to make it a multiple of 4 for
|
||||||
|
// spring-security/gh-15127
|
||||||
|
assertThat(cookieValue.length()).isEqualTo(123);
|
||||||
|
var cookie = tokenBasedRememberMeServices.decodeCookie(cookieValue);
|
||||||
|
assertThat(cookie).containsExactly("admin", "1718964174808", "SHA-256",
|
||||||
|
"cd934e02ed84bf375e082a59e8a0753b30788526fc07b82c5fcf7bbbc7cc4d5e");
|
||||||
|
|
||||||
|
cookieValue = "ZmFrZS11c2VyOjE3MTY0MzUxODczMjM6U0hBLTI1NjoyOWYxYzdjY2JiNDg5NzQxMz"
|
||||||
|
+ "kyZDI3YmE1YzMwZjMwZDA1Yzc5ZWU2NjI4OWI2ZDZkYTViNDMxYmJhOTlhMGM3";
|
||||||
|
assertThat(cookieValue.length()).isEqualTo(128);
|
||||||
|
cookie = tokenBasedRememberMeServices.decodeCookie(cookieValue);
|
||||||
|
assertThat(cookie).containsExactly("fake-user", "1716435187323", "SHA-256",
|
||||||
|
"29f1c7ccbb489741392d27ba5c30f30d05c79ee66289b6d6da5b431bba99a0c7");
|
||||||
|
}
|
||||||
|
}
|
|
@ -53,6 +53,10 @@ const handleLogout = () => {
|
||||||
|
|
||||||
await userStore.fetchCurrentUser();
|
await userStore.fetchCurrentUser();
|
||||||
|
|
||||||
|
// Clear csrf token
|
||||||
|
document.cookie =
|
||||||
|
"XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
|
||||||
|
|
||||||
router.replace({ name: "Login" });
|
router.replace({ name: "Login" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to logout", error);
|
console.error("Failed to logout", error);
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { AxiosError } from "axios";
|
||||||
import { Toast, VButton } from "@halo-dev/components";
|
import { Toast, VButton } from "@halo-dev/components";
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
import { submitForm } from "@formkit/core";
|
import { submitForm, reset } from "@formkit/core";
|
||||||
import { JSEncrypt } from "jsencrypt";
|
import { JSEncrypt } from "jsencrypt";
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
@ -29,29 +29,25 @@ const emit = defineEmits<{
|
||||||
(event: "succeed"): void;
|
(event: "succeed"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
interface LoginForm {
|
|
||||||
_csrf: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const loginForm = ref<LoginForm>({
|
const _csrf = ref("");
|
||||||
_csrf: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const handleGenerateToken = async () => {
|
const handleGenerateToken = async () => {
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
loginForm.value._csrf = token;
|
_csrf.value = token;
|
||||||
document.cookie = `XSRF-TOKEN=${token}; Path=/;`;
|
const expires = new Date();
|
||||||
|
expires.setFullYear(expires.getFullYear() + 1);
|
||||||
|
document.cookie = `XSRF-TOKEN=${token}; Path=/; SameSite=Lax; expires=${expires.toUTCString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
async function handleLogin(data: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe: boolean;
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
|
@ -61,10 +57,11 @@ const handleLogin = async () => {
|
||||||
encrypt.setPublicKey(publicKey.base64Format as string);
|
encrypt.setPublicKey(publicKey.base64Format as string);
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${import.meta.env.VITE_API_URL}/login`,
|
`${import.meta.env.VITE_API_URL}/login?remember-me=${data.rememberMe}`,
|
||||||
qs.stringify({
|
qs.stringify({
|
||||||
...loginForm.value,
|
_csrf: _csrf.value,
|
||||||
password: encrypt.encrypt(loginForm.value.password),
|
username: data.username,
|
||||||
|
password: encrypt.encrypt(data.password),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
@ -115,12 +112,12 @@ const handleLogin = async () => {
|
||||||
Toast.error(t("core.common.toast.unknown_error"));
|
Toast.error(t("core.common.toast.unknown_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
loginForm.value.password = "";
|
reset("passwordInput");
|
||||||
setFocus("passwordInput");
|
setFocus("passwordInput");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
handleGenerateToken();
|
handleGenerateToken();
|
||||||
|
@ -138,7 +135,6 @@ const mfaRequired = ref(false);
|
||||||
<template v-if="!mfaRequired">
|
<template v-if="!mfaRequired">
|
||||||
<FormKit
|
<FormKit
|
||||||
id="login-form"
|
id="login-form"
|
||||||
v-model="loginForm"
|
|
||||||
name="login-form"
|
name="login-form"
|
||||||
:actions="false"
|
:actions="false"
|
||||||
type="form"
|
type="form"
|
||||||
|
@ -170,9 +166,17 @@ const mfaRequired = ref(false);
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
>
|
>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="checkbox"
|
||||||
|
:label="$t('core.login.fields.remember_me.label')"
|
||||||
|
name="rememberMe"
|
||||||
|
:value="false"
|
||||||
|
:classes="inputClasses"
|
||||||
|
></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
<VButton
|
<VButton
|
||||||
class="mt-8"
|
class="mt-6"
|
||||||
block
|
block
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|
|
@ -6,6 +6,8 @@ core:
|
||||||
placeholder: Username
|
placeholder: Username
|
||||||
password:
|
password:
|
||||||
placeholder: Password
|
placeholder: Password
|
||||||
|
remember_me:
|
||||||
|
label: Remember Me
|
||||||
operations:
|
operations:
|
||||||
submit:
|
submit:
|
||||||
toast_success: Login successful
|
toast_success: Login successful
|
||||||
|
|
|
@ -6,6 +6,8 @@ core:
|
||||||
placeholder: 用户名
|
placeholder: 用户名
|
||||||
password:
|
password:
|
||||||
placeholder: 密码
|
placeholder: 密码
|
||||||
|
remember_me:
|
||||||
|
label: 保持登录会话
|
||||||
operations:
|
operations:
|
||||||
submit:
|
submit:
|
||||||
toast_success: 登录成功
|
toast_success: 登录成功
|
||||||
|
|
|
@ -6,6 +6,8 @@ core:
|
||||||
placeholder: 用戶名
|
placeholder: 用戶名
|
||||||
password:
|
password:
|
||||||
placeholder: 密碼
|
placeholder: 密碼
|
||||||
|
remember_me:
|
||||||
|
label: 保持登入會話
|
||||||
operations:
|
operations:
|
||||||
submit:
|
submit:
|
||||||
toast_success: 登入成功
|
toast_success: 登入成功
|
||||||
|
|
|
@ -49,6 +49,10 @@ const handleLogout = () => {
|
||||||
|
|
||||||
await userStore.fetchCurrentUser();
|
await userStore.fetchCurrentUser();
|
||||||
|
|
||||||
|
// Clear csrf token
|
||||||
|
document.cookie =
|
||||||
|
"XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
|
||||||
|
|
||||||
window.location.href = "/console/login";
|
window.location.href = "/console/login";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to logout", error);
|
console.error("Failed to logout", error);
|
||||||
|
|
Loading…
Reference in New Issue