会话管理
定义与作用
在 Web 应用中,会话管理(Session Management) 负责跟踪用户从登录到登出的完整交互周期。没有安全框架时,开发者需要手写大量重复代码:手动创建 HttpSession、判断会话是否过期、处理并发登录冲突、防范会话固定攻击等。任何一个环节疏漏,都会直接暴露安全漏洞。
Spring Security 将会话管理内建为核心安全模块,通过 SessionManagementConfigurer 提供统一的 DSL 配置入口,涵盖:
| 能力 | 说明 |
|---|---|
| 会话创建策略 | 控制何时创建 HttpSession(无状态 / 按需 / 始终) |
| 会话超时管理 | 统一配置会话失效时间,过期后自动清理安全上下文 |
| 会话注册表 | 维护所有活跃会话的注册中心,支持会话数量统计与强制下线 |
| 会话固定防护 | 登录成功后更换 Session ID,防止攻击者劫持旧会话 |
| 会话并发控制 | 限制同一账号同时在线的会话数量 |
没有框架时的手写痛点:
- 手动在
LoginServlet中调用request.getSession(),但不知道应该何时不创建会话 - 会话超时后,未清理的
SecurityContext导致匿名用户仍能访问受保护资源 - 并发登录完全依赖开发者自行维护在线用户表,代码散落在各 Controller 中
- 会话固定攻击需要手动在登录成功后调用
session.invalidate()再重建,极易遗漏
核心原理
Spring Security 的会话管理由过滤器链中多个组件协同完成:
HttpSessionSecurityContextRepository → 从 Session 加载/存储 SecurityContext
SessionManagementFilter → 检测会话是否过期、并发冲突
SessionFixationProtectionStrategy → 登录成功后更换 Session ID
ConcurrentSessionFilter → 拦截并发超限的会话
SessionRegistry / SessionRegistryImpl → 维护所有活跃会话的注册表
会话管理在过滤器链中的位置
| 顺序 | 过滤器 | 与会话管理的关系 |
|---|---|---|
| 3 | SecurityContextPersistenceFilter | 从 HttpSession 读取 SecurityContext 到 SecurityContextHolder;请求结束时将 SecurityContext 写回 HttpSession |
| 9 | SessionManagementFilter | 检测会话 ID 变更、会话过期、并发冲突,触发 SessionAuthenticationStrategy |
| 12 | ConcurrentSessionFilter | 检查当前会话是否被标记为过期(expired),若过期则强制跳转或销毁 |
核心类职责
SessionManagementConfigurer:配置入口,通过http.sessionManagement()开启 DSL 配置SessionCreationPolicy:枚举定义四种会话创建策略(详见独立文档)SessionRegistry:会话注册表接口,SessionRegistryImpl基于内存实现,存储SessionInformation(会话 ID、主体、最后请求时间、是否过期标记)SessionAuthenticationStrategy:会话认证策略接口,实现类包括SessionFixationProtectionStrategy、ConcurrentSessionControlAuthenticationStrategy、RegisterSessionAuthenticationStrategy
会话超时与过期处理
会话超时有两种触发方式:
- 容器层超时:
web.xml或application.properties中配置session-timeout - Spring Security 层检测:
ConcurrentSessionFilter定期检查SessionInformation.isExpired()
当会话过期时,SessionManagementFilter 将保存当前请求到 RequestCache,然后重定向到 expiredUrl 或调用 InvalidSessionStrategy。
会话管理整体流程
示例一:配置会话超时与过期跳转
场景说明
电商后台管理系统的管理员会话闲置 30 分钟后应自动失效,并跳转至会话过期提示页面,避免管理员离开后浏览器被他人利用。
操作前配置
仅配置了登录,未设置会话超时和过期处理,浏览器关闭后会话仍残留,重新打开可能无需重新登录:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll();
// 缺少会话超时配置,容器默认 30 分钟,无自定义过期跳转
}
}
操作后配置
显式配置会话超时时间、过期跳转 URL,以及检测无效会话时的跳转策略:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SessionSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.invalidSessionUrl("/session-invalid") // 检测到无效会话时跳转
.maximumSessions(1) // 同一账号最多 1 个会话
.maxSessionsPreventsLogin(false) // 新登录挤掉旧会话
.expiredUrl("/session-expired") // 会话被挤掉或过期时跳转
.sessionRegistry(sessionRegistry()) // 注入注册表
.and()
.sessionFixation().migrateSession(); // 登录后更换 Session ID,保留属性
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
结果分析
- 管理员在
/admin/dashboard页面闲置 30 分钟后,容器自动销毁HttpSession - 下一次操作触发
SessionManagementFilter检测到会话无效,重定向至/session-invalid - 页面提示"会话已过期,请重新登录",管理员必须重新输入凭据
SessionRegistryImpl维护在线管理员列表,运维人员可通过sessionRegistry.getAllPrincipals()获取当前在线人数
示例二:注册 SessionRegistry 用于在线用户监控
场景说明
金融系统需要实时统计当前在线用户数量,并支持管理员在后台强制踢掉某个用户的所有会话。需要显式暴露 SessionRegistry Bean,以便在 Controller 中注入使用。
操作前配置
未配置 SessionRegistry,虽然设置了并发控制,但 Spring Security 默认使用内部不可见的注册表,外部无法获取在线用户信息:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false);
// 没有 sessionRegistry() 调用,无法从外部访问注册表
}
操作后配置
显式声明 SessionRegistryImpl Bean,并在 sessionManagement 中注入,同时在业务层提供查询和强制下线接口:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OnlineMonitorConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/online/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/session-expired")
.sessionRegistry(sessionRegistry()); // 显式注入,外部可访问
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
AdminController 在线监控接口:
@RestController
@RequestMapping("/admin/online")
public class OnlineUserController {
@Autowired
private SessionRegistry sessionRegistry;
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public List<Map<String, Object>> listOnlineUsers() {
List<Map<String, Object>> result = new ArrayList<>();
for (Object principal : sessionRegistry.getAllPrincipals()) {
if (principal instanceof UserDetails) {
UserDetails user = (UserDetails) principal;
List<SessionInformation> sessions = sessionRegistry.getAllSessions(user, false);
for (SessionInformation session : sessions) {
Map<String, Object> info = new HashMap<>();
info.put("username", user.getUsername());
info.put("sessionId", session.getSessionId());
info.put("lastRequest", session.getLastRequest());
info.put("expired", session.isExpired());
result.add(info);
}
}
}
return result;
}
@PostMapping("/kick/{sessionId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> kickUser(@PathVariable String sessionId) {
SessionInformation session = sessionRegistry.getSessionInformation(sessionId);
if (session != null) {
session.expireNow(); // 标记会话过期
// 下一次该会话请求到达时,ConcurrentSessionFilter 会强制其跳转
}
return ResponseEntity.ok().build();
}
}
结果分析
GET /admin/online返回当前所有在线用户的会话列表,包括用户名、会话 ID、最后请求时间POST /admin/online/kick/{sessionId}将该会话标记为expired- 被踢用户下一次请求到达
ConcurrentSessionFilter时,检测到isExpired() == true,强制跳转至expiredUrl - 未注入
SessionRegistry时,上述功能完全无法实现,因为默认内部注册表对外不可见
易错场景:会话并发配置不生效
问题描述
配置了 maximumSessions(1) 和 maxSessionsPreventsLogin(false),但同一账号在 Chrome 和 Firefox 中同时登录后,两个浏览器都能正常操作,并未发生挤掉现象。
根本原因
SessionRegistry 未注入配置。ConcurrentSessionControlAuthenticationStrategy 依赖 SessionRegistry 跟踪会话,但 Spring Security 默认不会自动将内部使用的 SessionRegistry 与并发控制共享。如果 DSL 中缺少 .sessionRegistry(sessionRegistry()) 调用,并发策略会使用一个独立的、不可见的注册表,导致业务层注入的 SessionRegistry 与实际并发控制使用的不是同一个对象。
错误配置
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl(); // 声明了 Bean,但 DSL 中未引用
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false);
// 缺少 .sessionRegistry(sessionRegistry()) !!!
}
正确配置
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry()); // 必须显式注入同一个 Bean
}
面试考点
问:
maximumSessions(1)配置了但不生效,可能的原因是什么?答:最常见的原因是未在
sessionManagement()DSL 中调用.sessionRegistry(sessionRegistry())注入注册表。Spring Security 的并发控制策略需要SessionRegistry来跟踪当前主体的所有会话,如果未显式注入,并发策略会使用一个内部独立的注册表,导致外部无法感知或控制会话状态,并发限制失效。正确做法是显式声明SessionRegistryImplBean,并在maximumSessions()链式调用中通过.sessionRegistry()将其注入。
版本说明:本章节基于 Spring Security 5.x(
WebSecurityConfigurerAdapter+antMatchers+@EnableGlobalMethodSecurity(prePostEnabled=true)风格)。