本文在前文 Spring Security 入門(二):圖形驗證碼和手機短信驗證碼 的基礎上介紹 Remember-Me 功能和注銷登錄。
Remember-Me 功能
在實際開發中,為了用戶登錄方便常常會啟用記住我(Remember-Me)功能。如果用戶登錄時勾選了“記住我”選項,那么在一段有效時間內,會默認自動登錄,免去再次輸入用戶名、密碼等登錄操作。該功能的實現機理是根據用戶登錄信息生成 Token 並保存在用戶瀏覽器的 Cookie 中,當用戶需要再次登錄時,自動實現校驗並建立登錄態的一種機制。
Spring Security
提供了兩種 Remember-Me 的實現方式:
- 簡單加密 Token:用散列算法加密用戶必要的登錄系信息並生成 Token 令牌。
- 持久化 Token:數據庫等持久性數據存儲機制用的持久化 Token 令牌。
基本原理
Remember-Me 功能的開啟需要在configure(HttpSecurity http)
方法中通過http.rememberMe()
配置,該配置主要會在過濾器鏈中添加 RememberMeAuthenticationFilter 過濾器,通過該過濾器實現自動登錄。該過濾器的位置在其它認證過濾器之后,其它認證過濾器沒有進行認證處理時,該過濾器嘗試工作:

注意:Remember-Me 功能是用於再次登錄(認證)的,而不是再次請求。工作流程如下:
- 當用戶成功登錄認證后,瀏覽器中存在兩個 Cookie,一個是 remember-me,另一個是 JSESSIONID。用戶再次請求訪問時,請求首先被 SecurityContextPersistenceFilter 過濾器攔截,該過濾器會根據 JSESSIONID 獲取對應 Session 中存儲的 SecurityContext 對象。如果獲取到的 SecurityContext 對象中存儲了認證用戶信息對象 Authentiacaion,也就是說線程可以直接獲得認證用戶信息,那么后續的認證過濾器不需要對該請求進行攔截,remember-me 不起作用。
- 當 JSESSIONID 過期后,瀏覽器中只存在 remember-me 的 Cookie。用戶再次請求訪問時,由於請求沒有攜帶 JSESSIONID,SecurityContextPersistenceFilter 過濾器無法獲取 Session 中的 SecurityContext 對象,也就沒法獲得認證用戶信息,后續需要進行登錄認證。如果沒有 remember-me 的 Cookie,瀏覽器會重定向到登錄頁面進行表單登錄認證;但是 remember-me 的 Cookie 存在,RememberMeAuthenticationFilter 過濾器會將請求進行攔截,根據 remember-me 存儲的 Token 值實現自動登錄,並將成功登錄后的認證用戶信息對象 Authentiacaion 存儲到 SecurityContext 中。當響應返回時,SecurityContextPersistenceFilter 過濾器會將 SecurityContext 存儲在 Session 中,下次請求又通過 JSEESIONID 獲取認證用戶信息。
總結:remember-me 只有在 JSESSIONID 失效和前面的過濾器認證失敗或者未進行認證時才發揮作用。此時,只要 remember-me 的 Cookie 不過期,我們就不需要填寫登錄表單,就能實現再次登錄,並且 remember-me 自動登錄成功之后,會生成新的 Token 替換舊的 Token,相應 Cookie 的 Max-Age 也會重置。
此處對http.rememberMe()
返回值的主要方法進行說明,這些方法涉及 Remember-Me 配置,具體如下:
rememberMeParameter(String rememberMeParameter)
:指定在登錄時“記住我”的 HTTP 參數,默認為remember-me
。key(String key)
:“記住我”的 Token 中的標識字段,默認是一個隨機的 UUID 值。tokenValiditySeconds(int tokenValiditySeconds)
:“記住我” 的 Token 令牌有效期,單位為秒,即對應的 cookie 的 Max-Age 值,默認時間為 2 周。userDetailsService(UserDetailsService userDetailsService)
:指定 Remember-Me 功能自動登錄過程使用的 UserDetailsService 對象,默認使用 Spring 容器中的 UserDetailsService 對象.tokenRepository(PersistentTokenRepository tokenRepository)
:指定 TokenRepository 對象,用來配置持久化 Token。alwaysRemember(boolean alwaysRemember)
:是否應該始終創建記住我的 Token,默認為 false。useSecureCookie(boolean useSecureCookie)
:是否設置 Cookie 為安全,如果設置為 true,則必須通過 https 進行連接請求。
簡單加密 Token(基本使用)
在用戶選擇“記住我”登錄並成功認證后,Spring Security
將默認會生成一個名為 remember-me 的 Cookie 存儲 Token 並發送給瀏覽器;用戶注銷登錄后,該 Cookie 的 Max-Age 會被設置為 0,即刪除該 Cookie。Token 值由下列方式組合而成:
base64(username + ":" + expirationTime + ":" +
md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))
其中,username 代表用戶名;password 代表用戶密碼;expirationTime 表示記住我的 Token 的失效日期,以毫秒為單位;key 表示防止修改 Token 的標識,默認是一個隨機的 UUID 值。具體使用如下:
☕️ 修改 login.html 和 login-mobile.html,在登錄表單中添加“記住我”選項
<div><input name="remember-me" type="checkbox">記住我</div>
以 login.html 為例:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登錄</title>
</head>
<body>
<h3>表單登錄</h3>
<form method="post" th:action="@{/login/form}">
<input type="text" name="name" placeholder="用戶名"><br>
<input type="password" name="pwd" placeholder="密碼"><br>
<input name="imageCode" type="text" placeholder="驗證碼"><br>
<img th:onclick="this.src='/code/image?'+Math.random()" th:src="@{/code/image}" alt="驗證碼"/><br>
<div th:if="${param.error}">
<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用戶名或密碼錯誤</span>
</div>
<div><input name="remember-me" type="checkbox">記住我</div>
<button type="submit">登錄</button>
</form>
</body>
</html>
☕️ 修改安全配置類 SpringSecurityConfig
@EnableWebSecurity // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
//...
/**
* 定制基於 HTTP 請求的用戶訪問控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 開啟 Remember-Me 功能
http.rememberMe()
// 指定在登錄時“記住我”的 HTTP 參數,默認為 remember-me
.rememberMeParameter("remember-me")
// 設置 Token 有效期為 200s,默認時長為 2 星期
.tokenValiditySeconds(200)
// 指定 UserDetailsService 對象
.userDetailsService(userDetailsService);
// 開啟注銷登錄功能
http.logout()
// 用戶注銷登錄時訪問的 url,默認為 /logout
.logoutUrl("/logout")
// 用戶成功注銷登錄后重定向的地址,默認為 loginPage() + ?logout
.logoutSuccessUrl("/login/page?logout");
}
//...
}
☕️ 測試
訪問localhost:8080/login/page
,輸入正確用戶名、密碼和驗證碼,並勾選上“記住我”進行登錄:

成功登錄認證后,在返回的響應頭中可以找到 key 為 JSESSIONID 的 Cookie,生命周期為瀏覽器關閉時就刪除;key 為 remember-me 的 Cookie,Max-age 為 200 秒:

訪問localhost:8080/logout
,注銷登錄,在返回的響應頭中可以找到 remember-me 的 Cookie,Max-Age 被設置為 0,即刪除該 Cookie:

簡單加密 Token(源碼分析)
首次登錄
⭐️ AbstractAuthenticationProcessingFilter#successfulAuthentication
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//...
// 認證成功后的處理
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
}
//(1) 將認證成功的用戶信息對象 Authentication 封裝進 SecurityContext 對象中,並存入 SecurityContextHolder;
SecurityContextHolder.getContext().setAuthentication(authResult);
//(2) rememberMe 的處理
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
//(3) 發布認證成功的事件
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
//(4) 調用認證成功處理器
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
//...
}
用戶登錄后,在成功認證處理時,上述(2)過程會調用 AbstractRememberMeServices 的 loginSuccess() 方法進行 Remember-Me 處理。
⭐️ AbstractRememberMeServices#loginSuccess
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//...
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 判斷 Request 請求中是否攜帶了 remember-me 參數,且參數值為 true/on/yes/1
if (!this.rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
} else {
//(2) 本類的 onLoginSuccess() 方法是個抽象方法,所以實際調用的是子類
// TokenBasedRememberMeServices 重寫的 onLoginSuccess() 方法
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
}
⭐ TokenBasedRememberMeServices#onLoginSuccess
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 獲取用戶名、密碼
String username = this.retrieveUserName(successfulAuthentication);
String password = this.retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
} else {
if (!StringUtils.hasLength(password)) {
//(2) 通過 UserDetailsService 對象從數據庫中查詢對應 User的信息
UserDetails user = this.getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
//(3) 獲取 Token 的生命周期,默認為 1209600s(兩周)
int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
//(4) 獲取 Token 的過期時間
expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
//(5) 計算並獲取 Token 值
String signatureValue = this.makeTokenSignature(expiryTime, username, password);
//(6) 設置 Cookie,將 Token 傳遞給瀏覽器
this.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) + "'");
}
}
}
//...
}
二次登陸
✏️ RememberMeAuthenticationFilter#doFilter
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;
//(1) 判斷當前線程的 SecurityContext 對象是否存儲 Authentication 對象;
// 如果存在,意味着當前線程已經獲取了用戶信息,不需要再次進行登錄
if (SecurityContextHolder.getContext().getAuthentication() == null) {
//(2) 當前線程沒有對應用戶信息,調用 AbstractRememberMeServices 類的 autoLogin() 方法進行自動登錄,獲取用戶信息
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
// 獲取用戶信息成功
try {
//(3) 調用 ProviderManager 實現類的 authenticate() 方法進行身份認證
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
//(4) 認證成功后,將 Authentication 對象存儲當前線程的 SecurityContext
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
//(5) 調用本類的認證成功處理,是一個空方法
this.onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'");
}
if (this.eventPublisher != null) {
//(6) 發布認證成功的事件
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
//(7) 調用認證成功的處理器
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
} catch (AuthenticationException var8) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '" + rememberMeAuth + "'; invalidating remember-me token", var8);
}
// 認證失敗后的處理
this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, var8);
}
}
chain.doFilter(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'");
}
chain.doFilter(request, response);
}
}
}
上述的(2)過程調用 AbstractRememberMeServices 的 autoLogin() 方法實現自動登錄,獲取用戶信息。
✏️ AbstractRememberMeServices#autoLogin
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//...
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
//(1) 從 request 中獲取 remember-me 對應的 cookie 值
String rememberMeCookie = this.extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
} else {
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
this.cancelCookie(request, response);
return null;
} else {
UserDetails user = null;
try {
//(2) 對 cookie 值進行 Base64 解碼獲取 series 和 token 字段
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
//(3) 獲取 UserDetails,本類的 procssAutoLoginCookie() 方法是一個抽象方法,
// 所以實際調用的是子類 TokenBasedRememberMeServices 重寫的方法
user = this.procssAutoLoginCookie(cookieTokens, request, response);
//(4) 檢查用戶賬號是否鎖定、是否可用、是否過期
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
//(5) 將 UserDetails 對象封裝到 Authentication 對象里,並返回
return this.createSuccessfulAuthentication(request, user);
} catch (CookieTheftException var6) {
//...
}
// 獲取用戶信息類對象 UserDetails 失敗,刪除 remember-me 對應的 cookie
this.cancelCookie(request, response);
return null;
}
}
}
}
上述的(3)過程調用 TokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法獲取用戶信息。
✏️ TokenBasedRememberMeServices#processAutoLoginCookie
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
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) + "'");
} else {
long tokenExpiryTime;
try {
//(1) 獲取 Token 過期時間
tokenExpiryTime = new Long(cookieTokens[1]);
} catch (NumberFormatException var8) {
throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1] + "')");
}
//(2) 判斷 Token 是否過期
if (this.isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')");
} else {
//(3) 通過 UserDetailsService 對象獲取對應用戶信息 UserDetails
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> {
return "UserDetailsService " + this.getUserDetailsService() + " returned null for username " + cookieTokens[0] + ". This is an interface contract violation";
});
//(4) 比較 Token 中信息是否和預期的一樣,即判斷 Token 是否合法
String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
} else {
//(5) 返回用戶信息 UserDetails
return userDetails;
}
}
}
}
//...
}
持久化 Token(原理分析)
在用戶選擇“記住我”成功登錄認證后,默認會生成一個名為 remember-me 的 Cookie 儲存 Token,並發送給瀏覽器,具體實現流程如下:

- 用戶選擇“記住我”功能成功登錄認證后,
Spring Security
會把用戶名 username、序列號 series、令牌值 token 和最后一次使用自動登錄的時間 last_used 作為一條 Token 記錄存入數據庫表中,同時生成一個名為 remember-me 的 Cookie 存儲series:token
的 base64 編碼,該編碼為發送給瀏覽器的 Token。 - 當用戶需要再次登錄時,RememberMeAuthenticationFilter 過濾器首先會檢查請求是否有 remember-me 的 Cookie。如果存在,則檢查其 Token 值中的 series 和 token 字段是否與數據庫中的相關記錄一致,一致則通過驗證,並且系統重新生成一個新 token 值替換數據庫中對應記錄的舊 token,該記錄的序列號 series 保持不變,認證時間 last_used 更新,同時重新生成新的 Token(舊 series : 新 token)通過 Cookie 發送給瀏覽器,remember-me 的 Cookie 的 Max-Age 也因此重置。
- 上述驗證通過后,獲取數據庫中對應 Token 記錄的 username 字段,調用 UserDetailsService 獲取用戶信息。之后進行登錄認證,認證成功后將認證用戶信息 Authentication 對象存入 SecurityContext。
- 如果對應的 Cookie 值包含的 token 字段與數據庫中對應 Token 記錄的 token 字段不匹配,則有可能是用戶的 Cookie 被盜用,這時將會刪除數據庫中與當前用戶相關的所有 Token 記錄,用戶需要重新進行表單登錄。
- 如果對應的 Cookie 不存在,或者其值包含的 series 和 token 字段與數據庫中的記錄不匹配,則用戶需要重新進行表單登錄。如果用戶退出登錄,則刪除數據庫中對應的 Token 記錄,並將相應的 Cookie 的 Max-Age 設置為 0。
在實現上,Spring Security
使用 PersistentRememberMeToken 來表明一個驗證實體:
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
// 最后一個使用自動登錄的時間
private final Date date;
//...
}
對應的,在數據庫需要有一張 persistent_logins 表(存儲自動登錄信息的表),表結構如下:
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) PRIMARY KEY,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL
);
由於需要使用持久化 Token 方案,所以需要定制 tokenRepository,用於與數據庫表的交互。為此,我們需要創建一個 PersistentTokenRepository 實例,該實例中定義了持久化令牌的一些必要方法:
public interface PersistentTokenRepository {
void createNewToken(PersistentRememberMeToken var1);
void updateToken(String var1, String var2, Date var3);
PersistentRememberMeToken getTokenForSeries(String var1);
void removeUserTokens(String var1);
}
我們可以自定義實現 PersistentTokenRepository 接口,也可以使用Spring Security
提供的 JDBC 方案實現:
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
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)";
public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
private String tokensBySeriesSql = "select username,series,token,last_used from persistent_logins where series = ?";
private String insertTokenSql = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
private String updateTokenSql = "update persistent_logins set token = ?, last_used = ? where series = ?";
private String removeUserTokensSql = "delete from persistent_logins where username = ?";
//...
}
持久化 Token(基本使用)
📚 創建數據庫表 persistent_logins,用於存儲自動登錄信息
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) PRIMARY KEY,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL
);
📚 修改安全配置類 SpringSecurityConfig,使用持久化 Token 方式
@EnableWebSecurity // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private DataSource dataSource; // 數據源
/**
* 配置 JdbcTokenRepositoryImpl,用於 Remember-Me 的持久化 Token
*/
@Bean
public JdbcTokenRepositoryImpl tokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置數據源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次啟動的時候可以使用以下語句自動建表(可以不用這句話,自己手動建表,源碼中有語句的)
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
//...
/**
* 定制基於 HTTP 請求的用戶訪問控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 開啟 Remember-Me 功能
http.rememberMe()
// 指定在登錄時“記住我”的 HTTP 參數,默認為 remember-me
.rememberMeParameter("remember-me")
// 設置 Token 有效期為 200s,默認時長為 2 星期
.tokenValiditySeconds(200)
// 設置操作數據表的 Repository
.tokenRepository(tokenRepository())
// 指定 UserDetailsService 對象
.userDetailsService(userDetailsService);
// 開啟注銷登錄功能
http.logout()
// 用戶注銷登錄時訪問的 url,默認為 /logout
.logoutUrl("/logout")
// 用戶成功注銷登錄后重定向的地址,默認為 loginPage() + ?logout
.logoutSuccessUrl("/login/page?logout");
}
//...
}
完整的安全配置類 SpringSecurityConfig 如下:
package com.example.config;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import javax.sql.DataSource;
@EnableWebSecurity // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定義認證成功處理器
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定義認證失敗處理器
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定義過濾器(圖形驗證碼校驗)
@Autowired
private MobileAuthenticationConfig mobileAuthenticationConfig; // 手機短信驗證碼認證方式的配置類
@Autowired
private DataSource dataSource; // 數據源
/**
* 配置 JdbcTokenRepositoryImpl,用於 Remember-Me 的持久化 Token
*/
@Bean
public JdbcTokenRepositoryImpl tokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置數據源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次啟動的時候可以使用以下語句自動建表(可以不用這句話,自己手動建表,源碼中有語句的)
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
/**
* 密碼編碼器,密碼不能明文存儲
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 使用 BCryptPasswordEncoder 密碼編碼器,該編碼器會將隨機產生的 salt 混入最終生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用戶認證管理器來實現用戶認證
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 采用內存存儲方式,用戶認證信息存儲在內存中
// auth.inMemoryAuthentication()
// .withUser("admin").password(passwordEncoder()
// .encode("123456")).roles("ROLE_ADMIN");
// 不再使用內存方式存儲用戶認證信息,而是動態從數據庫中獲取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基於 HTTP 請求的用戶訪問控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 啟動 form 表單登錄
http.formLogin()
// 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
.loginPage("/login/page")
// 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
.loginProcessingUrl("/login/form")
// 設置登錄表單中的用戶名參數,默認為 username
.usernameParameter("name")
// 設置登錄表單中的密碼參數,默認為 password
.passwordParameter("pwd")
// 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
//.defaultSuccessUrl("/index")
// 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
//.failureUrl("/login/page?error");
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法進行認證成功和失敗處理,
// 使用自定義的認證成功和失敗處理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 開啟基於 HTTP 請求訪問控制
http.authorizeRequests()
// 以下訪問不需要任何權限,任何人都可以訪問
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下訪問需要 ROLE_ADMIN 權限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下訪問需要 ROLE_USER 權限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何請求訪問都需要先通過認證
.anyRequest().authenticated();
// 關閉 csrf 防護
http.csrf().disable();
// 將自定義過濾器(圖形驗證碼校驗)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
// 將手機短信驗證碼認證的配置與當前的配置綁定
http.apply(mobileAuthenticationConfig);
// 開啟 Remember-Me 功能
http.rememberMe()
// 指定在登錄時“記住我”的 HTTP 參數,默認為 remember-me
.rememberMeParameter("remember-me")
// 設置 Token 有效期為 200s,默認時長為 2 星期
.tokenValiditySeconds(200)
// 設置操作數據庫表的 Repository
.tokenRepository(tokenRepository())
// 指定 UserDetailsService 對象
.userDetailsService(userDetailsService);
// 開啟注銷登錄功能
http.logout()
// 用戶注銷登錄時訪問的 url,默認為 /logout
.logoutUrl("/logout")
// 用戶成功注銷登錄后重定向的地址,默認為 loginPage() + ?logout
.logoutSuccessUrl("/login/page?logout");
}
/**
* 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 靜態資源的訪問不需要攔截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
📚 測試
訪問localhost:8080/login/page
,輸入正確用戶名、密碼和驗證碼,並勾選上“記住我”進行登錄:

首次登錄
成功登錄認證后,可以在對應的數據表中找到相關 Token 記錄:

在瀏覽器返回的響應頭中可以找到 key 為 JSESSIONID 的 Cookie,生命周期為瀏覽器關閉時就刪除;key 為 remember-me 的 Cookie,Max-age 為 200 秒:

上圖中,如果對 remember-me 的 Cookie 值進行 base64 解碼,可以發現解碼后的字符串就是series:token
。
二次登錄
將瀏覽器保存的 JSESSIONID 刪除,只保留 remember-me 的 Cookie。訪問localhost:8080
,查看請求頭和響應頭:


從上圖可以看出,請求頭中只攜帶 remember-me 的 Cookie,響應頭返回新的 JSESSIONID 和 remember-me 的 Cookie。對比上面兩張圖,明顯可以發現 remember-me 的 Cookie 值改變,並且該 Cookie 的 Max-Age 重置。查看數據庫表,可以發現 token 字段改變,last_used 字段更新:

注銷登錄
訪問localhost:8080/logout
,注銷登錄,數據庫表中對應的 Token 記錄會被刪除:

在返回的響應頭中可以找到 remember-me 的 Cookie,Max-Age 被設置為 0,即刪除該 Cookie:

持久化 Token(源碼分析)
首次登錄
⭐️ AbstractAuthenticationProcessingFilter#successfulAuthentication
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//...
// 認證成功后的處理
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
}
//(1) 將認證成功的用戶信息對象 Authentication 封裝進 SecurityContext 對象中,並存入 SecurityContextHolder;
SecurityContextHolder.getContext().setAuthentication(authResult);
//(2) rememberMe 的處理
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
//(3) 發布認證成功的事件
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
//(4) 調用認證成功處理器
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
//...
}
用戶登錄后,在成功認證處理時,上述(2)過程會調用 AbstractRememberMeServices 的 loginSuccess() 方法進行 Remember-Me 處理。
⭐️ AbstractRememberMeServices#loginSuccess
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//...
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 判斷 Request 請求中是否攜帶了 remember-me 參數,且參數值為 true/on/yes/1
if (!this.rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
} else {
//(2) 本類的 onLoginSuccess() 方法是個抽象方法,所以實際調用的是子類
// PersistentTokenBasedRememberMeServices 重寫的 onLoginSuccess() 方法
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
}
⭐ PersistentTokenBasedRememberMeServices#onLoginSuccess
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 獲取用戶名
String username = successfulAuthentication.getName();
this.logger.debug("Creating new persistent login for user " + username);
//(2) 創建要插入的 Token 記錄對應實例 persistentToken
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
//(3) 使用 tokenRepository 往數據庫表中插入 Token 記錄
this.tokenRepository.createNewToken(persistentToken);
//(4) 將 Token 記錄的 series:token 字段通過 Cookie 發送給用戶瀏覽器
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}
//...
}
二次登陸
✏️ RememberMeAuthenticationFilter#doFilter
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;
//(1) 判斷當前線程的 SecurityContext 對象是否存儲 Authentication 對象;
// 如果存在,意味着當前線程已經獲取了用戶信息,不需要再次進行登錄
if (SecurityContextHolder.getContext().getAuthentication() == null) {
//(2) 當前線程沒有對應用戶信息,調用 AbstractRememberMeServices 類的 autoLogin() 方法進行自動登錄,獲取用戶信息
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
// 獲取用戶信息成功
try {
//(3) 調用 ProviderManager 實現類的 authenticate() 方法進行身份認證
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
//(4) 認證成功后,將 Authentication 對象存儲當前線程的 SecurityContext
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
//(5) 調用本類的認證成功處理,是一個空方法
this.onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'");
}
if (this.eventPublisher != null) {
//(6) 發布認證成功的事件
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
//(7) 調用認證成功的處理器
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
} catch (AuthenticationException var8) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '" + rememberMeAuth + "'; invalidating remember-me token", var8);
}
// 認證失敗后的處理
this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, var8);
}
}
chain.doFilter(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'");
}
chain.doFilter(request, response);
}
}
}
上述的(2)過程調用 AbstractRememberMeServices 的 autoLogin() 方法實現自動登錄,獲取用戶信息。
✏️ AbstractRememberMeServices#autoLogin
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//...
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
//(1) 從 request 中獲取 remember-me 對應的 cookie 值
String rememberMeCookie = this.extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
} else {
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
this.cancelCookie(request, response);
return null;
} else {
UserDetails user = null;
try {
//(2) 對 cookie 值進行 Base64 解碼獲取 series 和 token 字段
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
//(3) 獲取 UserDetails,本類的 procssAutoLoginCookie() 方法是一個抽象方法,
// 所以實際調用的是子類 PersistentTokenBasedRememberMeServices 重寫的方法
user = this.procssAutoLoginCookie(cookieTokens, request, response);
//(4) 檢查用戶賬號是否鎖定、是否可用、是否過期
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
//(5) 將 UserDetails 對象封裝到 Authentication 對象里,並返回
return this.createSuccessfulAuthentication(request, user);
} catch (CookieTheftException var6) {
//...
}
// 獲取用戶信息類對象 UserDetails 失敗,刪除 remember-me 對應的 cookie
this.cancelCookie(request, response);
return null;
}
}
}
}
上述的(3)過程調用 PersistentTokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法獲取用戶信息。
✏️ PersistentTokenBasedRememberMeServices#processAutoLoginCookie
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) + "'");
} else {
//(1) 獲取 Cookie 中的 series 和 token 字段
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
//(2) 根據 series 字段從數據庫中查詢 Token 記錄
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
//(3) 沒有查詢到 Token 記錄的處理,拋出異常
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) {
//(3) 查詢到的 Token 記錄中的 token 字段和 Cookie 中的字段不同,可能是 Cookie
// 被盜用,所以刪除數據庫表的該用戶的所有 Token 記錄,並拋出異常
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."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
//(3) Token 過期的處理,拋出異常
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
//(3) 查詢到正常 Token 記錄
if (this.logger.isDebugEnabled()) {
this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
}
//(3.1) 生成新的 Token(token 字段改變,series 字段不變,last_used 更新)
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
try {
//(3.2) 更新數據庫中的 Token 記錄
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
//(3.3) 將新 Token 的 series:token 通過 Cookie 發送給用戶瀏覽器
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
//(3.4) 通過 UserDetailsService 對象查詢用戶信息 UserDetails,並返回
return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
}
//...
}
注銷登錄
注銷登錄需要在安全配置類的configure(HttpSecurity http)
里使用http.logout()
配置,該配置主要會在過濾器鏈中加入 LogoutFilter 過濾器,Spring Security
通過該過濾器實現注銷登錄功能。
此處對http.logout()
返回值的主要方法進行介紹,這些方法設計注銷登錄的配置,具體如下:
logoutUrl(String outUrl)
:指定用戶注銷登錄時請求訪問的地址,默認為 POST 方式的/logout
。logoutSuccessUrl(String logoutSuccessUrl)
:指定用戶成功注銷登錄后的重定向地址,默認為/登錄頁面url?logout
。logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler)
:指定用戶成功注銷登錄后使用的處理器。deleteCookies(String ...cookieNamesToClear)
:指定用戶注銷登錄后刪除的 Cookie。invalidateHttpSession(boolean invalidateHttpSession)
:指定用戶注銷登錄后是否立即清除用戶的 Session,默認為 true。clearAuthentication(boolean clearAuthentication)
:指定用戶退出登錄后是否立即清除用戶認證信息對象 Authentication,默認為 true。addLogoutHandler(LogoutHandler logoutHandler)
:指定用戶注銷登錄時使用的處理器。
需要注意,Spring Security
默認以 POST 方式請求訪問/logout
注銷登錄,以 POST 方式請求的原因是為了防止 csrf(跨站請求偽造),如果想使用 GET 方式的請求,則需要關閉 csrf 防護。前面我們能以 GET 方式的請求注銷登錄,是因為我們在configure(HttpSecurity http)
方法中關閉了 csrf 防護:
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...
http.csrf().disable(); // 關閉 csrf 防護
// ...
}
默認配置下,成功注銷登錄后會進行如下三個操作:
- 刪除用戶瀏覽器中的指定 Cookie。
- 將用戶瀏覽器中 remember-me 的 Cookie 刪除,並清除用戶在數據庫中 remember-me 的 Token 記錄;
- 當前用戶的 Session 刪除,並清除當前 SecurityContext 中的用戶認證信息對象 Authentication。
- 通知用戶瀏覽器重定向到
/登錄頁面url?logout
。
基本使用
📚 自定義成功注銷登錄處理器
package com.example.config.security;
import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 繼承 SimpleUrlLogoutSuccessHandler 處理器,該類是 logoutSuccessUrl() 方法使用的成功注銷登錄處理器
*/
@Component
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
String xRequestedWith = request.getHeader("x-requested-with");
// 判斷前端的請求是否為 ajax 請求
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 成功注銷登錄,響應 JSON 數據
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(0, "注銷登錄成功!")));
}else {
// 以下配置等同於在 http.logout() 后配置 logoutSuccessUrl("/login/page?logout")
// 設置默認的重定向路徑
super.setDefaultTargetUrl("/login/page?logout");
// 調用父類的 onLogoutSuccess() 方法
super.onLogoutSuccess(request, response, authentication);
}
}
}
📚 修改安全配置類 SpringSecurityConfig
@EnableWebSecurity // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private CustomLogoutSuccessHandler logoutSuccessHandler; // 自定義成功注銷登錄處理器
//...
/**
* 定制基於 HTTP 請求的用戶訪問控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 開啟注銷登錄功能
http.logout()
// 用戶注銷登錄時訪問的 url,默認為 /logout
.logoutUrl("/logout")
// 用戶成功注銷登錄后重定向的地址,默認為 loginPage() + ?logout
//.logoutSuccessUrl("/login/page?logout")
// 不再使用 logoutSuccessUrl() 方法,使用自定義的成功注銷登錄處理器
.logoutSuccessHandler(logoutSuccessHandler)
// 指定用戶注銷登錄時刪除的 Cookie
.deleteCookies("JSESSIONID")
// 用戶注銷登錄時是否立即清除用戶的 Session,默認為 true
.invalidateHttpSession(true)
// 用戶注銷登錄時是否立即清除用戶認證信息 Authentication,默認為 true
.clearAuthentication(true);
}
//...
}
完整的安全配置類 SpringSecurityConfig 如下:
package com.example.config;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.CustomLogoutSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import javax.sql.DataSource;
@EnableWebSecurity // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定義認證成功處理器
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定義認證失敗處理器
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定義過濾器(圖形驗證碼校驗)
@Autowired
private MobileAuthenticationConfig mobileAuthenticationConfig; // 手機短信驗證碼認證方式的配置類
@Autowired
private CustomLogoutSuccessHandler logoutSuccessHandler; // 自定義成功注銷登錄處理器
@Autowired
private DataSource dataSource; // 數據源
/**
* 配置 JdbcTokenRepositoryImpl,用於 Remember-Me 的持久化 Token
*/
@Bean
public JdbcTokenRepositoryImpl tokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置數據源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次啟動的時候可以使用以下語句自動建表(可以不用這句話,自己手動建表,源碼中有語句的)
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
/**
* 密碼編碼器,密碼不能明文存儲
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 使用 BCryptPasswordEncoder 密碼編碼器,該編碼器會將隨機產生的 salt 混入最終生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用戶認證管理器來實現用戶認證
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 不再使用內存方式存儲用戶認證信息,而是動態從數據庫中獲取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基於 HTTP 請求的用戶訪問控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 啟動 form 表單登錄
http.formLogin()
// 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
.loginPage("/login/page")
// 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
.loginProcessingUrl("/login/form")
// 設置登錄表單中的用戶名參數,默認為 username
.usernameParameter("name")
// 設置登錄表單中的密碼參數,默認為 password
.passwordParameter("pwd")
// 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
//.defaultSuccessUrl("/index")
// 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
//.failureUrl("/login/page?error");
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法進行認證成功和失敗處理,
// 使用自定義的認證成功和失敗處理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 開啟基於 HTTP 請求訪問控制
http.authorizeRequests()
// 以下訪問不需要任何權限,任何人都可以訪問
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下訪問需要 ROLE_ADMIN 權限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下訪問需要 ROLE_USER 權限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何請求訪問都需要先通過認證
.anyRequest().authenticated();
// 關閉 csrf 防護
http.csrf().disable();
// 將自定義過濾器(圖形驗證碼校驗)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
// 將手機短信驗證碼認證的配置與當前的配置綁定
http.apply(mobileAuthenticationConfig);
// 開啟 Remember-Me 功能
http.rememberMe()
// 指定在登錄時“記住我”的 HTTP 參數,默認為 remember-me
.rememberMeParameter("remember-me")
// 設置 Token 有效期為 200s,默認時長為 2 星期
//.tokenValiditySeconds(200)
// 設置操作數據庫表的 Repository
.tokenRepository(tokenRepository())
// 指定 UserDetailsService 對象
.userDetailsService(userDetailsService);
// 開啟注銷登錄功能
http.logout()
// 用戶注銷登錄時訪問的 url,默認為 /logout
.logoutUrl("/logout")
// 用戶成功注銷登錄后重定向的地址,默認為 loginPage() + ?logout
//.logoutSuccessUrl("/login/page?logout")
// 不再使用 logoutSuccessUrl() 方法,使用自定義的成功注銷登錄處理器
.logoutSuccessHandler(logoutSuccessHandler)
// 指定用戶注銷登錄時刪除的 Cookie
.deleteCookies("JSESSIONID")
// 用戶注銷登錄時是否立即清除用戶的 Session,默認為 true
.invalidateHttpSession(true)
// 用戶注銷登錄時是否立即清除用戶認證信息 Authentication,默認為 true
.clearAuthentication(true);
}
/**
* 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 靜態資源的訪問不需要攔截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
📚 測試
訪問localhost:8080/login/page
,輸入正確用戶名、密碼和驗證碼,並勾選上“記住我”進行登錄:

訪問localhost:8080/logout
,注銷登錄,查看請求頭和響應頭:


由上圖可以看出,注銷登錄后,用戶瀏覽器的 JSESSIONID 和 remember-me 的 Cookie 被刪除。
源碼分析
✌ LogoutFilter#doFilter
public class LogoutFilter extends GenericFilterBean {
//...
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
//(1) 判斷請求的 URL 是否為指定的注銷登錄路徑,默認為 /logout
if (this.requiresLogout(request, response)) {
//(2) 獲取認證用戶信息 Authentication
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (this.logger.isDebugEnabled()) {
this.logger.debug("Logging out user '" + auth + "' and transferring to logout destination");
}
//(3) 注銷登錄的處理,調用 CompositeLogoutHandler 處理器的 logout 方法
this.handler.logout(request, response, auth);
//(4) 成功注銷登錄后的處理,調用成功注銷登錄處理器的 onLogoutSuccess() 方法,
// 默認重定向到 /登錄頁面url?logout
this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
} else {
chain.doFilter(request, response);
}
}
//...
}
上述(3)過程調用 CompositeLogoutHandler 處理器的 logout 方法進行注銷登錄的處理。
✌ CompositeLogoutHandler#logout
public final class CompositeLogoutHandler implements LogoutHandler {
private final List<LogoutHandler> logoutHandlers;
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Iterator var4 = this.logoutHandlers.iterator();
// 此處有多個處理器操作,主要有三個:
// 1. 清除指定 Cookie:CookieClearingLogoutHandler#logout
// 2. 清除 remember-me:PersistentTokenBasedRememberMeServices#logout
// 3. 使當前 Session無效,清空當前的 SecurityContext 中認證用戶信息 Authentication:
// SecurityContextLogoutHandler#logout
while(var4.hasNext()) {
LogoutHandler handler = (LogoutHandler)var4.next();
handler.logout(request, response, authentication);
}
}
}
下面會對這三個處理器的 logout()
✍ CookieClearingLogoutHandler#logout
public final class CookieClearingLogoutHandler implements LogoutHandler {
private final List<Function<HttpServletRequest, Cookie>> cookiesToClear;
// 此處傳入的 cookiesToClear 就是我們在安全配置類中使用 deleteCookies() 方法傳入的參數
public CookieClearingLogoutHandler(String... cookiesToClear) {
Assert.notNull(cookiesToClear, "List of cookies cannot be null");
List<Function<HttpServletRequest, Cookie>> cookieList = new ArrayList();
String[] var3 = cookiesToClear;
int var4 = cookiesToClear.length;
for(int var5 = 0; var5 < var4; ++var5) {
String cookieName = var3[var5];
// 創建 Cookie 和刪除邏輯的 Lambda 表達式
Function<HttpServletRequest, Cookie> f = (request) -> {
Cookie cookie = new Cookie(cookieName, (String)null);
// 此處的 cookiePath 設置存在問題,如果 contextPath 不為空,后綴不需要加 "/"
// cookiePath = contextPath.length() > 0 ? contextPath : "/"
String cookiePath = request.getContextPath() + "/";
cookie.setPath(cookiePath);
cookie.setMaxAge(0);
cookie.setSecure(request.isSecure());
return cookie;
};
cookieList.add(f);
}
this.cookiesToClear = cookieList;
}
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 將指定的 cookiesToClear 列表中的 Cookie 的 Max-Age 設置為 0,刪除該列表中的 Cookie
this.cookiesToClear.forEach((f) -> {
response.addCookie((Cookie)f.apply(request));
});
}
//...
}
✍ PersistentTokenBasedRememberMeServices#logout
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//(1) 調用父類 AbstractRememberMeServices 的同名方法,將 remember-me 的 Cookie 清除
super.logout(request, response, authentication);
if (authentication != null) {
//(2) 使用 tokenRepository 將數據庫表中對應用戶的 Token 記錄刪除
this.tokenRepository.removeUserTokens(authentication.getName());
}
}
//...
}
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Logout of user " + (authentication == null ? "Unknown" : authentication.getName()));
}
// 將 remember-me 的 Cookie 清除
this.cancelCookie(request, response);
}
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Cancelling cookie");
Cookie cookie = new Cookie(this.cookieName, (String)null);
cookie.setMaxAge(0);
cookie.setPath(this.getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}
if (this.useSecureCookie == null) {
cookie.setSecure(request.isSecure());
} else {
cookie.setSecure(this.useSecureCookie);
}
response.addCookie(cookie);
}
private String getCookiePath(HttpServletRequest request) {
String contextPath = request.getContextPath();
return contextPath.length() > 0 ? contextPath : "/";
}
//
}
✍ SecurityContextLogoutHandler#logout
public class SecurityContextLogoutHandler implements LogoutHandler {
//...
private boolean invalidateHttpSession = true;
private boolean clearAuthentication = true;
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Assert.notNull(request, "HttpServletRequest required");
if (this.invalidateHttpSession) {
//(1) 將當前 Session 強制失效
HttpSession session = request.getSession(false);
if (session != null) {
this.logger.debug("Invalidating session: " + session.getId());
session.invalidate();
}
}
if (this.clearAuthentication) {
//(2) 移除當前 SecurityContext 中的用戶認證用戶信息 Authentication
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication((Authentication)null);
}
//(3) 清空當前的 SecurityContextHolder
SecurityContextHolder.clearContext();
}
//...
}