Spring Security 入門(四):Session 會話管理


本文在 Spring Security 入門(三):Remember-Me 和注銷登錄 一文的代碼基礎上介紹Spring Security的 Session 會話管理。

Session 會話管理的配置方法

Session 會話管理需要在configure(HttpSecurity http)方法中通過http.sessionManagement()開啟配置。此處對http.sessionManagement()返回值的主要方法進行說明,這些方法涉及 Session 會話管理的配置,具體如下:

  • invalidSessionUrl(String invalidSessionUrl):指定會話失效時(請求攜帶無效的 JSESSIONID 訪問系統)重定向的 URL,默認重定向到登錄頁面。
  • invalidSessionStrategy(InvalidSessionStrategy invalidSessionStrategy):指定會話失效時(請求攜帶無效的 JSESSIONID 訪問系統)的處理策略。
  • maximumSessions(int maximumSessions):指定每個用戶的最大並發會話數量,-1 表示不限數量。
  • maxSessionsPreventsLogin(boolean maxSessionsPreventsLogin):如果設置為 true,表示某用戶達到最大會話並發數后,新會話請求會被拒絕登錄;如果設置為 false,表示某用戶達到最大會話並發數后,新會話請求訪問時,其最老會話會在下一次請求時失效並根據 expiredUrl() 或者 expiredSessionStrategy() 方法配置的會話失效策略進行處理,默認值為 false。
  • expiredUrl(String expiredUrl):如果某用戶達到最大會話並發數后,新會話請求訪問時,其最老會話會在下一次請求時失效並重定向到 expiredUrl。
  • expiredSessionStrategy(SessionInformationExpiredStrategy expiredSessionStrategy):如果某用戶達到最大會話並發數后,新會話請求訪問時,其最老會話會在下一次請求中失效並按照該策略處理請求。注意如果本方法與 expiredUrl() 同時使用,優先使用 expiredUrl() 的配置。
  • sessionRegistry(SessionRegistry sessionRegistry):設置所要使用的 sessionRegistry,默認配置的是 SessionRegistryImpl 實現類。

Session 會話失效處理

當用戶的 Session 會話失效(請求攜帶着無效的 JSESSIONID 訪問系統)時,可以制定相關策略對會話失效的請求進行處理。

invalidSessionUrl 方法

☕️ 修改安全配置類 SpringSecurityConfig,配置 Session 會話失效時重定向到/login/page

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 開啟 Session 會話管理配置
        http.sessionManagement()
                // 設置 Session 會話失效時重定向路徑,默認為 loginPage()
                .invalidSessionUrl("/login/page");        
    }
    //...
}

☕️ 設置 Session 的失效時間

Session 的失效時間配置是 SpringBoot 原生支持的,可以在 application.properties 配置文件中直接配置:

# session 失效時間,單位是秒,默認為 30min
server.servlet.session.timeout=30m

# JSESSIONID (Cookie)的生命周期,單位是秒,默認為 -1
server.servlet.session.cookie.max-age=-1

注意:Session 的失效時間至少要 1 分鍾,少於 1 分鍾按照 1 分鍾配置,查看源碼:

public class TomcatServletWebServerFactory extends AbstractServletWebServerFactory implements ConfigurableTomcatWebServerFactory, ResourceLoaderAware {
    //...
    private long getSessionTimeoutInMinutes() {
        Duration sessionTimeout = this.getSession().getTimeout();
        // 至少 1 分鍾,少於 1 分鍾按照 1 分鍾配置
        return this.isZeroOrLess(sessionTimeout) ? 0L : Math.max(sessionTimeout.toMinutes(), 1L);
    }
    //...
}

為了方便檢驗,在 application.properties 中配置 Session 的失效時間為 1 分鍾:

# session 失效時間,單位是秒,默認為 30min
server.servlet.session.timeout=60

☕️ 測試

瀏覽器訪問localhost:8080/login/page,輸入正確的用戶名、密碼(不選擇“記住我”功能)成功登錄后,重定向到首頁面:

之后,等待 1 分鍾,刷新頁面,瀏覽器重定向到/login/page


invalidSessionStrategy 方法

如果想要自定義 Session 會話失效處理策略,使用該方法傳入自定義策略。

⭐️ 自定義 Session 會話失效處理策略 CustomInvalidSessionStrategy

package com.example.config.security.session;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.session.InvalidSessionStrategy;
import org.springframework.stereotype.Component;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 用戶請求攜帶無效的 JSESSIONID 訪問時的處理策略,即對應的 Session 會話失效
 */
@Component
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {

    @Autowired
    private ObjectMapper objectMapper;

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 清除瀏覽器中的無效的 JSESSIONID
        Cookie cookie = new Cookie("JSESSIONID", null);
        cookie.setPath(getCookiePath(request));
        cookie.setMaxAge(0);
        response.addCookie(cookie);

        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<>(1, "SESSION 失效,請重新登錄!")));
        }else {
            // 重定向到登錄頁面
            redirectStrategy.sendRedirect(request, response, "/login/page");
        }
    }

    private String getCookiePath(HttpServletRequest request) {
        String contextPath = request.getContextPath();
        return contextPath.length() > 0 ? contextPath : "/";
    }
}

⭐️ 修改安全配置類 SpringSecurityConfig,配置使用自定義的 Session 會話失效處理策略

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private CustomInvalidSessionStrategy invalidSessionStrategy;  // 自定義 Session 會話失效策略
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 開啟 Session 會話管理配置
        http.sessionManagement()
                // 設置 Session 會話失效時重定向路徑,默認為 loginPage()
                // .invalidSessionUrl("/login/page")
                // 配置使用自定義的 Session 會話失效處理策略
                .invalidSessionStrategy(invalidSessionStrategy);       
    }
    //...
}

⭐️ 測試

瀏覽器訪問localhost:8080/login/page,輸入正確的用戶名、密碼(不選擇“記住我”功能)成功登錄后,重定向到首頁面:

之后,等待 1 分鍾,刷新頁面,查看響應頭:

同時,瀏覽器重定向到/login/page


Session 會話並發控制

Session 會話並發控制可以限制用戶的最大並發會話數量,例如:只允許一個用戶在一個地方登陸,也就是說每個用戶在系統中只能有一個 Session 會話。為了方便檢驗,在 application.properties 中將 Session 的過期時間改回 30 分鍾:

# session 有效期,單位是秒,默認為 30min
server.servlet.session.timeout=30m

在使用 Session 會話並發控制時,最好保證自定義的 UserDetails 實現類重寫了 equals() 和 hashCode() 方法:

@Data
public class User implements UserDetails {
	
    //...
    private String username;  // 用戶名
    //...

    @Override
    public boolean equals(Object obj) {  // equals() 方法一般要重寫
        return obj instanceof User && this.username.equals(((User) obj).username);
    }

    @Override
    public int hashCode() {   // hashCode() 方法一般要重寫
        return this.username.hashCode();
    }
}

我們前面實現了兩種登錄方式:用戶名、密碼登錄和手機短信驗證碼登錄,需要保證兩種登錄方式使用的是同一個 SessionAuthenticationStrategy 實例,也就是 MobileAuthenticationConfig 配置類中要有(1.4)的配置:

@Component
public class MobileAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    //...
    @Override
    public void configure(HttpSecurity http) throws Exception {
        //...
        //(1.1) 創建手機短信驗證碼認證過濾器的實例 filer
        MobileAuthenticationFilter filter = new MobileAuthenticationFilter();
        
        //...
        //(1.4) 設置 filter 使用 SessionAuthenticationStrategy 會話管理器
        // 多種登錄方式應該使用同一個會話管理器實例,獲取 Spring 容器已經存在的 SessionAuthenticationStrategy 實例
        SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);
        filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
        //...
    }
}

如果沒有(1.4)的配置,MobileAuthenticationFilter 默認使用的是 NullAuthenticatedSessionStrategy 實例管理 Session,而 UsernamePasswordAuthenticationFilter 使用的是 CompositeSessionAuthenticationStrategy 實例管理 Session,也就是說兩種登錄方式的 Session 管理是相互獨立的,這是不應該出現的情況。

基本使用

場景一:如果同一個用戶在第二個地方登錄,則不允許他二次登錄

✏️ 修改安全配置類 SpringSecurityConfig,配置用戶最大並發 Session 會話數量和限制用戶二次登錄

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 開啟 Session 會話管理配置
        http.sessionManagement()
                // 設置 Session 會話失效時重定向路徑,默認為 loginPage()
                // .invalidSessionUrl("/login/page")
                // 配置使用自定義的 Session 會話失效處理策略
                .invalidSessionStrategy(invalidSessionStrategy)
                // 設置單用戶的 Session 最大並發會話數量,-1 表示不受限制
                .maximumSessions(1)
                // 設置為 true,表示某用戶達到最大會話並發數后,新會話請求會被拒絕登錄
                .maxSessionsPreventsLogin(true);      
                // 設置所要使用的 sessionRegistry,默認為 SessionRegistryImpl 實現類
                .sessionRegistry(sessionRegistry());
    }
    
    /**
     * 注冊 SessionRegistry,該 Bean 用於管理 Session 會話並發控制
     */
    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }
    
    /**
     * 配置 Session 的監聽器(注意:如果使用並發 Sessoion 控制,一般都需要配置該監聽器)
     * 解決 Session 失效后, SessionRegistry 中 SessionInformation 沒有同步失效的問題
     */
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
    //..
}

✏️ 測試

第一個瀏覽器訪問localhost:8080/login/page,輸入正確的用戶名、密碼成功登錄后,會重定向到/index

第二個瀏覽器訪問localhost:8080/login/page,輸入相同的用戶名、密碼訪問,重定向/login/page?error

上述配置限制了同一個用戶的二次登陸,但是不建議使用該配置。因為用戶一旦被盜號,那真正的用戶后續就無法登錄,只能通過聯系管理員解決,所以如果只能一個用戶 Session 登錄,一般是新會話登錄並將老會話踢下線。

場景二:如果同一個用戶在第二個地方登錄,則將第一個踢下線

📚 自定義最老會話被踢時的處理策略 CustomSessionInformationExpiredStrategy:


package com.example.config.security.session;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 前提:Session 並發處理的配置為 maxSessionsPreventsLogin(false)
 * 用戶的並發 Session 會話數量達到上限,新會話登錄后,最老會話會在下一次請求中失效,並執行此策略
 */
@Component
public class CustomSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {

    @Autowired
    private ObjectMapper objectMapper;

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
        HttpServletRequest request = event.getRequest();
        HttpServletResponse response = event.getResponse();

        // 最老會話被踢下線時顯示的信息
        UserDetails userDetails = (UserDetails) event.getSessionInformation().getPrincipal();
        String msg = String.format("用戶[%s]在另外一台機器登錄,您已下線!", userDetails.getUsername());

        String xRequestedWith = event.getRequest().getHeader("x-requested-with");
        // 判斷前端的請求是否為 ajax 請求
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 認證成功,響應 JSON 數據
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1, msg)));
        }else {
            // 返回到登錄頁面顯示信息
            AuthenticationException e = new AuthenticationServiceException(msg);
            request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", e);
            redirectStrategy.sendRedirect(request, response, "/login/page?error");
        }
    }
}

📚 修改安全配置類 SpringSecurityConfig,配置最老會話被踢時的處理策略

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private CustomSessionInformationExpiredStrategy sessionInformationExpiredStrategy;  // 自定義最老會話失效策略
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 開啟 Session 會話管理配置
        http.sessionManagement()
                // 設置 Session 會話失效時重定向路徑,默認為 loginPage()
                // .invalidSessionUrl("/login/page")
                // 配置使用自定義的 Session 會話失效處理策略
                .invalidSessionStrategy(invalidSessionStrategy)
                // 設置單用戶的 Session 最大並發會話數量,-1 表示不受限制
                .maximumSessions(1)
                // 設置為 true,表示某用戶達到最大會話並發數后,新會話請求會被拒絕登錄;
                // 設置為 false,表示某用戶達到最大會話並發數后,新會話請求訪問時,其最老會話會在下一次請求時失效
                .maxSessionsPreventsLogin(false)
                // 設置所要使用的 sessionRegistry,默認為 SessionRegistryImpl 實現類
                .sessionRegistry(sessionRegistry())
                // 最老會話在下一次請求時失效,並重定向到 /login/page
                //.expiredUrl("/login/page");
                // 最老會話在下一次請求時失效,並按照自定義策略處理
                .expiredSessionStrategy(sessionInformationExpiredStrategy);
    }

    /**
     * 注冊 SessionRegistry,該 Bean 用於管理 Session 會話並發控制
     */
    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }
     
    /**
     * 配置 Session 的監聽器(如果使用並發 Sessoion 控制,一般都需要配置)
     * 解決 Session 失效后, SessionRegistry 中 SessionInformation 沒有同步失效問題
     */
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }   
    //...
}

📚 測試

第一個瀏覽器訪問localhost:8080/login/page,輸入正確的用戶名、密碼成功登錄后,重定向到/index

第二個瀏覽器訪問localhost:8080/login/page,輸入相同的用戶名、密碼成功登錄后,重定向到/index

刷新第一個瀏覽器頁面,重定向到/login/page?error


原理分析

✌ AbstractAuthenticationProcessingFilter#doFilter

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
    private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
    //...
    // 過濾器 doFilter() 方法
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        if (!this.requiresAuthentication(request, response)) {
            //(1) 判斷該請求是否為 POST 方式的登錄表單提交請求,如果不是則直接放行,進入下一個過濾器
            chain.doFilter(request, response);
        } else {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Request is to process authentication");
            }
	    // Authentication 是用來存儲用戶認證信息的類,后續會進行詳細介紹
            Authentication authResult;
            try {
                //(2) 調用子類 UsernamePasswordAuthenticationFilter 重寫的方法進行身份認證,
                // 返回的 authResult 對象封裝認證后的用戶信息
                authResult = this.attemptAuthentication(request, response);
                if (authResult == null) {
                    return;
                }
                //(3) Session 策略處理(如果配置了用戶 Session 最大並發數,就是在此處進行判斷並處理)
                // 默認使用的是新創建的 NullAuthenticatedSessionStrategy 實例,而 UsernamePasswordAuthenticationFilter 過濾器使用的是 CompositeSessionAuthenticationStrategy 實例
                this.sessionStrategy.onAuthentication(authResult, request, response);
            } catch (InternalAuthenticationServiceException var8) {
                this.logger.error("An internal error occurred while trying to authenticate the user.", var8);
                //(4) 認證失敗,調用認證失敗的處理器
                this.unsuccessfulAuthentication(request, response, var8);
                return;
            } catch (AuthenticationException var9) {
                this.unsuccessfulAuthentication(request, response, var9);
                return;
            }

            //(4) 認證成功的處理
            if (this.continueChainBeforeSuccessfulAuthentication) {
                // 默認的 continueChainBeforeSuccessfulAuthentication 為 false,所以認證成功之后不進入下一個過濾器
                chain.doFilter(request, response);
            }
	    // 調用認證成功的處理器
            this.successfulAuthentication(request, response, chain, authResult);
        }
    }
    //...
    public void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionStrategy) {
        this.sessionStrategy = sessionStrategy;
    }    
}

上述的(3)過程,sessionStrategy 默認使用的是新創建的 NullAuthenticatedSessionStrategy 實例,所以在前面我們要求 MobileAuthenticationFilter 使用 Spring 容器中已存在的 SessionAuthenticationStrategy 實例,兩種登錄方式使用同一個 CompositeSessionAuthenticationStrategy 實例管理 Session。

✌ CompositeSessionAuthenticationStrategy#onAuthentication

public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
    //...
    private final List<SessionAuthenticationStrategy> delegateStrategies;

    public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
        SessionAuthenticationStrategy delegate;
        // delegateStrategies 是 Session 處理策略集合,會調用這些策略的 onAuthentication() 方法
        // 包括處理 Session 並發數的策略 ConcurrentSessionControlAuthenticationStrategy
        for(Iterator var4 = this.delegateStrategies.iterator(); var4.hasNext(); delegate.onAuthentication(authentication, request, response)) {
            delegate = (SessionAuthenticationStrategy)var4.next();
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Delegating to " + delegate);
            }
        }
    }
    //...
}

✌ ConcurrentSessionControlAuthenticationStrategy#onAuthentication

public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy {
    //...
    public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
        //(1) 獲取用戶在系統中的 Session 列表,元素類型為 SessionInformation,該類后續會介紹
        List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
        //(2) 獲取用戶在系統的並發 Session 數量
        int sessionCount = sessions.size();
        //(3) 獲取用戶能夠允許的最大並發 Session 數量
        int allowedSessions = this.getMaximumSessionsForThisUser(authentication);
        //(4) 判斷當前用戶的並發 Session 數量是否達到上限
        if (sessionCount >= allowedSessions) {
            // allowedSessions 為 -1,表示並發 Session 數量不受限制
            if (allowedSessions != -1) {
                //(5) 當已存在的 Session 數量等於最大並發 Session 數量時
                if (sessionCount == allowedSessions) (5) 當已存在的會話數等於最大會話數時
                    HttpSession session = request.getSession(false);
                    if (session != null) {
                        Iterator var8 = sessions.iterator();

                        while(var8.hasNext()) {
                            SessionInformation si = (SessionInformation)var8.next();
                            //(6) 當前驗證的會話如果並非新的會話,則不做任何處理
                            if (si.getSessionId().equals(session.getId())) {
                                return;
                            }
                        }
                    }
                }
		//(5) 否則,進行策略判斷
                this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
            }
        }
    }

    protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException {
        //(1) exceptionIfMaximumExceeded 就是配置類中 maxSessionsPreventsLogin() 方法參數
        if (!this.exceptionIfMaximumExceeded && sessions != null) {
            // 當配置 maxSessionsPreventsLogin(false) 時,才運行此處代碼
            //(2) 將用戶的 SessionInformation 列表按照最后一次訪問時間進行排序
            sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
            //(3) 獲取需要踢下線的 SessionInformation 列表(最老會話列表)
            int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
            List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
            Iterator var6 = sessionsToBeExpired.iterator();

            while(var6.hasNext()) {
                //(4) 將用戶最老會話列表中的所有 SessionInformation 對象記為過期
		// 注意這里只是標記,而不是真正的將 HttpSession 對象過期,
                // 只有最老會話再次請求或者達到過期時間,HttpSession 對象才會真正失效
                SessionInformation session = (SessionInformation)var6.next();
                session.expireNow();
            }
        } else {
            // 當配置 maxSessionsPreventsLogin(true) 時,運行此處代碼
            //(2) 當前(最新)會話的請求訪問拋出異常,返回信息(超出最大並發 Session 數量)
            throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded"));
        }
    }    
    //...
}

上述代碼中,獲取當前用戶在系統中的 Session 列表的元素類型是 SessionInformation,而不是 HttpSession,我們查看其源碼定義:

public class SessionInformation implements Serializable {
    private Date lastRequest;         // 最后一次訪問時間
    private final Object principal;   // UserDetails 對象
    private final String sessionId;   // SessionId
    private boolean expired = false;  // 是否過期
    // ... 
}

可以發現 SessionInformation 並不是真正的 HttpSession 對象,只是對 SessionId 和用戶信息的一次封裝。對於該類的具體使用,需要查看 SessionRegistryImpl 類。

✌ SessionRegistryImpl

public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {
    // 存放用戶(UserDetails)以及其對應的所有 SessionId
    private final ConcurrentMap<Object, Set<String>> principals;
    // 存放 sessionId 以及其對應的 SessionInformation
    private final Map<String, SessionInformation> sessionIds;

    public SessionRegistryImpl() {
        this.principals = new ConcurrentHashMap();
        this.sessionIds = new ConcurrentHashMap();
    }

    public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals, Map<String, SessionInformation> sessionIds) {
        this.principals = principals;
        this.sessionIds = sessionIds;
    }

    public List<Object> getAllPrincipals() {
        return new ArrayList(this.principals.keySet());
    }    
    
    // 根據用戶的 UserDetails 對象獲取用戶在系統中的所有 SessionInformation
    public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
        //(1) 獲取用戶在系統中的所有 SessionId 的集合
        Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);
        if (sessionsUsedByPrincipal == null) {
            return Collections.emptyList();
        } else {
            List<SessionInformation> list = new ArrayList(sessionsUsedByPrincipal.size());
            Iterator var5 = sessionsUsedByPrincipal.iterator();

            while(true) {
                SessionInformation sessionInformation;
                do {
                    do {
                        if (!var5.hasNext()) {
                            //(4) 返回用戶的 sessionInformation 集合
                            return list;
                        }

                        String sessionId = (String)var5.next();
                        //(2) 根據 SessionId 查詢對應的 sessionInformation 對象
                        sessionInformation = this.getSessionInformation(sessionId);
                    } while(sessionInformation == null);
                } while(!includeExpiredSessions && sessionInformation.isExpired());
		//(3) 注意這里要判斷 SessionInformation 是否過期,未過期的才能加入 list
                list.add(sessionInformation);
            }
        }
    }

    public SessionInformation getSessionInformation(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        return (SessionInformation)this.sessionIds.get(sessionId);
    }

    // 實現 onApplicationEvent 接口,表明處理 SessionDestrotyedEvent 事件
    public void onApplicationEvent(SessionDestroyedEvent event) {
        String sessionId = event.getId();
        // 當會話銷毀事件被觸發時,移除對應 sessionId 的相關數據
        this.removeSessionInformation(sessionId);
    }

    public void refreshLastRequest(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        SessionInformation info = this.getSessionInformation(sessionId);
        if (info != null) {
            info.refreshLastRequest();
        }
    }    
    
    // 注冊新的會話
    // SessionManagementConfigure 默認會將 RegisterSessionAuthenticationStrategy
    // 添加到一個組合式的 SessionAuthenticationStartegy 中,並由
    // AbstractAuthenticationProcessingFilter 在登錄成功時調用,從而觸發
    // registerNewSession 動作
    public void registerNewSession(String sessionId, Object principal) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        Assert.notNull(principal, "Principal required as per interface contract");
        if (this.getSessionInformation(sessionId) != null) {
            // 如果 sessionId 存在,則移除相關 SessionInformation
            this.removeSessionInformation(sessionId);
        }

        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Registering session " + sessionId + ", for principal " + principal);
        }

        // 添加 sessionId 以及其對應的 SessionInformatio
        this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
        // 添加用戶(UserDetails)以及其對應的 SessionId
sessionsUsedByPrincipal
        this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
            if (sessionsUsedByPrincipal == null) {
                sessionsUsedByPrincipal = new CopyOnWriteArraySet();
            }
            
            ((Set)sessionsUsedByPrincipal).add(sessionId);
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
            }

            return (Set)sessionsUsedByPrincipal;
        });
    }

    // 移除對應的會話信息
    public void removeSessionInformation(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        //(1) 獲取 SessionId 對應的 SessionInformation 對象
        SessionInformation info = this.getSessionInformation(sessionId);
        if (info != null) {
            if (this.logger.isTraceEnabled()) {
                this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
            }
	    //(2) 移除 SessionId 以及其對應的 SessionInformation
            this.sessionIds.remove(sessionId);
            //(3) 移除用戶以及其對應的 SessionId
            this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Removing session " + sessionId + " from principal's set of registered sessions");
                }

                sessionsUsedByPrincipal.remove(sessionId);
                if (sessionsUsedByPrincipal.isEmpty()) {
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug("Removing principal " + info.getPrincipal() + " from registry");
                    }

                    sessionsUsedByPrincipal = null;
                }

                if (this.logger.isTraceEnabled()) {
                    this.logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal);
                }

                return sessionsUsedByPrincipal;
            });
        }
    }
}

也就是說,用戶每登錄一個新 Session 會話都會創建一個對應的 SessionInformation 對象,該對象是 SessionId 和用戶信息的封裝,相關信息會緩存在 principals 和 sessionIds 這兩個 Map 集合中。需要注意的是 principals 集合采用的是以用戶信息(UserDetails)為 key 的設計,在 HashMap 中以對象為 key 必須重寫 hashCode 和 equals 方法,所以前面我們自定義 UserDetails 實現類重寫了這兩個方法:

@Data
public class User implements UserDetails {
	
    //...
    private String username;  // 用戶名
    //...

    @Override
    public boolean equals(Object obj) { 
        // 對於 UserDetails 對象而言,username 就是其唯一標識
        return obj instanceof User && this.username.equals(((User) obj).username);
    }

    @Override
    public int hashCode() {   
        // 對於 UserDetails 對象而言,username 就是其唯一標識
        return this.username.hashCode();
    }
}

前面也提到,當用戶的並發 Session 會話數量達到上限,新會話登錄時,只是將最老會話的 SessionInformation 對象標記為過期,最老會話對應的 HttpSession 對象是在該會話的下一次請求訪問時才被真正銷毀。而Spring Security是通過監聽 HttpSession 對象的銷毀事件來觸發會話信息集合 principals 和 sessionIds 的清理工作,但是默認情況下是沒有注冊過相關的監聽器,這會導致Spring Security無法正常清理過期或已注銷的會話。所以,前面我們在安全配置類注冊了 HttpSessionEventPublisher 的 Bean,用於監聽 HttpSession 的銷毀:

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 配置 Session 的監聽器(如果使用並發 Sessoion 控制,一般都需要配置)
     * 解決 Session 失效后, SessionRegistry 中 SessionInformation 沒有同步失效問題
     */
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
    //...
}

自定義使用

✍ 統計當前用戶未過期的並發 Session 數量

@Controller
public class TestController {
    //...
    @Autowired
    private SessionRegistry sessionRegistry;

    //...
    @GetMapping("/test4")
    @ResponseBody
    public Object getOnlineSession() {
        // 統計當前用戶未過期的並發 Session 數量
        UserDetails user = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();
        List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(user, false);
        return new ResultData<>(sessions.size());
    }
}

✍ 統計所有在線用戶

@Controller
public class TestController {
    //...
    @Autowired
    private SessionRegistry sessionRegistry;

    //...
    @GetMapping("/test5")
    @ResponseBody
    public Object getOnlineUsers() {
        // 統計所有在線用戶
        List<String> userList = sessionRegistry.getAllPrincipals().stream()
                .map(user -> ((UserDetails) user).getUsername())
                .collect(Collectors.toList());
        return new ResultData<>(userList);
    }    
}

使用 Redis 共享 Session

這里使用 Redis 來實現 Session 共享,實現步驟特別簡單。

💡 在 pom.xml 中添加依賴

<!-- redis 依賴啟動器 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- redis 數據源 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.8.0</version>
</dependency>

<!-- 使用 Redis 管理 session -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

💡 在 application.properties 添加配置

# Redis 服務器地址
spring.redis.host=localhost
# Redis 服務器連接端口
spring.redis.port=6379
# Redis 服務器連接密碼(默認無)
spring.redis.password=
# Redis數據庫索引(默認為0)
spring.redis.database=1
# 連接池最大連接數(使用負值表示沒有限制),默認 8
spring.redis.lettuce.pool.max-active=100
# 連接池大阻塞等待時間(使用負值表示沒有限制),默認 -1
spring.redis.lettuce.pool.max-wait=PT10S
# 連接池中的大空閑連接 默認 8
spring.redis.lettuce.pool.max-idle=10
# 連接池中的小空閑連接 默認 0
spring.redis.lettuce.pool.min-idle=1
# 連接超時時間
spring.redis.timeout=PT10S

# 使用 Redis 存儲 Session,默認為 none(使用內存存儲)
spring.session.store-type=redis
# 指定存儲 SessionId 的 Cookie 名(使用 Redis 存儲 Session 后,Cookie 名默認會變為 SESSION)
server.servlet.session.cookie.name=JSESSIONID

💡 Redis 存儲 Session 默認的序列化方式為 JdkSerializationRedisSerializer,所以存入 Session 的對象都要實現 Serializable 接口。因此,要保證前面代碼中的驗證碼 CheckCode 類實現 Serializable 接口:

// 驗證碼信息類
public class CheckCode implements Serializable {
    private String code;           // 驗證碼字符
    private LocalDateTime expireTime;  // 過期時間
    //...
}

💡 測試

訪問localhost:8080/login/page,查看 Redis 數據庫中的 key 數據:

spring:session是 Redis 存儲 Session 的 默認前綴,每一個 Session 都會創建 3 組數據,下面進行介紹:

☕️ 第一組:string 結構,用於記錄指定 Session 的剩余存活時間

上面的例子中,spring:session:sessions:9bf69e21-ddd6-4c53-b7e6-976c047158cb就是這個 string 結構的 key,后綴的字符串是 JSEESIONID 的 base64 解碼值。其 value 為空,TTL 時間為對應 Session 的剩余存活時間,如下所示:

☕️ 第二組:hash 結構,用於存儲指定 Session 的數據

上面的例子中,spring:session:sessions:9bf69e21-ddd6-4c53-b7e6-976c047158cb就是這個 hash 結構的 key,后綴的字符串是 JSEESIONID 的 base64 解碼值。hash 結構的 value 值本身就是一個 map 集合,其 value 如下所示:

上圖記錄分別為 lastAccessedTime(最后訪問時間)、creationTime(創建時間)、maxInactiveInterval(最大存活時間)、sessionAttr:屬性名 (Session 里存儲的屬性數據)。

☕️ 第三組:set 結構,用於記錄 Session 的過期時間

上面的例子中,spring:session:expirations:1602144780000 就是這個 set 結構的 key,后綴的字符串是一個整分鍾的時間戳,其 value 是一個 set 集合,存的是這個時間戳的分鍾內要失效的 Session 對應的 JSEESIONID 的 base64 解碼值,例如:


remember-me 失效解釋

當配置了.maximumSessions(1).maxSessionsPreventsLogin(false)要求只能一個用戶 Session 登錄時,我們在兩個地方使用相同的賬號,並且都勾選 remember-me 進行登錄。最老會話的下一次請求不但會使老會話強制失效,還會使數據庫中所有該用戶的所有 remember-me 記錄被刪除。

第一個瀏覽器勾選 remember-me 登錄后,數據庫中 remember-me 記錄:

第二個瀏覽器使用相同賬號勾選 remember-me 登錄后,數據庫中 remember-me 記錄:

當刷新第一個瀏覽器,頁面重定向到localhost:8080/login/page?error,顯示用戶在另外一個地方登錄的信息,老會話被強制下線,數據庫中 remember-me 記錄:

可以發現,該用戶的所有 remember-me 記錄被刪了。


免責聲明!

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



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