Spring Boot + Vue 先後端分離項目,如何踢掉已登陸用戶?

上篇文章中,咱們講了在 Spring Security 中如何踢掉前一個登陸用戶,或者禁止用戶二次登陸,經過一個簡單的案例,實現了咱們想要的效果。css

可是有一個不太完美的地方,就是咱們的用戶是配置在內存中的用戶,咱們沒有將用戶放到數據庫中去。正常狀況下,鬆哥在 Spring Security 系列中講的其餘配置,你們只須要參考Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文,將數據切換爲數據庫中的數據便可。html

可是,在作 Spring Security 的 session 併發處理時,直接將內存中的用戶切換爲數據庫中的用戶會有問題,今天咱們就來講說這個問題,順便把這個功能應用到微人事中(https://github.com/lenve/vhr)。java

本文是鬆哥最近在連載的 Spring Security 系列第 14 篇,閱讀本系列前面的文章有助於更好的理解本文: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 地址等信息?
  13. Spring Security 自動踢掉前一個登陸用戶,一個配置搞定!

本文的案例將基於Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!一文來構建,因此重複的代碼我就不寫了,小夥伴們要是不熟悉能夠參考該篇文章。github

1.環境準備

首先,咱們打開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。後端

好了,配置完成後,咱們啓動項目,並行性多端登陸測試。

打開多個瀏覽器,分別進行多端登陸測試,咱們驚訝的發現,每一個瀏覽器都能登陸成功,每次登陸成功也不會踢掉已經登陸的用戶!

這是怎麼回事?

2.問題分析

要搞清楚這個問題,咱們就要先搞明白 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;
		});
	}

}

這個類的源碼仍是比較長,我這裏提取出來一些比較關鍵的部分:

  1. 首先你們看到,一上來聲明瞭一個 principals 對象,這是一個支持併發訪問的 map 集合,集合的 key 就是用戶的主體(principal),正常來講,用戶的 principal 其實就是用戶對象,鬆哥在以前的文章中也和你們講過 principal 是怎麼樣存入到 Authentication 中的(參見:鬆哥手把手帶你捋一遍 Spring Security 登陸流程),而集合的 value 則是一個 set 集合,這個 set 集合中保存了這個用戶對應的 sessionid。
  2. 若有新的 session 須要添加,就在 registerNewSession 方法中進行添加,具體是調用 principals.compute 方法進行添加,key 就是 principal。
  3. 若是用戶註銷登陸,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 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 方法便可。

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 實例時,須要兩個參數:

  1. sessionRegistry 就是咱們前面提供的 SessionRegistryImpl 實例。
  2. 第二個參數,是一個處理 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,如此以後,咱們的項目就配置完成了。

接下來,重啓 vhr 項目,進行多端登陸測試,若是本身被人踢下線了,就會看到以下提示:

完整的代碼,我已經更新到 vhr 上了,你們能夠下載學習。

若是小夥伴們對鬆哥錄製的 vhr 項目視頻感興趣,不妨看看這裏:微人事項目視頻教程

4.小結

好了,本文主要和小夥伴們介紹了一個在 Spring Security 中處理 session 併發問題時,可能遇到的一個坑,以及在先後端分離狀況下,如何處理 session 併發問題。不知道小夥伴們有沒有 GET 到呢?

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

相關文章
相關標籤/搜索