写在前面

通过前面《内存保存用户+自动踢掉登录用户》、《传统方式+JPA+自动踢掉登录用户》、《前后端分离+JPA+自动踢掉登录用户》三篇的学习,我们已经对SpringSecurity中如何自动踢掉已登录用户有了较为清晰的认识,不过前面学习的都是基于单体应用,当项目是集群化部署时,上述配置还能使用么?如果不能使用,那么应该采用什么方式呢?带着这些问题我们来进入本篇的学习。

注意本篇使用的操作系统为Windows,所涉及到的Redis和Nginx均是在Windows上面部署的。

集群会话方案

在传统的单体服务架构中,通常只有一台服务器,因此就不存在Session共享问题。但是在分布式或者集群项目中,Session共享是一个必须面对的问题。

下面是一个简单的集群项目架构图:

从图中可以知道,当客户端发起一个请求,此时请求到达Nginx上,被Nginx转发到TomcatA上,之后TomcatA往Session中保存了一份数据,之后客户端又发起一次请求,但是这个请求被Nginx转发到TomcatB上,由于TomcatB中不存在之前的Session信息,因此无法从中获取数据。

这个现象在分布式或者集群项目中非常常见,也是必须面对的问题。通常而言有如下三种方案来解决上述问题,分别是Session共享、Session拷贝(同步)和粘滞会话,下面分别进行学习。

session共享

对于上述这一类问题,目前比较主流的解决方案就是将各个服务之间需要共享的数据保存在一个公共的服务器上(通常是Redis),示意图如下所示:

可以看到,当所有的Tomcat需要往Session中写入数据时,只需都往Redis中写入;而当所有的Tomcat需要读取Session中的数据时,只需从Redis中读取即可。这样不同的服务就可以使用相同的Session数据。

上述方案可以由开发者来手动实现,即手动往Redis中存储数据和读取数据,可以借助于使用Redis可视化工具或者命令来实现这个功能,但是不太建议开发者手动来实现。

比较理想的就是使用Spring Session来实现这一功能,Spring Session使用Spring中的代理过滤器,将所有的Session操作拦截下来,自动的将数据同步到Redis中,或者从Redis中读取数据。

因此,对于开发者来说,使用Spring Session之后,所有关于Session的操作都是透明的,就像操作普通的Session一样来操作分布式或者集群中的Session。

Session拷贝(同步)

所谓的Session拷贝,是指不使用Redis,而是直接在各个Tomcat之间进行Session的数据拷贝,毫无疑问这种方式效率低下,且可变性差。当上述三个Tomcat中的任意一个的Session发生变化时,都需要将这些变化拷贝到其他两个,这样会加重开发者的工作量,尤其是当Tomcat的数据非常庞大的时候,这个任务几乎就完不成了,因此Session拷贝这种方式现在几乎都不会使用了。

粘滞会话

所谓的粘滞会话,是指将相同IP发来的请求通过Nginx路由到同一个Tomcat上去,这样就不需要进行Session共享和拷贝了。这的确是一种方案,而且Nginx是支持根据IP地址来路由的,但是在一些极端情况下,可能会导致负载失衡。因为大部分情况下,大家都是使用同一个公网IP,因此极易造成负载失衡。

综合上述三种方案,目前比较流行的方案就是Session共享,接下来就结合实例来学习如何使用Session共享。

Session共享实例

第一步,使用IDEA创建一个名为security-session的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
27
28
29
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<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.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</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>

第二步,在application.yml配置文件中新增如下配置,主要是配置Redis和Security信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# SpringSecurity配置
spring:
security:
user:
name: envy
password: 1234
# Redis配置
redis:
host: 127.0.0.1
port: 6379
timeout: 5000ms
database: 0
password: 1234
# SpringBoot属性
server:
port: 8080

此处由于着重学习Spring Session的相关内容,因此用户信息就直接配置在该文件中,同时设置默认端口为8080,在集群部署后,后面可以通过它来知道请求来自哪个SpringBoot应用。
第三步,新建一个controller包,并在里面新建一个HelloController类,里面的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
public class HelloController {
@Value("${server.port}")
private Integer port;

@GetMapping("/set")
public String set(HttpSession session){
session.setAttribute("user","envy");
return String.valueOf(port);
}

@GetMapping("/get")
public String get(HttpSession session){
return session.getAttribute("user") +":"+port;
}
}

可以看到代码非常简单,这里读取配置文件中server.port的端口信息,之后定义get和set方法来返回信息。

前面也说了这里以集群方式来启动SpringBoot应用,这样为了后续方便获取每一个请求来自于哪个SpringBoot,可以在每次请求时返回当前服务的端口号,因此笔者在上面使用了server.port。当然开发者也可以使用spring.application.name配置。

之后启动Redis服务,注意必须以redis-server redis.windows.conf命令来启动,否则无法将配置的密码等属性加载到Redis中,之后保持此窗口一直处于运行状态:

接着打包项目,进入到项目生成的target目录,依次使用如下命令来启动两个SpringBoot实例,这里仅仅是以不同端口来加以区分:

1
2
start /min java -jar security-session-0.0.1-SNAPSHOT.jar --server.port=8080
start /min java -jar security-session-0.0.1-SNAPSHOT.jar --server.port=8081

接着打开浏览器,访问http://localhost:8080/set,向8080这个服务的Session中保存一个变量。其实用户第一次访问时,页面会自动跳转到登录页面,用户输入用户名和密码后完成登录。登录成功后,另开启一个DOS窗口,查看一下此时Redis中的数据:

之后再访问http://localhost:8081/get链接,可可以看到页面就显示了8080服务中session的信息:

可以看到此时session共享就已经实现了。

Security配置

通过前面的配置,我们已经成功实现了Session共享,接下来尝试按照《前后端分离+JPA+自动踢掉登录用户》一文中的配置来对并发管理进行设置。

新建一个config包,并在里面新建一个SercurityConfig类,里面的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login")
.permitAll()
.and()
.sessionManagement().maximumSessions(1)
.maxSessionsPreventsLogin(true);
http.csrf().disable();
}
}

之后测试多端登录,你会发现这个配置不起任何作用,也就是说同一用户可以在多个浏览器上同时登录系统。

那么问题来了,为什么上述的配置会失效呢?这里需要回忆《传统方式+JPA+自动踢掉登录用户》和《前后端分离+JPA+自动踢掉登录用户》这两文中的内容了,我们说过SpringSecurity中与Session存储相关的接口为SessionRegistry,而它只有一个实现类SessionRegistryImpl,用于对会话信息进行统一管理,需要注意的是SessionRegistryImpl是基于内存的维护,而不是Session的维护。此处我们使用Spring Session+Redis实现了Session共享,但是由于SessionRegistryImpl依旧采用的是基于内存维护,因此上述配置就会失效。如果开发者需要实现上述功能,那么就需要将SessionRegistryImpl修改为基于Session共享的维护。

我们这样想,既然SpringSecurity中与Session存储相关的接口为SessionRegistry,而它只有一个实现类SessionRegistryImpl,同时又是基于内存维护的,如果我们需要基于Session共享,那么最简单最便捷的方式就是提供一个SessionRegistry的实现类,然后在这个实现类中实现基于Session共享的逻辑。

的确,这种方式扩展性非常好,于是Spring Session为开发者提供了SessionRegistry基于Session共享的实现类SpringSessionBackedSessionRegistry。查看一下该类的源码,如下所示:

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
public class SpringSessionBackedSessionRegistry<S extends Session> implements SessionRegistry {
private final FindByIndexNameSessionRepository<S> sessionRepository;

public SpringSessionBackedSessionRegistry(FindByIndexNameSessionRepository<S> sessionRepository) {
Assert.notNull(sessionRepository, "sessionRepository cannot be null");
this.sessionRepository = sessionRepository;
}

public List<Object> getAllPrincipals() {
throw new UnsupportedOperationException("SpringSessionBackedSessionRegistry does not support retrieving all principals, since Spring Session provides no way to obtain that information");
}

public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
Collection<S> sessions = this.sessionRepository.findByPrincipalName(this.name(principal)).values();
List<SessionInformation> infos = new ArrayList();
Iterator var5 = sessions.iterator();

while(true) {
Session session;
do {
if (!var5.hasNext()) {
return infos;
}

session = (Session)var5.next();
} while(!includeExpiredSessions && Boolean.TRUE.equals(session.getAttribute(SpringSessionBackedSessionInformation.EXPIRED_ATTR)));

infos.add(new SpringSessionBackedSessionInformation(session, this.sessionRepository));
}
}

public SessionInformation getSessionInformation(String sessionId) {
S session = this.sessionRepository.findById(sessionId);
return session != null ? new SpringSessionBackedSessionInformation(session, this.sessionRepository) : null;
}

public void refreshLastRequest(String sessionId) {
}

public void registerNewSession(String sessionId, Object principal) {
}

public void removeSessionInformation(String sessionId) {
}

protected String name(Object principal) {
return (new TestingAuthenticationToken(principal, (Object)null)).getName();
}
}

通过对SessionRegistryImplSpringSessionBackedSessionRegistry这两个类的源码,我们知道基于内存和基于Session的区别是非常明显的,前者是定义ConcurrentMap对象,然后根据对象的方法来进行操作;而后者是通过使用FindByIndexNameSessionRepository接口,通过调用接口的方法来实现操作。

仿照之前的逻辑,开发者需要提供一个SpringSessionBackedSessionRegistry实例,并且将其配置到sessionManagement中,这样session的并发数据的维护就交由SpringSessionBackedSessionRegistry来负责了,不再是之前的SessionRegistryImpl

修改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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private FindByIndexNameSessionRepository sessionRepository;

@Bean
SpringSessionBackedSessionRegistry sessionRegistry(){
return new SpringSessionBackedSessionRegistry(sessionRepository);
}


@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.permitAll()
.and()
.csrf().disable()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.sessionRegistry(sessionRegistry());
}
}

可以看到这里自动注入了一个private FindByIndexNameSessionRepository sessionRepository;对象,之后将其作为参数传入SpringSessionBackedSessionRegistry实例化对象中。

之后将项目进行打包,之后重新运行下述命令:

1
2
start /min java -jar security-session-0.0.1-SNAPSHOT.jar --server.port=8080
start /min java -jar security-session-0.0.1-SNAPSHOT.jar --server.port=8081

之后进行多端登录测试,由于我们配置了一个用户同时只能在一个平台上登录,且禁止用户新的登录,因此当用户在Chrome浏览器上登录后,就无法在Edge浏览器上登录,且此时会提示如下信息:

到现在我们就实现了集群或者分布式项目的“自动踢掉登录用户”这一功能。

为了让我们的例子变得更加像集群化部署项目,因此接下来引入Nginx,实现负载均衡。

引入Nginx

进入到Nginx安装目录的conf目录下,笔者路径为D:\Nginx\nginx-1.18.0\conf,之后在其目录下新建vhost目录。之后在conf目录下的nginx.conf文件中新增如下配置:

1
include vhost/*.conf;

注意添加的位置:

通过上述配置,我们就能更好的自定义配置信息,不至于增加主配置文件的大小。

之后进入vhost目录,在其中新建一个think.com.conf文件,然后在里面添加如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
upstream envy.com {
server 127.0.0.1:8080 weight=1;
server 127.0.0.1:8081 weight=2;
}

server {
listen 8083;
server_name think.com;

location / {
proxy_pass http://envy.com;
proxy_redirect default;
}
}

在上面的例子中,通过使用upstream指令定义了一个名为envy.com的负载均衡器,此处的名称可以随意指定,不一定是一个域名。后面的server表示真实服务器群组,后接真实服务器的IP地址,后面的weight表示服务权重,权重越大则将有更大比例的请求从Nginx上转发到该服务上。

之后是一个server块,里面的listen表示当前Nginx实例的监听端口为8083,server_name表示Nginx实例名称为think.com,注意这个名称必须与文件名保存一致(conf后缀除外)。location中的proxy_pass表示请求转发的地址,/表示将拦截到的所有请求都转发到之前配置好的服务集群中。proxy_redirect表示设置当发生重定向请求时,Nginx会自动修正响应头数据。默认是Tomcat返回重定向,此时重定向的地址为Tomcat的地址,我们需要将其修改为Nginx的地址。

配置完后,打开C:\Windows\System32\drivers\etc\hosts文件,在里面新增一条DNS解析记录:

1
127.0.0.1 think.com envy.com

之后启动Nginx,保持之前启动的两个SpringBoot实例和Redis一直处于运行状态。之后打开Chrome浏览器,访问http://think.com:8083/set链接,之后输入用户信息进行登录,这表示向Session中保存数据,之后该请求到达Nginx上,Nginx再将其转发到上述两个Spring Boot实例中的任意一个。

如下图,表示端口为8081的SpringBoot实例处理了这个/set接口:

之后再次访问/get接口,如下所示,可以看到该接口是被端口号为8080的SpringBoot实例处理了:

本篇对在集群和分布式环境中SpringSecurity处理Session的有关内容进行了较为细致的学习,这样后续在实际工作中会更加轻车熟路。