登录成功后,自动踢掉前一个登录用户,松哥第一次见到这个功能,就是在扣扣里边见到的,当时觉得挺好玩的。

自己做开发后,也遇到过一模一样的需求,正好最近的 Spring S* , b ) 3 a ] ~ecurity 系列正在连载,就结合 Spring S) g i .ecurity 来和大家聊一聊这个功能如何实现。

1.需求分析

在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑,松哥之前遇到的需求就是业务原因要求一个用户只能在一个设备上登录。

要实现一个用户不可以同时在两台设备上登录,我们有两种思路:

后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的& 0 C s R效果。

如果用户已经登录,则不允许后来者登录。

这种思路都能实现这个功能,具体使用哪一个,还要看我们具体的需求。

在 Spring SecZ / lurity 中,这两种都很好实现,一个配置就可以搞定。

2.具体实现

2.1 踢掉已经登录用户

想要用新的登录踢掉旧的登录,我们只需要将最7 l i大会话数设3 _ 5 y Y置为 1 即可,配置如下:

  1. @OverW D +ride
  2. protectedvoidconfigure(HttpSecurityhttp)throws) k \ !Exception{
  3. http.authorizeRequests()
  4. .anyRequest().authenticated()
  5. .and()
  6. .formLogin()
  7. .loginPage("/login.html")
  8. .permitAll()
  9. .and()} \ ; S j
  10. .csrf().disable()
  11. .sessionManagk Z M } L 3 Z 0ement()
  12. .maximumSessions(1);
  13. }

maximumSessions 表示配置最大会话数为 1,这样后面= , F w的登录就会自动踢掉前面的登录。这里其他的配置都是我们前面文章讲过的,我就不再重复介绍,文末可以下载案例完整代码。

配置完成后,分别用 ChD f b X x C ^rome 和 FL r { Zirefox 两个浏览器进c ^ \ + ~ U X [ b行测试(或者使用 Chrome 中的多用户功能)。

  1. Chrome 上登录成功后,访问 /hello 接口。
  2. Firefox 上登录成功后,^ P E k H M * o访问 /hello 接口v p + K S ( 3 ! {
  3. 在 Chrome 上再次访问 /hello 接口,此时会看到如下提示:
  1. Thissessionhe j u 3 [ { J Kasbeenexpired(possiblyduetomultipleconcurrentloginsbeingatth W ( A 4 P N r Remptedasthesameuser).

z l g 0 k _ 9 b以看到,这里说这个 session 已经过期,原因则是由于使用同一个用户进n ` t { r ^ l Q t行并发登录。

2.2 禁止新的登录

如果相同的用户已经登录了,你不想踢掉他,而是想禁止新的登录操作,那也好办,配置方式如下:

  1. @Overrid_ R d 0 ; \e
  2. proteJ q ( & t Yctedvk ? 7 R Xoidconfigure(HttpSecurityhttp)throwsException{
  3. http.authorizeRequestsx I Z 7 F ` C()
  4. .anyRequest().authenticated()
  5. .and()
  6. .formLogin()
  7. .l] [ K \ 0 ToginPage("/login.html")
  8. .permitAll()
  9. .and()
  10. .csrf().disable()
  11. .sessiow M Z f p t 9 3 PnManagement()I * N `
  12. .maximumSessions(1)
  13. .maxSessionsPreventsLogin(true);
  14. }

添加 maxSes7 Q f osionsPreventsLogin 配置即可。此时N 8 E S一个浏览器登录成功后,另外一个浏览器就登录不了了。

是不是很简– } e单?

不过还g \ :没完,我们还需要再提供一个 Bean:

  1. @Bean
  2. HttpSessionEventPublisherhttpSessionEventPublisher(){
  3. returnnewHttpSessionEven{ : \ ? r E } etPublish! O v [er();
  4. }

为什么要加这个 Bean 呢?因为在 Spring Security 中,它是通过监听 session 的销毁事件,来及时的清理 sesN T 1sion 的记录。用户从不同6 ` ; B ~ 7 q !的浏览器登录后,都会有对应Y d H的 session$ c 8 0 S b D,当用户注销登录之后,session 就会失效,但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的,这一个失效事件无法被 Spring 容器感知到^ # U c =,进而导致当用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来(小伙伴们可s | 2 i } R . $ T以自行尝试不添加上面的 Bean,然后让用户注销登录之后再重新登录)。R v i * y I

为了解决X 7 .这一问题,我们提供一个 HttpSessioV ` , B c (nEventPublisher ,这个类实现了 HttpSessio* ) H ; x ; ! nnListener 接口,在该 Bean 中,可以将 session 创建以, # 0 ; l q ? ) \j \ I X (销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到,该类4 c K I & ~ X部分源码如下:

  1. publicvoidsessionCreated(HttpSessionEventevent){
  2. HttpSessionCreatedEvente=newHttpSessionCreatc s , I _ u # y medEvent(event.getSession());
  3. getContext(event.getSession()\ , A A \ = n.getServletContext()).publishEvent(e);
  4. }
  5. publicvoidsessionDestroyed(HttpSessionEventevent){
  6. Ht~ 0 \ # ( ! `tpe C 4 : G x `Se~ O . T * 3 W pssionDestroyedEvente=newHttpSess5 Z C Y I *ionDestroyedEvent(eve% M \ 1 @ )nt.getSession());
  7. getContext(` $ a Q V C $event.getSession().getSe\ @ ` 9 4 frvletCO 0 + )ontext()).publishEvent(e);
  8. }

OK,虽然多了一个配置,但是依然很简a _ ? Y q 8 \ 6 p单!

3.实现原理

上面这个功能,在 Spring Security 中是怎么实现的呢?我们来稍微分析一下源码。c { S 3

首先我们知道,在用户登录的过程中,会经过 UsernamePasswordAuthenticationFiltP 5 Y ] W der,而 UsernamePasswordAuthenticationFilter 中过滤方法的调用是在 AbstractAuthenticationProcessingFilter 中触发的,我们来看下 A3 O q # m N X ObstractAuthenticationProcessingFilter#doFilter 方法的调用:

  1. publ) = & A f # _icvoiddoFilter(ServletRequestreq,ServletResponseresd w I z y i T,FilterChainchain)
  2. throwsIOException,ServletException{
  3. HttpServletRequestrequest% z } w \ = a=(HttpServletRequest)req;
  4. HttpServletResponseresponse=(HttpServletResponse)res;
  5. if(!requiresAuthentication(request,response)){
  6. chain.doFilter(request,response);
  7. return;
  8. }
  9. Authen} W , 3 kticationauthResult;
  10. try{
  11. authResult=atti x ` _ c G j cemptAutf v m , r : ( /hentication(request,response);
  12. if(authResult==null){
  13. return;
  14. }
  15. sessionStrategy.onAuthentication(authResult,request,response);
  16. }
  17. catch(InternalAuthenticationServiceExcepti0 + F j l ^ n E Ponfailed){
  18. unsW \ & .uccessfulAuthentication(requo C Hest,response,failed);
  19. return;
  20. }
  21. catch(AuthenticationExceptionfailed){
  22. unsuccK P [ hessfulAuthentication(request,r@ h -esponse,failed);
  23. re* \ cturn;
  24. }
  25. //Authenticationsuccess6 | / 8 q
  26. if(continueChainBeforeSuccessfulAuthentication){
  27. chain.doFilter(request,response);
  28. }d { I ~ u ?
  29. successfulAuthentication(request,response,chain,auth5 \ = ;Result);

在这段代码中,我们可以看到,调用 aL & 6 T z \ttemptAuthenticati? I + ; ] * o –on] \ ? o 方法走完认证流程之后,回来之后,接下来就是调用 sessionStrategy.onAuthentication 方法,这个方法I 8 } Y L就是用来处理 session 的并发问题的。具体) D d H v 3 L ] =在:

  1. publicclass! | O c { f kConcurrentSessionControln F *AuthenticationStrategyimplements
  2. MessageSourceAware,SessionAuthenticationStrategy{
  3. publicv) 2 3 x h 0 o `oidonAuthentication(Authenticationauthentication,
  4. HttpServletRequestrequest,HZ % 7 S n { R 5ttpServletResponseresponse){
  5. finalList<SessionInformation>sessions=sessionRegistry.getAllSessions(
  6. authentication.getPrincipal(),false);
  7. intsh Z k C YessionCount=sessionA N N O |s.size();
  8. intallowedSessions=getMaximumSessionsForThisUseK 6 , Z *r(authen; I Y N ? : Atication);
  9. if(sessionCouA % q l ? 8 + z unt<allowedSessions){
  10. //Theyhaven'tgottoomanyloginsessionsrunningatpresent
  11. return;
  12. }
  13. if(allowedSessions=N u z=-1){
  14. //Wepermitunlimitedlogins
  15. return;
  16. }
  17. if(sessionCount==allowedSessions){
  18. HttpSessionsession=request.getSession(false);
  19. if(session!=null){
  20. //OnlypeO L } a R _ b T Drmititthoughifthisrequestisassociatedwithoneofthe
  21. //alreadyregisteredsessions
  22. for(S% U VessionInformationsi:sessions){
  23. if(si.getSessionId().equals(session.getId())){
  24. return;
  25. }
  26. }
  27. }
  28. //Ifthesessionisnull,anewonewillbecreatedbytheparentclass,
  29. //exceedingtheallowednumber
  30. }
  31. allowableSessionsExceed 8 * ided(sessions,allowedSessions,sessio% 8 b 1 ( j d 1n. w 5 ( b uReg; r U B 2istry);
  32. }
  33. pro0 9 ktec) r L D ? { ! ztedvoidallowableSessionsExceeded(List<SG u 7 T d .essionInformation>sessions,
  34. intallowableSessions,SessionRegistryregistry)
  35. throwsSessionAuthenticationException{
  36. if(exq c M Q { & [ 0ceptionIfMaximumExceeded||(sessions==null)){
  37. throk p ! ~ 2wnewSessionAuthenticationException(messages.getMessage(
  38. "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
  39. newObject[]{allowableSessions},
  40. "Map v 6 Rximumsessionsof{0}forthisprincipalexceeded"));
  41. }
  42. //DetermX H 3 !ineleastrecentlyus` ! oedsessions,andmarkthemforinvalidation
  43. sessions.sort(Comparator.comparing(Sej Q e yssionInformation::getLastRequest));
  44. intmaxi~ = / i B = I y ZmumSessionsExcN r \ q p Y t XeededBy=sessions.size()-allowableSessions+1;
  45. List<SessionT S M ? [ X = ]Information>sessionsToBeExpired=k f 0 J ! f V isessions.subList(0,maximumSessionh + 5 u O ^ $sExceededBy);
  46. for(SessionInformationsession:sessionsToBeExpired){
  47. sq E } 4 J p Vession.expireNow();
  48. }
  49. }
  50. }

这段核心代码0 { e Z :我来给大家– t C Y # # o u稍微解释( a U G )下:

  1. 首先调用 sessionRegisj q ^ 5 7 C g %try.getAlt V \lSessions 方F 4 # = f {法获取当前用户的所有 session,该方法在调用时,传递两个参数,一个是当前用户的 authentication,另一个参数 false 表示不包含已经过期的 session(在用户登录成功后,会将用户的 sessionid 存起来,其中 key 是用户的主体(principal),value 则是该主! [ 7题对应的 sessioL c & ( Fnid 组成的一个集合)。
  2. z i N下来计算出当前用户已经有几个有效 s1 ? . qession 了,同时获取允许的 session 并发数。
  3. 如果当前 sessiov y g =n 数(sessionCount)小于 sess/ @ & ^ – C K yion 并发数(alloC 2 i \ m iwedSessions),则不做任何处理;如果 allowedSessionsz w V / H L 的值为 -1,表示对 session 数量不做任何限制。
  4. 如果当前 session 数(sessionCount)等于 session 并发数(allowedSessions),那7 4 z c ]就先看看当前 session 是否不为 null,并且已经存在于 sessions 中了,如9 W Y Q K P \ k F果已经存在了,那都是自家人,不做任何处理;如果当前 session 为 null,那么意味着将有一个新的 session 被创建出l / U $ 8 [来,届时当前 session 数(sev o 4 { u : ^ssionCount)就会超过 session 并发数(a& A # 3 X F $ LllowedSessions)。
  5. 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 allowableSessionsExceeded 中。
  6. allowableSessionsExceeded 方法中,首先会有 exceptionIfMaximum\ w GExceeded 属性,这就是我们在 SecurityConfig 中配置的 maxSessionsPrel s z D Y a cventsLogin 的值,默认为 false,如果p x l F L为 true,就直接抛出异常,那么这次登录就失败了(对应 2.2 小节的效果),如果为 false,则对 sessions 按照请求时间进行排序,然后再使多余的 session 过期即可(对应 2.1 小节的效果)。

4.小结

如此,两行简单的配置就实现了 Spring Security 中 session 的并发管理。是不是很简? 1 D ( E ? / l单?不过这里还有一个小小的坑,松哥将在下篇文章中继续和大家分析。

本文案例大家可以从 GitHub 上下载:https://github.com/lenve/spring-security-samples

本文转载自微信公众号「江南一点雨」,可以通y K /过以下二维码关注。转载本文请联系江南一2 Z [ U % O @点雨公众号。