上篇文章中,咱們講了在 Spring Security 中如何踢掉前一個登陸用戶,或者禁止用戶二次登陸,經過一個簡單的案例,實現了咱們想要的效果。css
可是有一個不太完美的地方,就是咱們的用戶是配置在內存中的用戶,咱們沒有將用戶放到數據庫中去。正常狀況下,鬆哥在 Spring Security 系列中講的其餘配置,你們只須要參考Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文,將數據切換爲數據庫中的數據便可。html
可是,在作 Spring Security 的 session 併發處理時,直接將內存中的用戶切換爲數據庫中的用戶會有問題,今天咱們就來講說這個問題,順便把這個功能應用到微人事中(https://github.com/lenve/vhr)。java
本文是鬆哥最近在連載的 Spring Security 系列第 14 篇,閱讀本系列前面的文章有助於更好的理解本文:git
本文的案例將基於Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文來構建,因此重複的代碼我就不寫了,小夥伴們要是不熟悉能夠參考該篇文章。github
首先,咱們打開Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文中的案例,這個案例結合 Spring Data Jpa 將用戶數據存儲到數據庫中去了。web
而後咱們將上篇文章中涉及到的登陸頁面拷貝到項目中(文末能夠下載完整案例):spring
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7XB0viq6-1588898082940)(http://img.itboyhub.com/2020/04/20200506204420.png)]數據庫
並在 SecurityConfig 中對登陸頁面稍做配置:json
@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。後端
好了,配置完成後,咱們啓動項目,並行性多端登陸測試。
打開多個瀏覽器,分別進行多端登陸測試,咱們驚訝的發現,每一個瀏覽器都能登陸成功,每次登陸成功也不會踢掉已經登陸的用戶!
這是怎麼回事?
要搞清楚這個問題,咱們就要先搞明白 Spring Security 是怎麼保存用戶對象和 session 的。
Spring Security 中經過 SessionRegistryImpl 類來實現對會話信息的統一管理,咱們來看下這個類的源碼(部分):
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> { /** <principal:Object,SessionIdSet> */ private final ConcurrentMap<Object, Set<String>> principals; /** <sessionId:Object,SessionInformation> */ private final Map<String, SessionInformation> sessionIds; 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; }); } }
這個類的源碼仍是比較長,我這裏提取出來一些比較關鍵的部分:
看到這裏,你們發現一個問題,ConcurrentMap 集合的 key 是 principal 對象,用對象作 key,必定要重寫 equals 方法和 hashCode 方法,不然第一次存完數據,下次就找不到了,這是 JavaSE 方面的知識,我就不用多說了。
若是咱們使用了基於內存的用戶,咱們來看下 Spring Security 中的定義:
public class User implements UserDetails, CredentialsContainer { private String password; private final String username; private final Set<GrantedAuthority> authorities; 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 List<Role> roles; @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 方法便可。
因爲微人事目前是採用了 JSON 格式登陸,因此若是項目控制 session 併發數,就會有一些額外的問題要處理。
最大的問題在於咱們用自定義的過濾器代替了 UsernamePasswordAuthenticationFilter,進而致使前面所講的關於 session 的配置,通通失效。全部相關的配置咱們都要在新的過濾器 LoginFilter 中進行配置 ,包括 SessionAuthenticationStrategy 也須要咱們本身手動配置了。
這雖然帶來了一些工做量,可是作完以後,相信你們對於 Spring Security 的理解又會更上一層樓。
咱們來看下具體怎麼實現,我這裏主要列出來一些關鍵代碼,完整代碼你們能夠從 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 實例時,須要兩個參數:
最後,咱們還須要在處理完登陸數據以後,手動向 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,如此以後,咱們的項目就配置完成了。
接下來,重啓 vhr 項目,進行多端登陸測試,若是本身被人踢下線了,就會看到以下提示:
完整的代碼,我已經更新到 vhr 上了,你們能夠下載學習。
若是小夥伴們對鬆哥錄製的 vhr 項目視頻感興趣,不妨看看這裏:微人事項目視頻教程
好了,本文主要和小夥伴們介紹了一個在 Spring Security 中處理 session 併發問題時,可能遇到的一個坑,以及在先後端分離狀況下,如何處理 session 併發問題。不知道小夥伴們有沒有 GET 到呢?
本文第二小節的案例你們能夠從 GitHub 上下載:https://github.com/lenve/spring-security-samples