上篇文章中,咱們講了在 Spring Security 中如何踢掉前一個登陸用戶,或者禁止用戶二次登陸,經過一個簡單的案例,實現了咱們想要的效果。css
可是有一個不太完美的地方,就是咱們的用戶是配置在內存中的用戶,咱們沒有將用戶放到數據庫中去。正常狀況下,鬆哥在 Spring Security 系列中講的其餘配置,你們只須要參考Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文,將數據切換爲數據庫中的數據便可。html
可是,在作 Spring Security 的 session 併發處理時,直接將內存中的用戶切換爲數據庫中的用戶會有問題,今天咱們就來講說這個問題,順便把這個功能應用到微人事中(https://github.com/lenve/vhr)。git
本文是鬆哥最近在連載的 Spring Security 系列第 14 篇,閱讀本系列前面的文章有助於更好的理解本文:github
本文的案例將基於Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文來構建,因此重複的代碼我就不寫了,小夥伴們要是不熟悉能夠參考該篇文章。web
首先,咱們打開Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文中的案例,這個案例結合 Spring Data Jpa 將用戶數據存儲到數據庫中去了。spring
而後咱們將上篇文章中涉及到的登陸頁面拷貝到項目中(文末能夠下載完整案例):數據庫
並在 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
轉自:https://mp.weixin.qq.com/s/nfqFDaLDH8UJVx7mqqgHmQ