写在前面

经过前面的学习,我们已经对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()方法则是在在其父类AbstractAuthenticationProcessingFilterdoFilter()方法中被触发的,这里再次粘贴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的并发管理有了一个较为清晰的认识,但是这里还有一个小坑,至于在哪,笔者将结合前后端分离模式来进行分析和填坑。