前言
- SpringSecurity支持记住我登录,点击下面的复选框即可完成记住我认证

1. RememberMeConfigurer
- RememberMeConfigurer作为RememberMeAuthenticationFilter的过滤器
- 在SpringSecurity中默认不会填充,通过以下代码开启基础配置
@Override
protected void configure(HttpSecurity http) throws Exception {
...
http.rememberMe();
...
}
1.1 rememberMeServices(...)
- RememberMeServices(...):填充RememberMeServices
public RememberMeConfigurer<H> rememberMeServices(RememberMeServices rememberMeServices) {
this.rememberMeServices = rememberMeServices;
return this;
}
- RememberMeServices:SpringSecurity中的负责认证的过滤器将调用其实现类来完成记住我机制
public interface RememberMeServices {
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
void loginFail(HttpServletRequest request, HttpServletResponse response);
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication);
}
- 其中有两个重要的实现
- TokenBasedRememberMeServices:不依赖于外部数据库
- PersistentTokenBasedRememberMeServices:支持持久化(数据库)
- 最后再介绍这两个类
1.2 tokenRepository(...)
- tokenRepository(...):使用持久化方式来保持记住我令牌
- 这个只有在PersistentTokenBasedRememberMeServices中会用到
public RememberMeConfigurer<H> tokenRepository(PersistentTokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
return this;
}
- 然后再看PersistentTokenRepository的源码:很明显是一个支持增删改查的类
public interface PersistentTokenRepository {
void createNewToken(PersistentRememberMeToken token);
void updateToken(String series, String tokenValue, Date lastUsed);
PersistentRememberMeToken getTokenForSeries(String seriesId);
void removeUserTokens(String username);
}
- 我们就看他的一个基于内存的实现类:
- JdbcTokenRepositoryImpl:基于JDBC的持久登录令牌
- InMemoryTokenRepositoryImpl:由Map支持的简单PersistentTokenRepository实现。仅用于测试
- 这两个类也是最后再来介绍
1.3 init(...)
- init(...):这里都是填充记住我机制的必要参数
- 但是这里为认证管理器注册了一个新的类:RememberMeAuthenticationProvider
- 因为记住我认证对象和用 用户名密码认证的对象不一样,需要特殊的认证提供者
public void init(H http) throws Exception {
validateInput();
String key = getKey();
RememberMeServices rememberMeServices = getRememberMeServices(http, key);
http.setSharedObject(RememberMeServices.class, rememberMeServices);
LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
if (logoutConfigurer != null && this.logoutHandler != null) {
logoutConfigurer.addLogoutHandler(this.logoutHandler);
}
RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(key);
authenticationProvider = postProcess(authenticationProvider);
http.authenticationProvider(authenticationProvider);
initDefaultLoginFilter(http);
}
1.3.1 RememberMeAuthenticationProvider
- RememberMeAuthenticationProvider是SpringSecurity其中的一种认证方式
- 记住我的认证规则很简单,只比较了秘钥
public class RememberMeAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!supports(authentication.getClass())) {
return null;
}
if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) {
throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey",
"The presented RememberMeAuthenticationToken does not contain the expected key"));
}
return authentication;
}
...
}
1.4 configure(...)
public void configure(H http) {
RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter(
http.getSharedObject(AuthenticationManager.class), this.rememberMeServices);
if (this.authenticationSuccessHandler != null) {
rememberMeFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
}
rememberMeFilter = postProcess(rememberMeFilter);
http.addFilter(rememberMeFilter);
}
2. RememberMeAuthenticationFilter
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
try {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
onSuccessfulAuthentication(request, response, rememberMeAuth);
this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
}
catch (AuthenticationException ex) {
this.logger.debug(LogMessage
.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
+ "rejected Authentication returned by RememberMeServices: '%s'; "
+ "invalidating remember-me token", rememberMeAuth),
ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
}
}
chain.doFilter(request, response);
}
- 大部分代码我已经加入了注释,并且都是以前介绍过的类,这里唯一的陌生代码就是第十四行
- 这里是通过RememberMeServices获取认证对象,我们就看看他的两个实现是怎么操作的
- TokenBasedRememberMeServices
- PersistentTokenBasedRememberMeServices
3. RememberMeServices
3.1 TokenBasedRememberMeServices
- TokenBasedRememberMeServices和PersistentTokenBasedRememberMeServices都有相同的父类:AbstractRememberMeServices

- 我们接下来就看看RememberMeServices中的三大方法在TokenBasedRememberMeServices中是如何实现的
3.1.1 loginSuccess(...)
- 此方法是当认证完成后才会被调用,下面是他的调用情况
- BasicAuthenticationFilter.doFilterInternal(...)

- UsernamePasswordAuthenticationFilter.successfulAuthentication(...)

- 接下来我们直接看其源码
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request,
response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(
"Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
}
}
- 别看源码很长,其实重点就在于如何生成的令牌,我们看makeTokenSignature(...)方法
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No MD5 algorithm available!");
}
}
- 很明显就是通过 (过期时间 + 用户名 + 密码 + key) 再通过MD5加密为签名
- 最后将用户名、过期时间、签名通过Base64加密保存到Cookie中
3.1.2 autoLogin(...)
- autoLogin(...)是在通过记住我认证后SecurityContext中没有认证对象才会被调用的方法
- 两个实现类并没有重写核心方法autoLogin(...),而是在其父类中有代码
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException ex) {
cancelCookie(request, response);
throw ex;
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
}
catch (InvalidCookieException ex) {
this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
}
catch (AccountStatusException ex) {
this.logger.debug("Invalid UserDetails: " + ex.getMessage());
}
catch (RememberMeAuthenticationException ex) {
this.logger.debug(ex.getMessage());
}
cancelCookie(request, response);
return null;
}
- autoLogin(...)方法的核心就在于通过processAutoLoginCookie(...)获取了UserDetails,而这个方法两个实现类都重写了
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
if (cookieTokens.length != 3) {
throw new InvalidCookieException(
"Cookie token did not contain 3" + " 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() + "')");
}
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
+ " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
- 分析源码就能得出结论:
- 由于记住我令牌最外面一层是通过Base64加密的所以说可以直接进行解密,变成下面的样子

- 然后判断是否过期
- 未过期就通过用户名获取用户对象
- 在用 用户名 + 密码 + 过期时间 + key 重新生成签名
- 只有Cookie中的令牌和重新生成的一样才认为此令牌有效
3.1.3 loginFail(...)
- 此方法是退出登录才会被调用的,其代码很简单,就是清除记住我令牌而已
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Interactive login attempt was unsuccessful.");
cancelCookie(request, response);
onLoginFail(request, response);
}
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Cancelling cookie");
Cookie cookie = new Cookie(this.cookieName, null);
cookie.setMaxAge(0);
cookie.setPath(getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}
cookie.setSecure((this.useSecureCookie != null) ? this.useSecureCookie : request.isSecure());
response.addCookie(cookie);
}
3.2 PersistentTokenBasedRememberMeServices
- 与TokenBasedRememberMeServices不一样,此类支持持久化,并且令牌的生成方式不一样
3.2.1 loginSuccess(...)
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
generateTokenData(), new Date());
try {
this.tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to save persistent token ", ex);
}
}
- 这里生成的令牌就和TokenBasedRememberMeServices不一样了
- 其中的series和tokenValue都是随机数
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date;
}
- 这里保存令牌是通过PersistentTokenRepository的实现类,我们就看个简单的例子
public class InMemoryTokenRepositoryImpl implements PersistentTokenRepository {
private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();
@Override
public synchronized void createNewToken(PersistentRememberMeToken token) {
PersistentRememberMeToken current = this.seriesTokens.get(token.getSeries());
if (current != null) {
throw new DataIntegrityViolationException("Series Id '" + token.getSeries() + "' already exists!");
}
this.seriesTokens.put(token.getSeries(), token);
}
@Override
public synchronized void updateToken(String series, String tokenValue, Date lastUsed) {
PersistentRememberMeToken token = getTokenForSeries(series);
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), series, tokenValue,
new Date());
this.seriesTokens.put(series, newToken);
}
...
}
- 这里保存和更新都是以series作为键,这里是重点
3.2.2 processAutoLoginCookie(...)
- 此方法的重点就在于一个令牌的Series是固定的,而TokenValue是会随着请求不断的更新的,一旦发现TokenValue值不对就说明此令牌已经发生了泄露
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
+ Arrays.asList(cookieTokens) + "'");
}
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}
if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'",
token.getUsername(), token.getSeries()));
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to update token: ", ex);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
3.2.3 loginFail(...)
- 登出的时候就和TokenBasedRememberMeServices一样了
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
super.logout(request, response, authentication);
if (authentication != null) {
this.tokenRepository.removeUserTokens(authentication.getName());
}
}
4. 总结
- 最后总结下记住我的认证逻辑:
- 以其他表单认证或者基本认证等认证方式进行认证后会通过RememberMeServices创建记住我令牌,并添加在Cookie中
- 等过一段时间后HttpSession过期了,SecurityContextRepository中的认证对象为空了
- 此时就来到了RememberMeAuthenticationFilter,解析出记住我令牌
- 通过RememberMeServices校验记住我令牌,然后进行认证
- 认证完成后创建记住我认证对象,并将其放入SecurityContext中