記住我功能的基本原理
當用戶登錄發起認證請求時,會通過UsernamePasswordAuthenticationFilter進行用戶認證,認證成功之后,SpringSecurity 調用前期配置好的記住我功能,實際是調用了RememberMeService接口,其接口的實現類會將用戶的信息生成Token並將它寫入 response 的Cookie中,在寫入的同時,內部的TokenRepositoryTokenRepository會將這份Token再存入數據庫一份。
當用戶再次訪問服務器資源的時候,首先會經過RememberMeAuthenticationFiler過濾器,在這個過濾器里面會讀取當前請求中攜帶的 Cookie,這里存着上次服務器保存 的Token,然后去數據庫中查找是否有相應的 Token,如果有,則再通過UserDetailsService獲取用戶的信息。

記住我功能的過濾器
從圖中可以得知記住我的過濾器在過濾鏈的中部,注意是在UsernamePasswordAuthenticationFilter之后。

前端頁面checkbox設置
在 html 中增加記住我復選框checkbox控件,注意其中復選框的name 一定必須為remember-me
<input type="checkbox" name="remember-me" value="true"/>
配置cookie存儲數據庫源
本例中使用了 springboot 管理的數據庫源,所以注意要配置spring-boot-starter-jdbc的依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
如果不配置會報編譯異常:
The type org.springframework.jdbc.core.support.JdbcDaoSupport cannot be resolved. It is indirectly referenced from required .class files
記住我的安全認證配置:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 將自定義的驗證碼過濾器放置在 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/login") // 設置登錄頁面
.loginProcessingUrl("/user/login") // 自定義的登錄接口
.successHandler(myAuthenctiationSuccessHandler)
.failureHandler(myAuthenctiationFailureHandler)
.defaultSuccessUrl("/home").permitAll() // 登錄成功之后,默認跳轉的頁面
.and().authorizeRequests() // 定義哪些URL需要被保護、哪些不需要被保護
.antMatchers("/", "/index", "/user/login", "/code/image").permitAll() // 設置所有人都可以訪問登錄頁面
.anyRequest().authenticated() // 任何請求,登錄后可以訪問
.and().csrf().disable() // 關閉csrf防護
.rememberMe() // 記住我配置
.tokenRepository(persistentTokenRepository()) // 配置數據庫源
.tokenValiditySeconds(3600)
.userDetailsService(userDetailsService);
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
// 將 DataSource 設置到 PersistentTokenRepository
persistentTokenRepository.setDataSource(dataSource);
// 第一次啟動的時候自動建表(可以不用這句話,自己手動建表,源碼中有語句的)
// persistentTokenRepository.setCreateTableOnStartup(true);
return persistentTokenRepository;
}
}
注意:在數據庫源配置之前,建議手動在數據庫中新增一張保存的cookie表,其數據庫腳本在JdbcTokenRepositoryImpl的靜態屬性中配置了:
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
PersistentTokenRepository {
/** Default SQL for creating the database table to store the tokens */
public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
+ "token varchar(64) not null, last_used timestamp not null)";
}
因此可以事先執行以下sql 腳本創建表:
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null);
當然,JdbcTokenRepositoryImpl自身還有一個setCreateTableOnStartup()方法進行開啟自動建表操作,但是不建議使用。
當成功登錄之后,RememberMeService會將成功登錄請求的cookie存儲到配置的數據庫中:

源碼分析
首次請求
首先進入到AbstractAuthenticationProcessingFilter過濾器中的doFilter()方法:
public abstract class AbstractAuthenticationProcessingFilter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
……
try {
authResult = attemptAuthentication(request, response);
……
}
catch (InternalAuthenticationServiceException failed) {
……
}
successfulAuthentication(request, response, chain, authResult);
}
}
其中當用戶認證成功之后,會進入successfulAuthentication()方法,在用戶信息被保存在了SecurityContextHolder之后,其中就調用了rememberMeServices.loginSuccess():
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
……
SecurityContextHolder.getContext().setAuthentication(authResult);
// 調用記住我服務接口的登錄成功方法
rememberMeServices.loginSuccess(request, response, authResult);
// Fire event
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
successHandler.onAuthenticationSuccess(request, response, authResult);
}
在這個RememberMeServices有個抽象實現類,在抽象實現類loginSuccess()方法中進行了記住我功能判斷,為什么前端的復選框控件的 name 必須為remember-me,原因就在此:
public abstract class AbstractRememberMeServices implements RememberMeServices,
InitializingBean, LogoutHandler {
public static final String DEFAULT_PARAMETER = "remember-me";
private String parameter = DEFAULT_PARAMETER;
@Override
public final void loginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
}
當識別到記住我功能開啟的時候,就會進入onLoginSuccess()方法,其具體的方法實現在PersistentTokenBasedRememberMeServices類中:
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
logger.debug("Creating new persistent login for user " + username);
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
// 保存cookie到數據庫
tokenRepository.createNewToken(persistentToken);
// 將cookie回寫一份到響應中
addCookie(persistentToken, request, response);
}
catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}
}
上面的tokenRepository.createNewToken()和addCookie()就將 cookie 保存到數據庫並回顯到響應中。
第二次請求
當第二次請求傳到服務器的時候,請求會被RememberMeAuthenticationFilter過濾器進行過濾:過濾器首先判定之前的過濾器都沒有認證通過當前用戶,也就是SecurityContextHolder中沒有已經認證的信息,所以會調用rememberMeServices.autoLogin()的自動登錄接口拿到已通過認證的rememberMeAuth進行用戶認證登錄:
public class RememberMeAuthenticationFilter extends GenericFilterBean implements
ApplicationEventPublisherAware {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// SecurityContextHolder 不存在已經認證的 authentication,表示前面的過濾器沒有做過任何身份認證
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// 調用自動登錄接口
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
// Store to SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
onSuccessfulAuthentication(request, response, rememberMeAuth);
……
}
catch (AuthenticationException authenticationException) {
……
}
}
chain.doFilter(request, response);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'");
}
chain.doFilter(request, response);
}
}
}
這個自動登錄的接口,又由其抽象實現類進行實現:
public abstract class AbstractRememberMeServices implements RememberMeServices,
InitializingBean, LogoutHandler {
@Override
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
// 從請求中獲取cookie
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
// 解碼請求中的cookie
String[] cookieTokens = decodeCookie(rememberMeCookie);
// 根據 cookie 找到用戶認證
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException cte) {
……
}
cancelCookie(request, response);
return null;
}
}
processAutoLoginCookie()的具體實現還是由PersistentTokenBasedRememberMeServices來實現,總得來說就是一頓判定當前的cookieTokens是不是在數據庫中存在tokenRepository.getTokenForSeries(presentedSeries),並判斷是不是一樣的,如果一樣,就是把當前請求的新 token 更新保存到數據庫,最后通過當前請求token中的用戶名調用UserDetailsService.loadUserByUsername()進行用戶認證。
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
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) + "'");
}
final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];
// 從數據庫查詢上次保存的token
PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
// 查詢不到拋異常
throw new RememberMeAuthenticationException(……);
}
// token 不匹配拋出異常
// We have a match for this user/series combination
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(……);
}
// 過期判斷
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), generateTokenData(), new Date());
try {
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception e) {
……
}
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
