Spring Security 自動踢掉前一個登陸用戶,一個配置搞定!

登陸成功後,自動踢掉前一個登陸用戶,鬆哥第一次見到這個功能,就是在扣扣裏邊見到的,當時以爲挺好玩的。html

本身作開發後,也遇到過如出一轍的需求,正好最近的 Spring Security 系列正在連載,就結合 Spring Security 來和你們聊一聊這個功能如何實現。java

本文是本系列的第十三篇,閱讀前面文章有助於更好的理解本文:git

  1. 挖一個大坑,Spring Security 開搞!
  2. 鬆哥手把手帶你入門 Spring Security,別再問密碼怎麼解密了
  3. 手把手教你定製 Spring Security 中的表單登陸
  4. Spring Security 作先後端分離,咱就別作頁面跳轉了!通通 JSON 交互
  5. Spring Security 中的受權操做原來這麼簡單
  6. Spring Security 如何將用戶數據存入數據庫?
  7. Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!
  8. Spring Boot + Spring Security 實現自動登陸功能
  9. Spring Boot 自動登陸,安全風險要怎麼控制?
  10. 在微服務項目中,Spring Security 比 Shiro 強在哪?
  11. SpringSecurity 自定義認證邏輯的兩種方式(高級玩法)
  12. Spring Security 中如何快速查看登陸用戶 IP 地址等信息?

1.需求分析

在同一個系統中,咱們可能只容許一個用戶在一個終端上登陸,通常來講這多是出於安全方面的考慮,可是也有一些狀況是出於業務上的考慮,鬆哥以前遇到的需求就是業務緣由要求一個用戶只能在一個設備上登陸。github

要實現一個用戶不能夠同時在兩臺設備上登陸,咱們有兩種思路:spring

  • 後來的登陸自動踢掉前面的登陸,就像你們在扣扣中看到的效果。
  • 若是用戶已經登陸,則不容許後來者登陸。

這種思路都能實現這個功能,具體使用哪個,還要看咱們具體的需求。數據庫

在 Spring Security 中,這兩種都很好實現,一個配置就能夠搞定。後端

2.具體實現

2.1 踢掉已經登陸用戶

想要用新的登陸踢掉舊的登陸,咱們只須要將最大會話數設置爲 1 便可,配置以下:瀏覽器

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login.html")
            .permitAll()
            .and()
            .csrf().disable()
            .sessionManagement()
            .maximumSessions(1);
}

maximumSessions 表示配置最大會話數爲 1,這樣後面的登陸就會自動踢掉前面的登陸。這裏其餘的配置都是咱們前面文章講過的,我就再也不重複介紹,文末能夠下載案例完整代碼。安全

配置完成後,分別用 Chrome 和 Firefox 兩個瀏覽器進行測試(或者使用 Chrome 中的多用戶功能)。session

  1. Chrome 上登陸成功後,訪問 /hello 接口。
  2. Firefox 上登陸成功後,訪問 /hello 接口。
  3. 在 Chrome 上再次訪問 /hello 接口,此時會看到以下提示:
This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).

能夠看到,這裏說這個 session 已通過期,緣由則是因爲使用同一個用戶進行併發登陸。

2.2 禁止新的登陸

若是相同的用戶已經登陸了,你不想踢掉他,而是想禁止新的登陸操做,那也好辦,配置方式以下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login.html")
            .permitAll()
            .and()
            .csrf().disable()
            .sessionManagement()
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true);
}

添加 maxSessionsPreventsLogin 配置便可。此時一個瀏覽器登陸成功後,另一個瀏覽器就登陸不了了。

是否是很簡單?

不過還沒完,咱們還須要再提供一個 Bean:

@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}

爲何要加這個 Bean 呢?由於在 Spring Security 中,它是經過監聽 session 的銷燬事件,來及時的清理 session 的記錄。用戶從不一樣的瀏覽器登陸後,都會有對應的 session,當用戶註銷登陸以後,session 就會失效,可是默認的失效是經過調用 StandardSession#invalidate 方法來實現的,這一個失效事件沒法被 Spring 容器感知到,進而致使當用戶註銷登陸以後,Spring Security 沒有及時清理會話信息表,覺得用戶還在線,進而致使用戶沒法從新登陸進來(小夥伴們能夠自行嘗試不添加上面的 Bean,而後讓用戶註銷登陸以後再從新登陸)。

爲了解決這一問題,咱們提供一個 HttpSessionEventPublisher ,這個類實現了 HttpSessionListener 接口,在該 Bean 中,能夠將 session 建立以及銷燬的事件及時感知到,而且調用 Spring 中的事件機制將相關的建立和銷燬事件發佈出去,進而被 Spring Security 感知到,該類部分源碼以下:

public void sessionCreated(HttpSessionEvent event) {
    HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession());
    getContext(event.getSession().getServletContext()).publishEvent(e);
}
public void sessionDestroyed(HttpSessionEvent event) {
    HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
    getContext(event.getSession().getServletContext()).publishEvent(e);
}

OK,雖然多了一個配置,可是依然很簡單!

3.實現原理

上面這個功能,在 Spring Security 中是怎麼實現的呢?咱們來稍微分析一下源碼。

首先咱們知道,在用戶登陸的過程當中,會通過 UsernamePasswordAuthenticationFilter(參考:鬆哥手把手帶你捋一遍 Spring Security 登陸流程),而 UsernamePasswordAuthenticationFilter 中過濾方法的調用是在 AbstractAuthenticationProcessingFilter 中觸發的,咱們來看下 AbstractAuthenticationProcessingFilter#doFilter 方法的調用:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;
    if (!requiresAuthentication(request, response)) {
        chain.doFilter(request, response);
        return;
    }
    Authentication authResult;
    try {
        authResult = attemptAuthentication(request, response);
        if (authResult == null) {
            return;
        }
        sessionStrategy.onAuthentication(authResult, request, response);
    }
    catch (InternalAuthenticationServiceException failed) {
        unsuccessfulAuthentication(request, response, failed);
        return;
    }
    catch (AuthenticationException failed) {
        unsuccessfulAuthentication(request, response, failed);
        return;
    }
    // Authentication success
    if (continueChainBeforeSuccessfulAuthentication) {
        chain.doFilter(request, response);
    }
    successfulAuthentication(request, response, chain, authResult);

在這段代碼中,咱們能夠看到,調用 attemptAuthentication 方法走完認證流程以後,回來以後,接下來就是調用 sessionStrategy.onAuthentication 方法,這個方法就是用來處理 session 的併發問題的。具體在:

public class ConcurrentSessionControlAuthenticationStrategy implements
        MessageSourceAware, SessionAuthenticationStrategy {
    public void onAuthentication(Authentication authentication,
            HttpServletRequest request, HttpServletResponse response) {

        final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
                authentication.getPrincipal(), false);

        int sessionCount = sessions.size();
        int allowedSessions = getMaximumSessionsForThisUser(authentication);

        if (sessionCount < allowedSessions) {
            // They haven't got too many login sessions running at present
            return;
        }

        if (allowedSessions == -1) {
            // We permit unlimited logins
            return;
        }

        if (sessionCount == allowedSessions) {
            HttpSession session = request.getSession(false);

            if (session != null) {
                // Only permit it though if this request is associated with one of the
                // already registered sessions
                for (SessionInformation si : sessions) {
                    if (si.getSessionId().equals(session.getId())) {
                        return;
                    }
                }
            }
            // If the session is null, a new one will be created by the parent class,
            // exceeding the allowed number
        }

        allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
    }
    protected void allowableSessionsExceeded(List<SessionInformation> sessions,
            int allowableSessions, SessionRegistry registry)
            throws SessionAuthenticationException {
        if (exceptionIfMaximumExceeded || (sessions == null)) {
            throw new SessionAuthenticationException(messages.getMessage(
                    "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
                    new Object[] {allowableSessions},
                    "Maximum sessions of {0} for this principal exceeded"));
        }

        // Determine least recently used sessions, and mark them for invalidation
        sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
        int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
        List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
        for (SessionInformation session: sessionsToBeExpired) {
            session.expireNow();
        }
    }
}

這段核心代碼我來給你們稍微解釋下:

  1. 首先調用 sessionRegistry.getAllSessions 方法獲取當前用戶的全部 session,該方法在調用時,傳遞兩個參數,一個是當前用戶的 authentication,另外一個參數 false 表示不包含已通過期的 session(在用戶登陸成功後,會將用戶的 sessionid 存起來,其中 key 是用戶的主體(principal),value 則是該主題對應的 sessionid 組成的一個集合)。
  2. 接下來計算出當前用戶已經有幾個有效 session 了,同時獲取容許的 session 併發數。
  3. 若是當前 session 數(sessionCount)小於 session 併發數(allowedSessions),則不作任何處理;若是 allowedSessions 的值爲 -1,表示對 session 數量不作任何限制。
  4. 若是當前 session 數(sessionCount)等於 session 併發數(allowedSessions),那就先看看當前 session 是否不爲 null,而且已經存在於 sessions 中了,若是已經存在了,那都是自家人,不作任何處理;若是當前 session 爲 null,那麼意味着將有一個新的 session 被建立出來,屆時當前 session 數(sessionCount)就會超過 session 併發數(allowedSessions)。
  5. 若是前面的代碼中都沒能 return 掉,那麼將進入策略判斷方法 allowableSessionsExceeded 中。
  6. allowableSessionsExceeded 方法中,首先會有 exceptionIfMaximumExceeded 屬性,這就是咱們在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默認爲 false,若是爲 true,就直接拋出異常,那麼此次登陸就失敗了(對應 2.2 小節的效果),若是爲 false,則對 sessions 按照請求時間進行排序,而後再使多餘的 session 過時便可(對應 2.1 小節的效果)。

4.小結

如此,兩行簡單的配置就實現了 Spring Security 中 session 的併發管理。是否是很簡單?不過這裏還有一個小小的坑,鬆哥將在下篇文章中繼續和你們分析。

本文案例你們能夠從 GitHub 上下載:https://github.com/lenve/spring-security-samples

好了,不知道小夥伴們有沒有 GET 到呢?若是 GET 到了記得點個在看鼓勵下鬆哥哦~

相關文章
相關標籤/搜索