当前位置: 首页 > 科技观察

SpringSecurity系列只允许一台设备在线

时间:2023-03-22 12:22:10 科技观察

,登录成功后自动踢出上次登录用户。松哥第一次看到这个功能是在口口,当时觉得挺好玩的。自己做开发后,也遇到了完全一样的需求。正好最新的SpringSecurity系列正在连载中,下面就和大家谈谈如何结合SpringSecurity实现这个功能。1、需求分析在同一个系统中,我们可能在一个终端上只允许一个用户登录。一般来说,这可能是出于安全考虑,但也有一些情况是出于业务考虑。松哥之前遇到的需求是业务原因要求一个用户只能在一台设备上登录。实现一个用户不能同时登录两台设备,我们有两个思路:后面的登录自动踢出前面的登录,就像QUOUQUAN中看到的效果。如果用户已经登录,则不允许以后登录。这个思路可以实现这个功能,具体使用哪一个就看我们的具体需求了。在SpringSecurity中,这两种实现起来都很容易,一次配置即可。2、具体实现2.1踢掉已登录用户如果想用新登录踢掉旧登录,我们只需要将最大会话数设置为1即可。配置如下:@Overrideprotectedvoidconfigure(HttpSecurityhttp)throwsException{http.authorizeRequests().anyRequest().authenticated().and().formLogin().loginPage("/login.html").permitAll().and().csrf().disable().sessionManagement().maximumSessions(1);}maximumSessions表示配置的最大会话数为1,这样以后的登录会自动踢掉之前的登录。这里的其他配置在我们之前的文章中都有提到,这里不再重复介绍。案例完整代码可在文末下载。配置完成后,使用Chrome和Firefox进行测试(或者使用Chrome中的多用户功能)。Chrome登录成功后,访问/hello界面。在Firefox上登录成功后,访问/hello界面。在Chrome上再次访问/hello界面,会看到如下提示:Thissessionhasbeenexpired(possiblyduetommultipleconcurrentloginsbeingattemptedasthesameuser)。您可以看到会话已过期,因为同一用户用于并发登录。2.2禁止新登录如果同一个用户已经登录了,你不想把他踢出去,而是想禁止新的登录操作,这很容易做到。配置方法如下:@Overrideprotectedvoidconfigure(HttpSecurityhttp)throwsException{http.authorizeRequests()。anyRequest().authenticated().and().formLogin().loginPage("/login.html").permitAll().and().csrf().disable().sessionManagement().maximumSessions(1).maxSessionsPreventsLogin(true);}只需添加maxSessionsPreventsLogin配置。这时一个浏览器登录成功后,另一个浏览器无法登录,是不是很简单?但是还没完,我们还需要再提供一个Bean:@BeanHttpSessionEventPublisherhttpSessionEventPublisher(){returnnewHttpSessionEventPublisher();}为什么要添加这个Bean呢?因为在SpringSecurity中,它会监听session的销毁事件,及时清理session记录。用户从不同的浏览器登录后,都会有相应的session。当用户注销再登录时,session会失效,但默认失效是通过调用StandardSession#invalidate方法实现的。这个失效事件无法被Spring容器感知到,导致用户注销再登录时,SpringSecurity没有及时清理session信息表,认为用户还在线,导致用户无法重新登录(可以尽量不要自己添加上面的Bean,然后让用户注销重新登录再登录)。为了解决这个问题,我们提供了一个HttpSessionEventPublisher,它实现了HttpSessionListener接口。在这个Bean中,可以及时感知session的创建和销毁事件,调用Spring中的事件机制发布相关的创建和销毁事件。出去被SpringSecurity感知。该类源码如下:){HttpSessionDestroyedEvente=newHttpSessionDestroyedEvent(event.getSession());getContext(event.getSession().getServletContext()).publishEvent(e);}OK,虽然多了一个配置,但是还是很简单的!3.实现原理上面的功能在SpringSecurity中是如何实现的?让我们稍微分析一下源代码。首先,我们知道在用户登录的过程中,UsernamePasswordAuthenticationFilter会经过,在AbstractAuthenticationProcessingFilter中触发UsernamePasswordAuthenticationFilter中filter方法的调用,我们看AbstractAuthenticationProcessingFilter#doFilterIO方法的调用:publicvoiddoFilter(ServletRequestreq,ServletFilthResponseres)ServletException{HttpServletRequestrequest=(HttpServletRequest)req;HttpServletResponseresponse=(HttpServletResponse)res;if(!requiresAuthentication(request,response)){chain.doFilter(request,response);return;}AuthenticationauthResult;try{authResult=att请求、响应);如果(authResult==null){返回;}sessionStrategy.onAuthentication(authResult、请求、响应);}catch(InternalAuthenticationServiceExceptionfailed){unsuccessfulAuthentication(请求、响应、失败);返回;}catch(AuthenticationExceptionrequestfailed){Authentication(,response,failed);return;}//认证成功if(continueChainBeforeSuccessfulAuthentication){chain.doFilter(request,response);}successfulAuthentication(request,response,chain,authResult);在这段代码中,我们可以看到在调用attemptAuthentication方法完成认证过程后,返回后,下一步就是调用sessionStrategy.onAuthentication方法,用于处理session并发问题具体在:publicclassConcurrentSessionControlAuthenticationStrategyimplementsMessageSourceAware,SessionAuthenticationStrategy{publicvoidonAuthentication(Authenticationauthentication,HttpServletRequestrequest,HttpServletResponseresponse){finalListsessions=sessionRegistry.getAllSessions(authentication.getPrincipal(),false);intsessionCount=sessions.size();intallowedSessions=getMaximumSessionsForThisUser(authentication);if(sessionCountsessions,intallowableSessions,SessionRegistryregistry)throwsSessionAuthenticationException{if(exceptionIfMaximumExceeded||(sessions==null)){thrownewSessionAuthenticationException(messages.getAuthentesColentedMessage("并发[]{allowableSessions},"Maximumsessionsof{0}forthisprincipalexceeded"));}//Determinelemostrecentlyusedsessions,andmarkthemforinvalidationssessions.sort(Comparator.comparing(SessionInformation::getLastRequest));intmaximumSessionsExceededBy=sessions.size()-列出SessionInformation>sessionsToBeExpired=sessions.subList(0,maximumSessionsExceedBy);for(SessionInformationsession:sessionsToBeExpired){session.expireNow();}}}给大家解释一下这段核心代码:首先调用sessionRegistry.getAllSessions方法获取当前用户的所有session,调用该方法时,有两个参数通过,一个是当前用户的认证,另一个参数false表示不包括已经过期的session(用户登录成功后,会保存用户的sessionid,其中key是用户的principal,value由主题A对应的sessionid集合)接下来计算当前用户已经有多少个有效会话,同时获取允许的并发会话数。如果当前会话数(sessionCount)小于并发会话数(allowedSessions),则不做任何处理;如果allowedSessions的值为-1,则表示没有会话数限制。如果当前会话数(sessionCount)等于并发会话数(allowedSessions),那么首先检查当前session是否不为null且已经存在于sessions中,如果已经存在则为自己家族,do不做任何处理;如果当前session为null,则意味着会创建一个新的session,那么当前session的个数(sessionCount)就会超过session的并发数(allowedSessions)。如果之前的代码返回失败,就会进入策略判断方法allowableSessionsExceeded。在allowableSessionsExceeded方法中,首先会有一个exceptionIfMaximumExceeded属性,也就是我们在SecurityConfig中配置的maxSessionsPreventsLogin的值。默认为假。如果为true,则直接抛出异常,本次登录失败(对应2.2节中的效果),如果为false,则根据请求时间对session进行排序,然后将多余的session过期(对应于第2.1节中的效果)。4.综上所述,简单的两行配置就实现了SpringSecurity中会话的并发管理。是不是很简单?不过这里还是有一个小坑,松哥下一篇文章会继续和大家一起分析。本文案例可从GitHub下载:https://github.com/lenve/spring-security-samples本文转载自微信公众号“江南一点雨”,你可以通过以下二维码关注。转载本文请联系江南一点鱼公众号。