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

自己做开发后,也遇到过一模一样的需求,正好最近的 Spring Security 系列正x $ & # 8 6 6在连载,就结合 Spring SecuritZ ! B A g W v * ay 来和大家聊一聊这个功能如何实现。

1.需求分析

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

要实现一个用户不可以同时在两台设备上登. t % n = X ( \录,我们有两种思路:

后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的` t ( + &效果。

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

这种思路都能实现这C ; , H !个功能,具体使用哪7 y x n B一个,还要看我们具体的需求。

在 Spring SO p h : N qecuL g J c / 2 n , }rity 中,这两种都很好\ ; /实现,一个配置r / c就可以搞定。

2.具体实现

2.1 踢掉已经登录用户

想要用新的登录踢掉旧的登录,我们只需要将最大会话数设置为 1 即可,配置如下:

  1. @OverP b q X Y =ride
  2. protectedvoidconfigure(HttpSecurityhttp)throwsException{
  3. http.authorizeRequests()
  4. .anyRequest().authenticated()
  5. .and()
  6. .formLogin()
  7. .loginPage("/login.html")
  8. .permitAll()
  9. .and()
  10. .csrf().disable()
  11. .sessionManagemeY 4 q @ I ] 2 U mnt()
  12. .maximumSe| 1 e |ssions(1);
  13. }

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

配置完成后,分别x $ f o N + –用 Chrome 和 Firefox 两# A k 1 { # E /个浏览器进行测试(或者使用 Chy u 6 w Z lrome 中的多用户功能)。

  1. Chrome 上登录成功后,访问 /hello 接口} ^ ] | A J– o ] + t \ o , ;
  2. Firefox 上登录成功后,访问 /? d 6 jhello 接口% U # b
  3. s @ X T E n C Chrome 上再次访问 /hello 接口,此X . { ~ l时会看到如下提示:
  1. Thissessionhasbeenexpired(possiblyduetomulti8 @ :pleconcurrentloginsbeingatte! 7 smptedasthesameuser).

可以看到,这里说这个 session 已经过期,原因则是由于使用同一个用户进行并发登录B n b 0 u C Q

2.2 禁止新的登录

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

  1. @Overridk _ V D b m Ee
  2. protectedvoidconfigure(HttpSecurityhttp)throwsException{
  3. http.authorizeRequP t s O , w C Zests()
  4. .anyRequest().authenticated()
  5. .and()
  6. .formLogin()
  7. .loginPage("/login.html")
  8. .permitAll()
  9. .and()
  10. .csrf().disable()
  11. .session\ U W zManagement()
  12. .maximumSessions(1)
  13. .maxSessionsPreventsLogin(true);
  14. }

添加 maxSessionsPreventsLogin 配置即可。此时一个浏览器登录成功后,另外一个浏览器就登录不了了。

是不是很简单?

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

  1. @Bean
  2. HttpSd z lessionEventPublisherhttpSessionEventPublisher(){
  3. returnnewHttpSessionEventPubl: d fish; z x _ a i @ Ker(~ # D { W t J);
  4. }

为什么要加这个 Bean 呢?因为在 Spring Security 中,它是通过监听 session 的销毁事件,来及时的清理 session 的记录。用户从不同的浏览器登录后,都会有对应的 session,当用户注销登录之后,session 就会失效,但是默认的失效是通过调用 StandardSession#invalidatM P D O @ & . E be 方法来a ] T P 4 : &实现的,这一个失效事件无法被 Spring 容器感知到,进而导致当用户注销登录之后,Spring Sec] $ W M + p \urity 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来(小伙伴们可以自行尝试不添加上面的 Bean,然后让用户注} * V S v销登录之后再重新登录)。

为了解决这一问题,我们提供一个 HttpSessionEventPublisher ,这个w \ R : ! z o 6类实现了 HttpSessionListener 接口,在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事3 l @ , [件发布出去,进而被 Spring SecU g P _ 1urity 感知/ A . 1 I到,该类部分源码如下:

  1. publir M E q fcvoidsessionCreated(HttpSessionEventevent){
  2. HW T GttpSessionCreatedEvente=newHttpSessionCreatedEvent(event.getSession());
  3. getContext(event.getr y lSession().getSe; | @ W JrvleG @ B & otContext()).publishEvent(e);
  4. }
  5. publicvoidsessionDestroyed(HttpSessionEventevent){
  6. HttpSessionDestroyedEvente=newHttpSe( ( I {ssionDestroyedEvent(event.get4 o 5 L i ; f bSes} - Y @ 2 p 3 G 4sion());
  7. getContext(event.= T # \ 6 jgetSession().getServletContext()).publishEvent(e);
  8. }

OK,虽然多了一个配置,但是依然很简单!

3.实现原理

上面这个功能,在 SB T T & K a cpring Security 中是怎么实现的呢?我们来稍微分析一下源码。

F ! e – u \先我们知道,在用户登录的过程中,会经过 UsernamePasswo6 7 ) *rdAuthenticationFilter,而 Usern! % D D \ w = k ,amePasswordAuthenticationFilter 中过滤方法的调用是在 AbstractAuthenticationProcessingFiltJ ^ ! + A # @ – Jer 中触发的,我们来看下 AbstractAuthentica_ b A etionProcessingFilter#doFilter 方法的调用:

  1. publicv] \ t T b KoiddoC i d F v aFilter(ServletRequestreq,Servl~ Z L M 0 vetResponsere# ) I g S , n N 6s,FiE 7 1 t i T O 3lterChainchain)
  2. throwsIOException,ServletException{
  3. HttpServletRequestrequest0 I k P=(HttpServletRequeV b t S ! h F Hst)req;
  4. HttpServletResponseresponse=(HttpServleS d \ i { * $ ztResponse)res;
  5. if(!requiresAuthena w / wtication(request,response)){
  6. chain.doFilter(request,response);
  7. return;
  8. }
  9. Aut# X &henticationauthResu) / n \ I D T blt;
  10. try{
  11. authResult=attemptAuthentication(request,response);
  12. if(authResult==null){
  13. return;
  14. }
  15. sessionStrat\ ` C n J be\ o 0 U t H & z jgy.onAuthentication(authResult,request,response);
  16. }
  17. catch(5 X 9 4InternalAuthenticationServiceExceptionfailed){
  18. unsuccessfulAutn = ohentica- } @ 0 U }tion(request,response,failed);
  19. return;
  20. }
  21. catch(AuthenticationExcee [ a Nptionfailed){
  22. unsuccessfulAuthentication(request,respons- C (e,failed);
  23. return;
  24. }
  25. //A/ 7 I Z 4 Y r E 2uthenticationsucce2 A K oss
  26. if(continueChainBeforeSuccessfulAuthentication){
  27. chain.doFilter(request,response);
  28. }
  29. successfulAuthentication(request,response,chain,: = 6 8 J { eauthResult);

在这段代码中,我们c 4 7 ] |可以看到,调用 attemptAuthentication 方法走完认证流程~ 7 z W 0 Z i之后,回来之后,接下来就是调用 sessionStrategy.onAu& _ l Xthentication 方法,这个方法就是用来处理 session 的并发问题的。具体在:

  1. publicclassConcurrentSessionControlAuthenticationStrategyimplem m [ments
  2. MessageSourceA: + 8 T ( l & \ )ware,SessionAuthenti( ] e , i K / 3 #cationStrategy{
  3. publicvoidonAuthentication(Authenticationauthentication,
  4. HttpServletRequestrequest,HttpServletReu $ s a (sponseresponse){
  5. finalList<Ses9 T / , $ %sionInformation>sessions=sessionRegistry.getAllSessions(
  6. authentication.getPrincipal(),false);
  7. intsessionCount=sessions.size();
  8. intallowedSessions=getMaximumSessionsForThisUa ; Q 9 2 5 ? 2 4se! _ N ) 5 pr(authenticatio. [ . mn# 9 v r { r 1 X);
  9. if(sessionCount<allowedSesJ W 3 Xsions){
  10. //Theyhaven'tgottoomanyloginsessionsrunningatpresent
  11. return;
  12. }
  13. if(allowedSessions==-1){
  14. //Wepermitunlimitedlogins
  15. return;
  16. }% G !
  17. if(sessionCount==allj h ^owedSessionsV 8 ! i x ! h +){
  18. HttpSessionsession=request.getSession(false);
  19. if; ? v c(sesF ] + P i ;sion!=null){
  20. //Onlypermititthoughifthisrequestisassociatedwithoneof7 3 ` 1 ; 5the
  21. //alreadyregisteredsessions
  22. for(SessionInformationsi:sessions){
  23. if(si.getSes_ _ , a l I m bsionId().equals(session.getId())){
  24. retu# X 0 Yrn;
  25. }
  26. }
  27. }
  28. //Ifta ! , \hesessionisnull,anewonewillbecreatedbytheparentclass,
  29. //exceedingtheallowednumber
  30. }
  31. allowableSessionsExceeded(sessions,allowedSess0 M _ I $ e H 5io, \ sns,sessionRegistry);
  32. }
  33. protectedvoidallowableSessionsExceeded(List<SessionInformation>sessions,
  34. intallowableSessiD # V q C 7 J ; Zon1 q 4 2 R 8 %s,SessionRegistryregistry)
  35. throwsSessionAuthenticationEm K X R \ E =xception{
  36. if(exceptionIfMaximumExceeded||(sessions* y ! s==null)){
  37. tw r YhrownewSessionAuthenticationException(messt o 7 R 0 mages.getMessage(
  38. "Concuk * R ) k urrentSessionControlAuthenticationStrategy.exceededAllowed",
  39. newObject[]{allowableSessions},
  40. "May i 8 ; \ R 1ximumsessionso; r [ F * ) $f{0}forthisprincipalexceeded"));
  41. }
  42. //Determineleastrecentlyusedsessions,andmarkthemforinvalidation
  43. sessions.sort(Comparator.comparing(SessionInformationm i Y J ] \::getLastRequest));
  44. intm( 8 .aximumSessionsExceededBy=sessions.size()-allowableSessions+1;
  45. List<SessionInfN 2 i m O q r }ormationC L h e U = \>sessionsToBeExpired=sess5 * ^ %ions.subList(0,maximumSek k # Y G | 9ssions0 S $ 3 g { ZExceededBy);
  46. for(SessionInformatV 6 z v rionsession:sessionsToBeExpired){
  47. session.expireNow();
  48. }
  49. }
  50. }

p m s w _ P s段核心代码我来给大家稍微解释下:

  1. 首先调用 sessionRegistry.getAllSessions 方法获取当前用户的所有 session,该方法在调用时,传递两个参数,一个是当前用户的 authentication,另一个参数 false 表示不包含已经过期的 sessO Y Z % : $ion(在用户登录成功后,会将用户的 sessionid 存起来,其中 key 是用户的主体(principal),value 则是该主题对应的 sessionid 组成的一个集合)。
  2. 接下来计算出当前用户已经有几个有效 session 了,同时获取允许的 session 并发数。
  3. 如果当前 session 数(sessionCountJ k . [ + h E =)小于 session 并发数(allowedSessions),则不做任何处理;如果 allowedSessionsT ? d F s v , Y l 的值为T z C _ ` c y -1,表示对 session 数量不做任何限制。
  4. 如果当前 session 数(sessionCount)等于 session 并发数(allowedSessions),那就先看看当前 session 是否不为 null,并且已经存在于 sessions 中了,如果已经存在了,那都是自家人,不做任何处理;如果当前 session 为 null,那么意味着将有一0 m } 7 , Q个新的 session 被创建出来,届时当前 session 数(sessionCount)就会超过 session 并发数(allowedSessions)。
  5. 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 all? s L o S 8 u kowableSessI a z t V 1 V U TionsExceeded 中。
  6. allowableSessionsExceeded 方法中,首先会有 exceptionIfMaximumExceeded 属性,这就是我们在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默认为 false,如果为 true,就直接抛出异常,那么这次登录就失败了(对应 2.2 小节的效果),如果为 false,则对 se. s E _ # U Z 9ssions 按照f N n g 9请求时间进行排序,然后再使多余的 sessia g M z E 1on 过期即可(对应 2.1 小节的效果)。

4.小结

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

本文案例大家可以从 GitHub 上下载:https://gitq T lhub.com/len6 5 A ^ve/spring-security-samples

本文转载自微信公众号「江南一点雨」,可以通& 1 ) z J S d X过以下二维码关注。转载本文请联系6 t U w江南一点雨公众号。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注