1. 前言

在 Web 应用安全体系中,会话管理是认证授权后的重要防线。攻击者常通过会话劫持会话固定突破系统边界,而业务系统则面临并发滥用带来的资源风险。

Spring Security 的会话管理模块由 SessionManagementFilter 与一系列 SessionAuthenticationStrategy 共同协作,负责在用户登录或访问受保护资源时执行统一的会话检查与策略。默认情况下,框架允许单个用户拥有无限多个并发会话,而在每次登录时会执行会话固定保护策略,将旧 Session ID 迁移到新 Session 中,以防止攻击者利用已有的 Session ID 进行劫持。

在本章节,笔者将基于 Spring Security 6,深入解析会话管理的安全实践。


2. 会话固定攻击防护原理

2.1 攻击概述

会话固定(Session Fixation)指攻击者诱导受害者在已知的 Session ID 下登录,随后持该 ID 进行未授权操作。Spring Security 通过会话固定保护策略,在每次用户登录后刷新 Session ID 来防御此类攻击。

攻击流程解析

会话固定攻击流程示意图

2.2 Spring Security 防御策略

SessionManagementFilter 在认证成功后,会调用相应的 SessionAuthenticationStrategy 执行上述策略。migrateSession() 在底层通过 HttpServletRequest.changeSessionId()复制属性到新 Session 来实现 ID 刷新,有效防止攻击者使用固定 ID 进行入侵。

配置样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration  
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 启用会话固定防护,采用迁移会话策略
.sessionManagement(session -> session
.sessionFixation(fix ->
fix.migrateSession() // 默认:保留旧数据,生成新 Session ID
// fix.changeSessionId() // 仅变更会话 ID
// fix.newSession() // 完全新建 Session,不保留任何属性
// fix.none() // 禁用保护(不推荐)
)
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
}

配置说明

  • none:不做任何处理,不推荐使用
  • changeSessionId:认证后仅变更会话 ID
  • newSession:创建全新、干净的 Session,不复制原属性
  • migrateSession(默认):创建新 Session 并复制原有属性,保留必要数据同时更换 ID,防范攻击者利用旧 ID

3. 并发会话控制方案

当同一账号在多处登录时,可能带来数据冲突、授权混乱甚至资源浪费。Spring Security 通过并发会话控制,可限制用户的最大在线会话数,并在超过限制时选择“踢出旧会话”或“拒绝新登录”两种策略。

3.1 为什么要限制并发会话?

  • 防止账号共享:同一账号在多处登录可能意味着凭证泄露
  • 防范会话劫持:一旦旧会话被劫持,可通过限制数量自动踢出旧会话

3.2 核心组件

  • SessionRegistry:维护用户与其活跃 Session 的映射
  • ConcurrentSessionControlAuthenticationStrategy:在用户登录时检查并发会话数量,超过上限可抛出 SessionAuthenticationException 或踢出最早会话
  • HttpSessionEventPublisher:侦听 HttpSessionEvent,在 Session 销毁时同步更新 SessionRegistry

3.3 配置样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration  
@EnableWebSecurity
public class SecurityConfig {

// 1. 注册 Session 事件监听器,使 Spring Security 能及时感知会话生命周期事件
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // 同一用户仅允许 1 个会话
.maxSessionsPreventsLogin(true) // true:超出拒绝后续登录;false:踢出最早会话
.expiredUrl("/login?expired") // 会话过期后重定向地址
);
return http.build();
}
}

4. 测试验证方案

为演示并发控制与会话固定攻击防护功能,笔者借鉴官方样例代码稍作调整进行演示

官方样例代码:https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/session-management/maximum-sessions

❶ 确保引入 spring-security-test

1
2
3
4
5
6
<dependencies>  
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
</dependencies>

❷ 编写配置类

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
@Configuration  
@EnableWebSecurity
public class SessionSecurityConfig {

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.sessionManagement(session -> session
.sessionFixation(sf -> sf.migrateSession())
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.expiredUrl("/login?expired")
);
return http.build();
}

@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}

❸ 编写测试接口

1
2
3
4
5
6
7
@RestController  
public class HomeController {
@GetMapping("/")
public String hello() {
return "hello";
}
}

❹ 编写测试类

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
import com.toher.springsecurity.demo.session.management.DemoSessionApplication;  
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

@SpringBootTest(classes = DemoSessionApplication.class)
@AutoConfigureMockMvc
public class SessionConcurrencyTest {

@Autowired
private MockMvc mockMvc;

@Test
void testConcurrentLogin() throws Exception {
// 第一次登录
MvcResult mvcResult = mockMvc.perform(formLogin())
.andExpect(authenticated())
.andReturn();
MockHttpSession firstLoginSession = (MockHttpSession) mvcResult.getRequest().getSession();

mockMvc.perform(get("/").session(firstLoginSession))
.andExpect(authenticated());

// 第二次登录将被拒绝
mockMvc.perform(formLogin()).andExpect(authenticated());

// 验证第一次会话已失效
mockMvc.perform(get("/").session(firstLoginSession))
.andExpect(unauthenticated());
}
}

❺ 并行运行测试

观察控制台信息,可调整 .maxSessionsPreventsLogin(true) 值以测试第二次登录是拒绝还是踢出最早会话。

第一次登录请求
第一次登录截图

测试请求获取数据
获取数据截图

再次登录被拒绝,返回登录页
拒绝登录截图

❻ 会话固定攻击模拟

1
2
3
4
5
6
7
8
9
10
11
# 获取初始会话 ID  
curl -I http://localhost:8080/login
Set-Cookie: JSESSIONID=12345; Path=/; HttpOnly

# 使用固定会话 ID 尝试认证
curl -X POST http://localhost:8080/login \
-H "Cookie: JSESSIONID=12345" \
-d "username=admin&password=123456"

# 验证响应是否生成新会话 ID
Set-Cookie: JSESSIONID=67890; Path=/; HttpOnly

以该 ID 发起登录请求,登录后检查返回的 Set-Cookie 中 Session ID 是否已更换,验证 ID 刷新策略。

5. 会话事件监听器

当会话失效时,可借助会话事件监听器执行相应操作(如日志记录):

1
2
3
4
5
6
7
8
9
10
11
12
@Component  
public class SessionActivityListener implements ApplicationListener<SessionDestroyedEvent> {

@Override
public void onApplicationEvent(SessionDestroyedEvent event) {
event.getSecurityContexts().forEach(context -> {
AuditLog.log("会话终止",
context.getAuthentication().getName(),
"原因: " + event.getSessionId());
});
}
}

6. 结语

通过本章节的讲解与实战示例,结合 sessionFixation().migrateSession()maximumSessions(...) 等配置,相信大家已掌握 Spring Security 的并发会话控制与会话固定防护策略,能够为应用构建坚实的会话安全防线。