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
guqing 2024-05-24 14:20:50 +08:00 committed by GitHub
parent 69c3a63618
commit 9ec608be3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 810 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package run.halo.app.security.authentication.rememberme;
import reactor.core.publisher.Mono;
@FunctionalInterface
public interface CookieSignatureKeyResolver {
Mono<String> resolveSigningKey();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,8 @@ core:
placeholder: 用户名 placeholder: 用户名
password: password:
placeholder: 密码 placeholder: 密码
remember_me:
label: 保持登录会话
operations: operations:
submit: submit:
toast_success: 登录成功 toast_success: 登录成功

View File

@ -6,6 +6,8 @@ core:
placeholder: 用戶名 placeholder: 用戶名
password: password:
placeholder: 密碼 placeholder: 密碼
remember_me:
label: 保持登入會話
operations: operations:
submit: submit:
toast_success: 登入成功 toast_success: 登入成功

View File

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