歡迎您光臨本站 註冊首頁

Spring Boot + Vue 前後端分離項目如何踢掉已登錄用戶

←手機掃碼閱讀     kyec555 @ 2020-05-08 , reply:0

我們講了在 Spring Security 中如何踢掉前一個登錄用戶,或者禁止用戶二次登錄,通過一個簡單的案例,實現了我們想要的效果。
但是有一個不太完美的地方,就是我們的用戶是配置在內存中的用戶,我們沒有將用戶放到數據庫中去。正常情況下,鬆哥在 Spring Security 系列中講的其他配置,大家只需要參考Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文,將數據切換為數據庫中的數據即可。
本文是本系列的第十三篇,閱讀前面文章有助於更好的理解本文:
但是,在做 Spring Security 的 session 併發處理時,直接將內存中的用戶切換為數據庫中的用戶會有問題,今天我們就來說說這個問題,順便把這個功能應用到微人事中(https://github.com/lenve/vhr )。
本文的案例將基於Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文來構建,所以重複的代碼我就不寫了,小夥伴們要是不熟悉可以參考該篇文章。
1.環境準備
首先,我們打開Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文中的案例,這個案例結合 Spring Data Jpa 將用戶數據存儲到數據庫中去了。
然後我們將上篇文章中涉及到的登錄頁面拷貝到項目中(文末可以下載完整案例):
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7XB0viq6-1588898082940)(http://img.itboyhub.com/2020/...]
並在 SecurityConfig 中對登錄頁面稍作配置:
@Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/js/**", "/css/**", "/images/**"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() ... .and() .formLogin() .loginPage("/login.html") .loginProcessingUrl("/doLogin") ... .and() .sessionManagement() .maximumSessions(1); }
這裡都是常規配置,我就不再多說。注意最後面我們將 session 數量設置為 1。
好了,配置完成後,我們啟動項目,並行性多端登錄測試。
打開多個瀏覽器,分別進行多端登錄測試,我們驚訝的發現,每個瀏覽器都能登錄成功,每次登錄成功也不會踢掉已經登錄的用戶!
這是怎麼回事?
2.問題分析
要搞清楚這個問題,我們就要先搞明白 Spring Security 是怎麼保存用戶對象和 session 的。
Spring Security 中通過 SessionRegistryImpl 類來實現對會話信息的統一管理,我們來看下這個類的源碼(部分):
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener

{ /***/ private final ConcurrentMap<Object, Set> principals; /***/ private final MapsessionIds; public void registerNewSession(String sessionId, Object principal) { if (getSessionInformation(sessionId) != null) { removeSessionInformation(sessionId); } sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date())); principals.compute(principal, (key, sessionsUsedByPrincipal) -> { if (sessionsUsedByPrincipal == null) { sessionsUsedByPrincipal = new CopyOnWriteArraySet<>(); } sessionsUsedByPrincipal.add(sessionId); return sessionsUsedByPrincipal; }); } public void removeSessionInformation(String sessionId) { SessionInformation info = getSessionInformation(sessionId); if (info == null) { return; } sessionIds.remove(sessionId); principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> { sessionsUsedByPrincipal.remove(sessionId); if (sessionsUsedByPrincipal.isEmpty()) { sessionsUsedByPrincipal = null; } return sessionsUsedByPrincipal; }); } }
這個類的源碼還是比較長,我這裡提取出來一些比較關鍵的部分:
首先大家看到,一上來聲明瞭一個 principals 對象,這是一個支持併發訪問的 map 集合,集合的 key 就是用戶的主體(principal),正常來說,用戶的 principal 其實就是用戶對象,鬆哥在之前的文章中也和大家講過 principal 是怎麼樣存入到 Authentication 中的(參見: Spring Security 登錄流程),而集合的 value 則是一個 set 集合,這個 set 集合中保存了這個用戶對應的 sessionid。
如有新的 session 需要添加,就在 registerNewSession 方法中進行添加,具體是調用 principals.compute 方法進行添加,key 就是 principal。
如果用戶註銷登錄,sessionid 需要移除,相關操作在 removeSessionInformation 方法中完成,具體也是調用 principals.computeIfPresent 方法,這些關於集合的基本操作我就不再贅述了。
看到這裡,大家發現一個問題,ConcurrentMap 集合的 key 是 principal 對象,用對象做 key,一定要重寫 equals 方法和 hashCode 方法,否則第一次存完數據,下次就找不到了,這是 JavaSE 方面的知識,我就不用多說了。
如果我們使用了基於內存的用戶,我們來看下 Spring Security 中的定義:
public class User implements UserDetails, CredentialsContainer { private String password; private final String username; private final Setauthorities; private final boolean accountNonExpired; private final boolean accountNonLocked; private final boolean credentialsNonExpired; private final boolean enabled; @Override public boolean equals(Object rhs) { if (rhs instanceof User) { return username.equals(((User) rhs).username); } return false; } @Override public int hashCode() { return username.hashCode(); } }
可以看到,他自己實際上是重寫了 equals 和 hashCode 方法了。
所以我們使用基於內存的用戶時沒有問題,而我們使用自定義的用戶就有問題了。
找到了問題所在,那麼解決問題就很容易了,重寫 User 類的 equals 方法和 hashCode 方法即可:
@Entity(name = "t_user") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; private boolean accountNonExpired; private boolean accountNonLocked; private boolean credentialsNonExpired; private boolean enabled; @ManyToMany(fetch = FetchType.EAGER,cascade = CascadeType.PERSIST) private Listroles; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(username, user.username); } @Override public int hashCode() { return Objects.hash(username); } ... ... }
配置完成後,重啟項目,再去進行多端登錄測試,發現就可以成功踢掉已經登錄的用戶了。
如果你使用了 MyBatis 而不是 Jpa,也是一樣的處理方案,只需要重寫登錄用戶的 equals 方法和 hashCode 方法即可。
3.微人事應用
3.1 存在的問題
由於微人事目前是採用了 JSON 格式登錄,所以如果項目控制 session 併發數,就會有一些額外的問題要處理。
最大的問題在於我們用自定義的過濾器代替了 UsernamePasswordAuthenticationFilter,進而導致前面所講的關於 session 的配置,統統失效。所有相關的配置我們都要在新的過濾器 LoginFilter 中進行配置 ,包括 SessionAuthenticationStrategy 也需要我們自己手動配置了。
這雖然帶來了一些工作量,但是做完之後,相信大家對於 Spring Security 的理解又會更上一層樓。
3.2 具體應用
我們來看下具體怎麼實現,我這裡主要列出來一些關鍵代碼,完整代碼大家可以從 GitHub 上下載:https://github.com/lenve/vhr 。
首先第一步,我們重寫 Hr 類的 equals 和 hashCode 方法,如下:
public class Hr implements UserDetails { ... ... @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Hr hr = (Hr) o; return Objects.equals(username, hr.username); } @Override public int hashCode() { return Objects.hash(username); } ... ... }
接下來在 SecurityConfig 中進行配置。
這裡我們要自己提供 SessionAuthenticationStrategy,而前面處理 session 併發的是 ConcurrentSessionControlAuthenticationStrategy,也就是說,我們需要自己提供一個 ConcurrentSessionControlAuthenticationStrategy 的實例,然後配置給 LoginFilter,但是在創建 ConcurrentSessionControlAuthenticationStrategy 實例的過程中,還需要有一個 SessionRegistryImpl 對象。
前面我們說過,SessionRegistryImpl 對象是用來維護會話信息的,現在這個東西也要我們自己來提供,SessionRegistryImpl 實例很好創建,如下:
@Bean SessionRegistryImpl sessionRegistry() { return new SessionRegistryImpl(); }
然後在 LoginFilter 中配置 SessionAuthenticationStrategy,如下:
@Bean LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> { //省略 } ); loginFilter.setAuthenticationFailureHandler((request, response, exception) -> { //省略 } ); loginFilter.setAuthenticationManager(authenticationManagerBean()); loginFilter.setFilterProcessesUrl("/doLogin"); ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry()); sessionStrategy.setMaximumSessions(1); loginFilter.setSessionAuthenticationStrategy(sessionStrategy); return loginFilter; }
我們在這裡自己手動構建 ConcurrentSessionControlAuthenticationStrategy 實例,構建時傳遞 SessionRegistryImpl 參數,然後設置 session 的併發數為 1,最後再將 sessionStrategy 配置給 LoginFilter。
其實上篇文章中,我們的配置方案,最終也是像上面這樣,只不過現在我們自己把這個寫出來了而已。
這就配置完了嗎?沒有!session 處理還有一個關鍵的過濾器叫做 ConcurrentSessionFilter,本來這個過濾器是不需要我們管的,但是這個過濾器中也用到了 SessionRegistryImpl,而 SessionRegistryImpl 現在是由我們自己來定義的,所以,該過濾器我們也要重新配置一下,如下:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() ... http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> { HttpServletResponse resp = event.getResponse(); resp.setContentType("application/json;charset=utf-8"); resp.setStatus(401); PrintWriter out = resp.getWriter(); out.write(new ObjectMapper().writeValueAsString(RespBean.error("您已在另一臺設備登錄,本次登錄已下線!"))); out.flush(); out.close(); }), ConcurrentSessionFilter.class); http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class); }
在這裡,我們重新創建一個 ConcurrentSessionFilter 的實例,代替系統默認的即可。在創建新的 ConcurrentSessionFilter 實例時,需要兩個參數:
sessionRegistry 就是我們前面提供的 SessionRegistryImpl 實例。
第二個參數,是一個處理 session 過期後的回調函數,也就是說,當用戶被另外一個登錄踢下線之後,你要給什麼樣的下線提示,就在這裡來完成。
最後,我們還需要在處理完登錄數據之後,手動向 SessionRegistryImpl 中添加一條記錄:
public class LoginFilter extends UsernamePasswordAuthenticationFilter { @Autowired SessionRegistry sessionRegistry; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //省略 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); setDetails(request, authRequest); Hr principal = new Hr(); principal.setUsername(username); sessionRegistry.registerNewSession(request.getSession(true).getId(), principal); return this.getAuthenticationManager().authenticate(authRequest); } ... ... } }
在這裡,我們手動調用 sessionRegistry.registerNewSession 方法,向 SessionRegistryImpl 中添加一條 session 記錄。
OK,如此之後,我們的項目就配置完成了。

[kyec555 ] Spring Boot + Vue 前後端分離項目如何踢掉已登錄用戶已經有298次圍觀

http://coctec.com/docs/vue-js/show-post-233331.html