会话并发控制
定义与作用
会话并发控制(Concurrent Session Control) 限制同一用户账号在系统中同时保持活跃的会话数量。没有安全框架时,开发者需要自行维护一张"用户 → 会话列表"的映射表,在每次登录时手动检查数量、在请求到达时检测是否被挤掉、在登出时清理记录。这种手写方案极易出现竞态条件(两个请求同时登录时并发计数错误)、内存泄漏(Session 过期后未清理映射表)和分布式环境下的数据不一致问题。
Spring Security 通过 ConcurrentSessionControlAuthenticationStrategy 和 ConcurrentSessionFilter 提供开箱即用的会话并发控制,核心能力包括:
| 能力 | 说明 |
|---|---|
| 最大会话数限制 | maximumSessions(N) 限制同一主体最多 N 个活跃会话 |
| 新登录策略 | maxSessionsPreventsLogin(false) 挤掉最早会话 vs true 阻止新登录 |
| 会话过期检测 | ConcurrentSessionFilter 拦截已被标记为过期的会话 |
| 过期跳转 | expiredUrl() 配置被挤掉后的跳转页面 |
| 注册表管理 | SessionRegistry 维护所有会话,支持查询和强制下线 |
核心原理
并发控制涉及的三个核心组件
ConcurrentSessionControlAuthenticationStrategy
→ 在认证成功时检查同一主体已存在的会话数量
→ 如果超过 maximumSessions,根据 maxSessionsPreventsLogin 决定策略
RegisterSessionAuthenticationStrategy
→ 在认证成功后将新会话注册到 SessionRegistry
ConcurrentSessionFilter
→ 每个请求到达时检查当前会话是否被标记为 expired
→ 如果 expired == true,调用 SessionInformationExpiredStrategy 处理
两种踢人策略对比
| 配置 | maxSessionsPreventsLogin(false) | maxSessionsPreventsLogin(true) |
|---|---|---|
| 行为 | 新登录成功,最早/最旧的会话被标记为过期 | 新登录被阻止,返回错误信息 |
| 用户体验 | 旧设备上的用户被踢出(下次操作时跳转) | 新设备登录失败,提示"账号已在线" |
| 安全场景 | 允许用户在新设备上继续操作 | 防止账号被他人盗用登录 |
| 适用场景 | 移动端单点登录、个人设备切换 | 金融系统、管理员账号保护 |
会话过期标记与检测机制
// 挤掉旧会话时的核心逻辑(简化)
public class ConcurrentSessionControlAuthenticationStrategy implements SessionAuthenticationStrategy {
public void onAuthentication(Authentication authentication,
HttpServletRequest request,
HttpServletResponse response) {
List<SessionInformation> sessions = sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
int allowedSessions = maximumSessions;
if (sessions.size() >= allowedSessions) {
if (maxSessionsPreventsLogin) {
// 策略:阻止新登录
throw new SessionAuthenticationException("已达到最大会话数");
} else {
// 策略:挤掉最早会话
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
int sessionsToExpire = sessions.size() - allowedSessions + 1;
for (int i = 0; i < sessionsToExpire; i++) {
sessions.get(i).expireNow(); // 标记为过期,但 Session 本身未被销毁
}
}
}
}
}
关键注意:expireNow() 只是将 SessionInformation 的 expired 标记设为 true,并不会立即销毁 HttpSession。真正的拦截发生在 ConcurrentSessionFilter 检测到该标记后执行跳转或销毁。
会话并发控制流程
示例一:允许挤掉旧会话(移动端单点登录)
场景说明
移动端 App 用户在新手机上登录后,旧手机上的会话应自动失效,确保用户始终只有一台设备在线。使用 maxSessionsPreventsLogin(false) 实现"后来者居上"。
操作前配置
未配置并发控制,用户可在多台设备同时登录,账号共享风险高:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MobileOldConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/mobile/**").hasRole("MOBILE_USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/api/mobile/login")
.permitAll();
// 缺少会话并发控制,用户可在 10 台手机上同时登录
}
}
操作后配置
限制同一账号最多 1 个会话,新登录自动挤掉旧会话:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MobileSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/mobile/**").hasRole("MOBILE_USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/api/mobile/login")
.permitAll()
.and()
.sessionManagement()
.maximumSessions(1) // 同一账号最多 1 个会话
.maxSessionsPreventsLogin(false) // 新登录挤掉旧会话
.expiredUrl("/api/mobile/session-expired") // 被挤掉后跳转
.sessionRegistry(sessionRegistry());
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
被挤掉后的响应处理(REST 风格):
@Component
public class RestSessionExpiredStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event)
throws IOException, ServletException {
HttpServletResponse response = event.getResponse();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"会话已在其他设备登录,请重新登录\"}");
}
}
配置中使用自定义过期策略:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new RestSessionExpiredStrategy()) // 自定义 REST 响应
.sessionRegistry(sessionRegistry());
}
结果分析
- 用户在手机 A 登录,SessionA 注册到
SessionRegistry - 用户在手机 B 登录,
ConcurrentSessionControlAuthenticationStrategy检测到已有 1 个会话,且maxSessionsPreventsLogin=false,执行SessionA.expireNow() - 手机 B 登录成功,SessionB 注册到
SessionRegistry - 手机 A 再次发送请求时,
ConcurrentSessionFilter检测到 SessionA 已过期,调用RestSessionExpiredStrategy返回 401 JSON 响应 - 手机 A 的 App 收到 401 后清除本地 Token/Session,提示用户"已在其他设备登录"
示例二:阻止新登录(管理员账号保护)
场景说明
后台管理员账号具有高权限,必须防止他人在另一台机器上同时登录。如果管理员已登录,新的登录尝试应直接被拒绝,直至管理员主动登出或会话过期。
操作前配置
未区分普通用户和管理员,全部使用挤掉策略,管理员账号被窃取后可被攻击者在另一设备登录并挤掉合法管理员:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false); // 管理员也被挤掉,不安全
}
操作后配置
为管理员路径单独配置 maxSessionsPreventsLogin(true),普通用户路径保持挤掉策略:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AdminSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false) // 默认:挤掉旧会话
.expiredUrl("/session-expired")
.sessionRegistry(sessionRegistry())
.and()
.sessionFixation().migrateSession();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
注意:Spring Security 5.x 的
sessionManagement()DSL 在单条链中只能配置一种并发策略。若需对 ADMIN 和普通用户区分策略,需要通过多个 SecurityFilterChain 或自定义SessionAuthenticationStrategy实现。以下展示自定义策略方案:
自定义策略:管理员阻止新登录,普通用户允许挤掉:
public class RoleBasedConcurrentSessionStrategy implements SessionAuthenticationStrategy {
private final SessionRegistry sessionRegistry;
private final int maximumSessions;
public RoleBasedConcurrentSessionStrategy(SessionRegistry sessionRegistry, int maximumSessions) {
this.sessionRegistry = sessionRegistry;
this.maximumSessions = maximumSessions;
}
@Override
public void onAuthentication(Authentication authentication,
HttpServletRequest request,
HttpServletResponse response) {
boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
List<SessionInformation> sessions = sessionRegistry
.getAllSessions(authentication.getPrincipal(), false);
if (sessions.size() >= maximumSessions) {
if (isAdmin) {
// 管理员:阻止新登录
throw new SessionAuthenticationException(
"管理员账号已在线,请先登出后再登录");
} else {
// 普通用户:挤掉最早会话
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
sessions.get(0).expireNow();
}
}
// 注册新会话
sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
}
}
配置注入自定义策略:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionAuthenticationStrategy(
new CompositeSessionAuthenticationStrategy(Arrays.asList(
new SessionFixationProtectionStrategy(), // 先防护会话固定
new RoleBasedConcurrentSessionStrategy(sessionRegistry(), 1), // 再控制并发
new RegisterSessionAuthenticationStrategy(sessionRegistry()) // 最后注册会话
))
)
.sessionRegistry(sessionRegistry());
}
结果分析
- 普通用户
user01在 PC 和手机同时登录时,新会话挤掉旧会话,用户始终可在最新设备上操作 - 管理员
admin已在 PC 登录后,攻击者尝试在另一台 PC 登录,触发SessionAuthenticationException,Spring Security 返回登录失败,提示"管理员账号已在线" - 合法管理员必须先在原设备点击"退出登录",或等待 30 分钟会话超时,新设备才能登录成功
易错场景:并发配置不生效的完整排查清单
问题描述
配置了 maximumSessions(1) 和 maxSessionsPreventsLogin(false),但同一用户在 Chrome 和 Firefox 中登录后,两个浏览器都能正常操作,未发生挤掉。
排查清单与根本原因
| 检查项 | 正确做法 | 错误后果 |
|---|---|---|
| SessionRegistry 是否注入 | .sessionRegistry(sessionRegistry()) 显式注入 | 并发策略使用独立注册表,与实际注册表分离,挤掉失效 |
| SessionRegistry 是否为同一个 Bean | 业务层注入的 @Autowired SessionRegistry 与配置中注入的是同一个 SessionRegistryImpl Bean | 两个不同实例,数据不互通 |
是否配置了 expiredUrl 或 expiredSessionStrategy | 至少配置一个,否则被挤掉用户无感知 | 会话虽已标记过期,但前端不知道 |
| ConcurrentSessionFilter 是否生效 | 确认过滤器链中 ConcurrentSessionFilter 已注册 | 缺少此 Filter,过期标记不会被拦截 |
| Cookie 路径/域名是否一致 | 确保两个浏览器访问的是同一域名下的同一应用 | 跨域或子域 Session 隔离,并发策略不生效 |
最常见错误:未注入 SessionRegistry
// ❌ 错误配置
@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()); // 必须显式注入
}
面试考点
问:配置了
maximumSessions(1)但同一用户可以多点登录,可能的原因有哪些?答:最常见的原因是未显式注入
SessionRegistry。Spring Security 的ConcurrentSessionControlAuthenticationStrategy依赖SessionRegistry跟踪会话,如果 DSL 中未调用.sessionRegistry(),并发策略会创建一个内部独立的注册表,而业务层(或理解为外部)无法感知,导致并发控制形同虚设。正确做法是在配置类中声明SessionRegistryImplBean,并在maximumSessions()的链式调用中通过.sessionRegistry()注入同一个实例。另外需要确认
ConcurrentSessionFilter是否已注册(Spring Security 5.x 通常自动注册,但自定义过滤器链时可能被覆盖),以及被挤掉后是否配置了expiredUrl或expiredSessionStrategy,否则用户无感知。
版本说明:本章节基于 Spring Security 5.x(
WebSecurityConfigurerAdapter+antMatchers+@EnableGlobalMethodSecurity(prePostEnabled=true)风格)。