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

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

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

View File

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

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

View File

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

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

View File

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

View File

@ -6,6 +6,8 @@ core:
placeholder: Username
password:
placeholder: Password
remember_me:
label: Remember Me
operations:
submit:
toast_success: Login successful

View File

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

View File

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

View File

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