Spring Security 入門(三):Remember-Me 和注銷登錄


本文在前文 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,並發送給瀏覽器,具體實現流程如下:

  1. 用戶選擇“記住我”功能成功登錄認證后,Spring Security會把用戶名 username、序列號 series、令牌值 token 和最后一次使用自動登錄的時間 last_used 作為一條 Token 記錄存入數據庫表中,同時生成一個名為 remember-me 的 Cookie 存儲series:token的 base64 編碼,該編碼為發送給瀏覽器的 Token。
  2. 當用戶需要再次登錄時,RememberMeAuthenticationFilter 過濾器首先會檢查請求是否有 remember-me 的 Cookie。如果存在,則檢查其 Token 值中的 series 和 token 字段是否與數據庫中的相關記錄一致,一致則通過驗證,並且系統重新生成一個新 token 值替換數據庫中對應記錄的舊 token,該記錄的序列號 series 保持不變,認證時間 last_used 更新,同時重新生成新的 Token(舊 series : 新 token)通過 Cookie 發送給瀏覽器,remember-me 的 Cookie 的 Max-Age 也因此重置。
  3. 上述驗證通過后,獲取數據庫中對應 Token 記錄的 username 字段,調用 UserDetailsService 獲取用戶信息。之后進行登錄認證,認證成功后將認證用戶信息 Authentication 對象存入 SecurityContext。
  4. 如果對應的 Cookie 值包含的 token 字段與數據庫中對應 Token 記錄的 token 字段不匹配,則有可能是用戶的 Cookie 被盜用,這時將會刪除數據庫中與當前用戶相關的所有 Token 記錄,用戶需要重新進行表單登錄。
  5. 如果對應的 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 防護
    // ...
}

默認配置下,成功注銷登錄后會進行如下三個操作:

  1. 刪除用戶瀏覽器中的指定 Cookie。
  2. 將用戶瀏覽器中 remember-me 的 Cookie 刪除,並清除用戶在數據庫中 remember-me 的 Token 記錄;
  3. 當前用戶的 Session 刪除,並清除當前 SecurityContext 中的用戶認證信息對象 Authentication。
  4. 通知用戶瀏覽器重定向到/登錄頁面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();
    }
    //...
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM