写在前面 经过前面的学习,我们已经对SpringSecurity用户登录内容有了较为深刻的认识,接下来学习一个较为有意思的功能—自动踢掉登录用户。
你可能不知道什么是“自动踢掉登录用户”,但是你可能遇到过这种场景:当你在A电脑上登录了QQ,然后再在B电脑上登录时,QQ就会将你从A电脑上踢下线,也就是告知你同一时刻同一平台你只能登录一个实例。
从本篇文章开始,如果没有特殊说明,那么都是新建一个gitee分支,在新的项目上进行编码。在gitee上新建一个kickoff-user分支,然后将本地分支切换过去,接下来开始进行代码逻辑编写。
需求描述 在实际工作中,出于安全考量,我们可能要求某个系统只允许一个用户在一个终端上登录,更有甚者要求一个用户在一个设备上登录。如钉钉,这款软件就规定用户最多只能在三台手机上登录(仅仅针对手机端),显然这就是对用户登录的设备进行了绑定。
需要说明的是,终端和设备两者是不同的,终端包括PC、手机端等方式,而设备就是指单纯的某台具体的手机。
要实现上述功能,即一个用户无法同时在两台设备上登录,可以有两种实现思路: (1)后来的登录用户踢掉前面已经登录的用户,QQ就是这种方式; (2)如果用户已经登录,那么就不允许后来者登录,一般银行就是采用这种方式。
既然上述两种思路都能实现上述功能,那么具体选择哪种则需要结合具体的使用场景。庆幸的是SpringSecurity对这两种思路都提供了具体的实现,下面将分别进行介绍。
项目实例化 第一步 ,使用IDEA创建一个名为kickoff-user
的SpringBoot工程,并在其pom.xml依赖文件中添加如下依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies>
第二步 ,新建config包,并在该包内新建一个SecurityConfig
类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); } @Override protected AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } @Bean @Override protected UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("envy").password("1234").roles("admin").build()); return manager; } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/js/**", "/css/**","/login.html"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .loginProcessingUrl("/login") .permitAll() .and() .csrf().disable(); } }
可以看到此处使用了基于内存的验证方式,同时自定义了登录表单,且登录页面为/login.html
,同时登录页面处理逻辑使用的是默认的接口/login
,前者是GET方式,后者则是POST方式。第三步 ,在static目录下新建登录页面login.html
,里面的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <div align="center"> <h1>用户登录</h1> <form action="/login" method="post"> <input type="text" placeholder="请输入用户名" name="username"><br> <input type="password" placeholder="请输入密码" name="password"><br> <button type="submit">登录</button> </form> </div> </body> </html>
第四步 ,新建controller包,并在里面新建一个HelloController
类,里面的代码如下:
1 2 3 4 5 6 7 @RestController public class HelloController { @GetMapping("/hello") public String hello(){ return "hello,world!"; } }
第五步 ,启动项目进行测试。用户访问/hello
接口就会跳转到/login.html
页面,输入正确的用户名和密码后,点击登录即可完成登录,页面显示既定的hello,world!
信息。
踢掉已经登录用户 如果想用新登录的用户来踢掉已经登录的用户,开发者只需将最大会话数设置为1,这样就能保证每次该用户只有一个会话。
在SecurityConfig#configure(HttpSecurity http)
方法内新增如下配置信息:
1 2 3 4 5 6 7 8 9 @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() ...... .csrf().disable() .sessionManagement() .maximumSessions(1); }
其实就是增加了一个sessionManagement
配置项,并设置最大会话数maximumSessions
的值为1,这样如果该用户再次登录时,系统就会自动将前面已登录的用户踢掉。
接下来就是启动项目,开始测试,准备两个浏览器,一个Chrome,一个FireFox或者微软自带的Edge浏览器(别用IE): 第一步,使用Chrome浏览器访问/hello
接口,页面跳转到登录页面,输入正确信息后,页面跳转到/hello
接口,并显示hello,world!
; 第二步,使用FireFox或者微软自带的Edge浏览器,访问/hello
接口,页面跳转到登录页面,输入正确信息后,页面跳转到/hello
接口,并显示hello,world!
; 第三步,刷新Chrome浏览器此时的页面,即/hello
接口页面,可以发现页面显示如下信息:
可以看到此时页面已经出现提示信息,告知我们此会话已过期(可能是由于同一用户尝试多个并发登录)。
禁止用户新的登录 如果开发者不想让之前已经登录的用户下线,那么可以通过禁止用户新的登录这一方式来实现。
在SecurityConfig#configure(HttpSecurity http)
方法内新增如下配置信息:
1 2 3 4 5 6 7 8 9 10 @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() ...... .csrf().disable() .sessionManagement() .maximumSessions(1) .maxSessionsPreventsLogin(true); }
可以看到此处仅仅在之前的基础上添加了maxSessionsPreventsLogin(true)
这一配置。之后启动项目进行测试,发现当用在进行上述测试第三步的时候,页面跳转到了http://localhost:8080/login.html?error
页面,同时清空了用户输入的信息,这一就使得用户无法新登录。但是当用户从第一步中使用的Chrome浏览器上退出时,第二步中依旧无法完成登录功能,这样就导致只要用户之前登录过系统,无论其是否退出,后续都无法在其他平台上登录,这和我们预想的压根不一样。
其实仅仅上述配置是不够的,我们还需要提供一个HttpSessionEventPublisher
实例。在SecurityConfig
类中新增如下代码:
1 2 3 4 @Bean HttpSessionEventPublisher httpSessionEventPublisher(){ return new HttpSessionEventPublisher(); }
问题来了,为什么需要提供一个HttpSessionEventPublisher
实例,原因在于SpringSecurity是通过监听Session的销毁事件来及时清理Session的记录。我们知道,当用户从不同的浏览器登录后,它都会有对应的Session,然后用户注销登录后,那么对应的Session就会失效。但是Session默认的失效是通过调用StandardSession#invalidate()
方法来实现的,而这一生效事件无法被Spring容器所感知到,这样会导致用户注销登录之后,SpringSecurity没有及时清理会话信息表,认为用户有依旧还在线,进而导致用户无法重新登录。
通过提供一个HttpSessionEventPublisher
实例就能解决这个问题,查看一下这个HttpSessionEventPublisher
的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class HttpSessionEventPublisher implements HttpSessionListener { private static final String LOGGER_NAME = HttpSessionEventPublisher.class.getName(); public HttpSessionEventPublisher() { } ApplicationContext getContext(ServletContext servletContext) { return SecurityWebApplicationContextUtils.findRequiredWebApplicationContext(servletContext); } public void sessionCreated(HttpSessionEvent event) { HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession()); Log log = LogFactory.getLog(LOGGER_NAME); if (log.isDebugEnabled()) { log.debug("Publishing event: " + e); } this.getContext(event.getSession().getServletContext()).publishEvent(e); } public void sessionDestroyed(HttpSessionEvent event) { HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession()); Log log = LogFactory.getLog(LOGGER_NAME); if (log.isDebugEnabled()) { log.debug("Publishing event: " + e); } this.getContext(event.getSession().getServletContext()).publishEvent(e); } }
可以发现这个HttpSessionEventPublisher
类实现HttpSessionListener
接口,且该类提供了session创建、销毁的方法,因此在该Bean中可以将session创建、销毁的事件及时感知到,并调用Spring中的事件机制将相关的创建和销毁事件发布出去,进而被SpringSecurity所感知到。
源码分析 现在我们通过阅读源码来分析SpringSecurity中是如何实现上述两个功能的。
通过前面《详解登录流程》一文的学习,我们知道用户在登录过程中会经过UsernamePasswordAuthenticationFilter
这个过滤器,而这个过滤器中的attemptAuthentication()
方法则是在在其父类AbstractAuthenticationProcessingFilter
的doFilter()
方法中被触发的,这里再次粘贴AbstractAuthenticationProcessingFilter#doFilter()
方法的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; if (!this.requiresAuthentication(request, response)) { chain.doFilter(request, response); } else { if (this.logger.isDebugEnabled()) { this.logger.debug("Request is to process authentication"); } Authentication authResult; try { authResult = this.attemptAuthentication(request, response); if (authResult == null) { return; } this.sessionStrategy.onAuthentication(authResult, request, response); } catch (InternalAuthenticationServiceException var8) { this.logger.error("An internal error occurred while trying to authenticate the user.", var8); this.unsuccessfulAuthentication(request, response, var8); return; } catch (AuthenticationException var9) { this.unsuccessfulAuthentication(request, response, var9); return; } if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } this.successfulAuthentication(request, response, chain, authResult); } }
从上述源码中可以知道调用attemptAuthentication()
方法完成用户认证之后,接着调用sessionStrategy.onAuthentication()
方法来处理session并发相关的内容,查看一下这个sessionStrategy,发现它是一个SessionAuthenticationStrategy
对象,而这个SessionAuthenticationStrategy
是一个接口,里面只有一个onAuthentication()
方法:
1 2 3 public interface SessionAuthenticationStrategy { void onAuthentication(Authentication var1, HttpServletRequest var2, HttpServletResponse var3) throws SessionAuthenticationException; }
因此我们需要找到SessionAuthenticationStrategy
的实现类,且该类对onAuthentication()
方法的逻辑进行了实现:
可以看到上面有一个名为ConcurrentSessionControlAuthenticationStrategy
的实现类,含义就是“并发会话控制身份验证策略”,这个类用来控制session的并发。查看一下该类的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy { protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private final SessionRegistry sessionRegistry; private boolean exceptionIfMaximumExceeded = false; private int maximumSessions = 1; public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) { Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null"); this.sessionRegistry = sessionRegistry; } public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) { List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false); int sessionCount = sessions.size(); int allowedSessions = this.getMaximumSessionsForThisUser(authentication); if (sessionCount >= allowedSessions) { if (allowedSessions != -1) { if (sessionCount == allowedSessions) { HttpSession session = request.getSession(false); if (session != null) { Iterator var8 = sessions.iterator(); while(var8.hasNext()) { SessionInformation si = (SessionInformation)var8.next(); if (si.getSessionId().equals(session.getId())) { return; } } } } this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry); } } } protected int getMaximumSessionsForThisUser(Authentication authentication) { return this.maximumSessions; } protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException { if (!this.exceptionIfMaximumExceeded && sessions != null) { sessions.sort(Comparator.comparing(SessionInformation::getLastRequest)); int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1; List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy); Iterator var6 = sessionsToBeExpired.iterator(); while(var6.hasNext()) { SessionInformation session = (SessionInformation)var6.next(); session.expireNow(); } } else { throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded")); } } public void setExceptionIfMaximumExceeded(boolean exceptionIfMaximumExceeded) { this.exceptionIfMaximumExceeded = exceptionIfMaximumExceeded; } public void setMaximumSessions(int maximumSessions) { Assert.isTrue(maximumSessions != 0, "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); this.maximumSessions = maximumSessions; } public void setMessageSource(MessageSource messageSource) { Assert.notNull(messageSource, "messageSource cannot be null"); this.messages = new MessageSourceAccessor(messageSource); } }
这里面有很多方法,这里主要以分析其中的onAuthentication()
方法为主: (1)首先调用sessionRegistry.getAllSessions(authentication.getPrincipal(), false)
方法来获取当前用户所有的session,里面传入两个参数,第一个为当前用户的身份,用户名或者用户本身;第二个参数false,表示不包含已经过期的session。因为用户在登录成功后,会将用户的sessionId进行保存,其中key是用户的身份,也就是principal,而value则是该身份所对应的一系列sessionId的集合。可以看到这个sessionRegistry.getAllSessions(authentication.getPrincipal(), false)
方法返回的是List<SessionInformation>
类型的sessions对象。 (2)计算(1)中得到session的个数,也就是当前用户所拥有的有效session个数(定义为sessionCount
变量)。之后调用getMaximumSessionsForThisUser()
方法来获取当前用户所拥有的最大session数(定义为allowedSessions
变量),查看源码可以这个maximumSessions
属性默认值为1。 (3)判断当前session数sessionCount
与session允许并发数allowedSessions
值的大小,如果sessionCount
小于allowedSessions
,则不作任何处理;如果sessionCount
大于或等于allowedSessions
,则首先判断allowedSessions
的值是否为-1,如果为-1,则说明对session的数量不作任何限制;如果不为-1,则进行下一步判断。
接下来判断当前session数sessionCount
与session允许并发数allowedSessions
值是否相等,如果相等,则先判断当前session是否不为null,且已经存在于sessions中?如果已经存在,那么不作任何处理。如果当前session为null,则说明将有一个新的session被创建出来,此时当前session数sessionCount
的值就会超出session允许并发数allowedSessions
的值。
如果当前session数sessionCount
大于session允许并发数allowedSessions
的值,那么就调用allowableSessionsExceeded()
方法来处理相应的逻辑,该方法名称就是“超过允许的会话”。 (4)接下来开始阅读allowableSessionsExceeded()
方法的源码,可以看到首先判断exceptionIfMaximumExceeded
属性,该属性含义为“如果最大超过,则异常”,其实这个值就是之前我们在SecurityConfig#configure(HttpSecurity http)
方法中配置的maxSessionsPreventsLogin()
项,如下所示:
这个maxSessionsPreventsLogin
属性值默认为false,如果这个值为true,那么就会抛出SessionAuthenticationException
异常,说明此次登录失败。如果为false,且sessions不为空,则继续往下执行。
接下来调用sessions.sort()
方法来根据请求时间进行排序,之后再将超出session允许并发数allowedSessions
的值的其余session都过期。举个例子,运动会记录选手比赛时间,它记录了十个,但是最后只取前三名,因此需要对成绩按照时间进行排序,取前三个,其余的都删除,这里说的就是这个意思。
通过上面的源码分析和实际例子,详细大家对SpringSecurity中session的并发管理有了一个较为清晰的认识,但是这里还有一个小坑,至于在哪,笔者将结合前后端分离模式来进行分析和填坑。