記住我功能的基本原理
當使用者登入發起認證請求時,會通過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());
}
}
複製程式碼
個人部落格:woodwhale's blog
部落格園:木鯨魚的部落格