登錄成功後,自動踢掉前一個登錄用戶,鬆哥第一次見到這個功能,就是在扣扣裡邊見到的,當時覺得挺好玩的。
自己做開發後,也遇到過一模一樣的需求,正好最近的 Spring Security 系列正在連載,就結合 Spring Security 來和大家聊一聊這個功能如何實現。
本文是本系列的第十三篇,閱讀前面文章有助於更好的理解本文:
1.需求分析
在同一個系統中,我們可能只允許一個用戶在一個終端上登錄,一般來說這可能是出於安全方面的考慮,但是也有一些情況是出於業務上的考慮,鬆哥之前遇到的需求就是業務原因要求一個用戶只能在一個設備上登錄。
要實現一個用戶不可以同時在兩臺設備上登錄,我們有兩種思路:
後來的登錄自動踢掉前面的登錄,就像大家在扣扣中看到的效果。
如果用戶已經登錄,則不允許後來者登錄。
這種思路都能實現這個功能,具體使用哪一個,還要看我們具體的需求。
在 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 中的多用戶功能)。
Chrome 上登錄成功後,訪問 /hello 接口。
Firefox 上登錄成功後,訪問 /hello 接口。
在 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
[月球人 ] Spring Security 自動踢掉前一個登錄用戶的實現代碼已經有256次圍觀