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 java.time.Duration;
|
||||
import lombok.Data;
|
||||
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
|
||||
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
|
||||
|
@ -13,6 +14,8 @@ public class SecurityProperties {
|
|||
|
||||
private final ReferrerOptions referrerOptions = new ReferrerOptions();
|
||||
|
||||
private final RememberMeOptions rememberMe = new RememberMeOptions();
|
||||
|
||||
@Data
|
||||
public static class FrameOptions {
|
||||
|
||||
|
@ -27,4 +30,9 @@ public class SecurityProperties {
|
|||
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 java.net.URI;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
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 reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
||||
private final RememberMeServices rememberMeServices;
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
|
@ -25,7 +29,7 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
|||
http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING);
|
||||
}
|
||||
|
||||
public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
|
||||
private class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
|
||||
|
||||
private final ServerLogoutSuccessHandler defaultHandler;
|
||||
|
||||
|
@ -38,15 +42,18 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
|||
@Override
|
||||
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange,
|
||||
Authentication authentication) {
|
||||
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange())
|
||||
.flatMap(matchResult -> {
|
||||
if (matchResult.isMatch()) {
|
||||
var response = exchange.getExchange().getResponse();
|
||||
response.setStatusCode(HttpStatus.NO_CONTENT);
|
||||
return response.setComplete();
|
||||
}
|
||||
return defaultHandler.onLogoutSuccess(exchange, authentication);
|
||||
});
|
||||
return rememberMeServices.loginFail(exchange.getExchange())
|
||||
.then(ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
|
||||
.matches(exchange.getExchange())
|
||||
.flatMap(matchResult -> {
|
||||
if (matchResult.isMatch()) {
|
||||
var response = exchange.getExchange().getResponse();
|
||||
response.setStatusCode(HttpStatus.NO_CONTENT);
|
||||
return response.setComplete();
|
||||
}
|
||||
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.security.authentication.CryptoService;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||
|
||||
@Component
|
||||
public class LoginSecurityConfigurer implements SecurityConfigurer {
|
||||
|
@ -41,12 +42,15 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
|
|||
private final MessageSource messageSource;
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
|
||||
private final RememberMeServices rememberMeServices;
|
||||
|
||||
public LoginSecurityConfigurer(ObservationRegistry observationRegistry,
|
||||
ReactiveUserDetailsService userDetailsService,
|
||||
ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder,
|
||||
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
|
||||
ExtensionGetter extensionGetter, ServerResponse.Context context,
|
||||
MessageSource messageSource, RateLimiterRegistry rateLimiterRegistry) {
|
||||
MessageSource messageSource, RateLimiterRegistry rateLimiterRegistry,
|
||||
RememberMeServices rememberMeServices) {
|
||||
this.observationRegistry = observationRegistry;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.passwordService = passwordService;
|
||||
|
@ -57,13 +61,14 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
|
|||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||
this.rememberMeServices = rememberMeServices;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
var filter = new AuthenticationWebFilter(authenticationManager());
|
||||
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);
|
||||
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
||||
filter.setAuthenticationFailureHandler(handler);
|
||||
|
|
|
@ -20,6 +20,7 @@ import org.springframework.web.ErrorResponse;
|
|||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Slf4j
|
||||
|
@ -30,30 +31,35 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
|||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final RememberMeServices rememberMeServices;
|
||||
|
||||
private final ServerAuthenticationFailureHandler defaultFailureHandler =
|
||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||
|
||||
private final ServerAuthenticationSuccessHandler defaultSuccessHandler =
|
||||
new RedirectServerAuthenticationSuccessHandler("/console/");
|
||||
|
||||
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource) {
|
||||
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource,
|
||||
RememberMeServices rememberMeServices) {
|
||||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
this.rememberMeServices = rememberMeServices;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||
AuthenticationException exception) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(
|
||||
defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||
// Skip the handleAuthenticationException.
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange));
|
||||
return rememberMeServices.loginFail(exchange)
|
||||
.then(ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(
|
||||
defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||
// Skip the handleAuthenticationException.
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -61,7 +67,8 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
|||
Authentication authentication) {
|
||||
if (authentication instanceof TwoFactorAuthentication) {
|
||||
// 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 -> {
|
||||
|
@ -73,20 +80,21 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
|
|||
};
|
||||
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return xhrMatcher.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange,
|
||||
authentication)
|
||||
.then(Mono.empty())))
|
||||
.flatMap(isXhr -> {
|
||||
if (authentication instanceof CredentialsContainer container) {
|
||||
container.eraseCredentials();
|
||||
}
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(authentication.getPrincipal())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
});
|
||||
return rememberMeServices.loginSuccess(exchange, authentication)
|
||||
.then(xhrMatcher.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange,
|
||||
authentication)
|
||||
.then(Mono.empty())))
|
||||
.flatMap(isXhr -> {
|
||||
if (authentication instanceof CredentialsContainer container) {
|
||||
container.eraseCredentials();
|
||||
}
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(authentication.getPrincipal())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
}));
|
||||
}
|
||||
|
||||
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.web.reactive.function.server.ServerResponse;
|
||||
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.TotpAuthenticationFilter;
|
||||
|
||||
|
@ -20,20 +21,23 @@ public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
|
|||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final RememberMeServices rememberMeServices;
|
||||
|
||||
public TwoFactorAuthSecurityConfigurer(
|
||||
ServerSecurityContextRepository securityContextRepository,
|
||||
TotpAuthService totpAuthService, ServerResponse.Context context,
|
||||
MessageSource messageSource) {
|
||||
MessageSource messageSource, RememberMeServices rememberMeServices) {
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
this.totpAuthService = totpAuthService;
|
||||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
this.rememberMeServices = rememberMeServices;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
|
||||
context, messageSource);
|
||||
context, messageSource, rememberMeServices);
|
||||
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import org.springframework.web.server.ServerWebExchange;
|
|||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.authentication.login.HaloUser;
|
||||
import run.halo.app.security.authentication.login.UsernamePasswordHandler;
|
||||
import run.halo.app.security.authentication.rememberme.RememberMeServices;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Slf4j
|
||||
|
@ -27,14 +28,15 @@ public class TotpAuthenticationFilter extends AuthenticationWebFilter {
|
|||
public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
|
||||
TotpAuthService totpAuthService,
|
||||
ServerResponse.Context context,
|
||||
MessageSource messageSource) {
|
||||
MessageSource messageSource,
|
||||
RememberMeServices rememberMeServices) {
|
||||
super(new TwoFactorAuthManager(totpAuthService));
|
||||
|
||||
setSecurityContextRepository(securityContextRepository);
|
||||
setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
|
||||
setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
|
||||
|
||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
||||
var handler = new UsernamePasswordHandler(context, messageSource, rememberMeServices);
|
||||
setAuthenticationSuccessHandler(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();
|
||||
|
||||
// Clear csrf token
|
||||
document.cookie =
|
||||
"XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
|
||||
|
||||
router.replace({ name: "Login" });
|
||||
} catch (error) {
|
||||
console.error("Failed to logout", error);
|
||||
|
|
|
@ -7,7 +7,7 @@ import { AxiosError } from "axios";
|
|||
import { Toast, VButton } from "@halo-dev/components";
|
||||
import { onMounted, ref } from "vue";
|
||||
import qs from "qs";
|
||||
import { submitForm } from "@formkit/core";
|
||||
import { submitForm, reset } from "@formkit/core";
|
||||
import { JSEncrypt } from "jsencrypt";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
@ -29,29 +29,25 @@ const emit = defineEmits<{
|
|||
(event: "succeed"): void;
|
||||
}>();
|
||||
|
||||
interface LoginForm {
|
||||
_csrf: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const loginForm = ref<LoginForm>({
|
||||
_csrf: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const _csrf = ref("");
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const handleGenerateToken = async () => {
|
||||
const token = randomUUID();
|
||||
loginForm.value._csrf = token;
|
||||
document.cookie = `XSRF-TOKEN=${token}; Path=/;`;
|
||||
_csrf.value = token;
|
||||
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 {
|
||||
loading.value = true;
|
||||
|
||||
|
@ -61,10 +57,11 @@ const handleLogin = async () => {
|
|||
encrypt.setPublicKey(publicKey.base64Format as string);
|
||||
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_URL}/login`,
|
||||
`${import.meta.env.VITE_API_URL}/login?remember-me=${data.rememberMe}`,
|
||||
qs.stringify({
|
||||
...loginForm.value,
|
||||
password: encrypt.encrypt(loginForm.value.password),
|
||||
_csrf: _csrf.value,
|
||||
username: data.username,
|
||||
password: encrypt.encrypt(data.password),
|
||||
}),
|
||||
{
|
||||
withCredentials: true,
|
||||
|
@ -115,12 +112,12 @@ const handleLogin = async () => {
|
|||
Toast.error(t("core.common.toast.unknown_error"));
|
||||
}
|
||||
|
||||
loginForm.value.password = "";
|
||||
reset("passwordInput");
|
||||
setFocus("passwordInput");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleGenerateToken();
|
||||
|
@ -138,7 +135,6 @@ const mfaRequired = ref(false);
|
|||
<template v-if="!mfaRequired">
|
||||
<FormKit
|
||||
id="login-form"
|
||||
v-model="loginForm"
|
||||
name="login-form"
|
||||
:actions="false"
|
||||
type="form"
|
||||
|
@ -170,9 +166,17 @@ const mfaRequired = ref(false);
|
|||
autocomplete="current-password"
|
||||
>
|
||||
</FormKit>
|
||||
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
:label="$t('core.login.fields.remember_me.label')"
|
||||
name="rememberMe"
|
||||
:value="false"
|
||||
:classes="inputClasses"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
class="mt-8"
|
||||
class="mt-6"
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
|
|
|
@ -6,6 +6,8 @@ core:
|
|||
placeholder: Username
|
||||
password:
|
||||
placeholder: Password
|
||||
remember_me:
|
||||
label: Remember Me
|
||||
operations:
|
||||
submit:
|
||||
toast_success: Login successful
|
||||
|
|
|
@ -6,6 +6,8 @@ core:
|
|||
placeholder: 用户名
|
||||
password:
|
||||
placeholder: 密码
|
||||
remember_me:
|
||||
label: 保持登录会话
|
||||
operations:
|
||||
submit:
|
||||
toast_success: 登录成功
|
||||
|
|
|
@ -6,6 +6,8 @@ core:
|
|||
placeholder: 用戶名
|
||||
password:
|
||||
placeholder: 密碼
|
||||
remember_me:
|
||||
label: 保持登入會話
|
||||
operations:
|
||||
submit:
|
||||
toast_success: 登入成功
|
||||
|
|
|
@ -49,6 +49,10 @@ const handleLogout = () => {
|
|||
|
||||
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";
|
||||
} catch (error) {
|
||||
console.error("Failed to logout", error);
|
||||
|
|
Loading…
Reference in New Issue