乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

会话并发控制

定义与作用

会话并发控制(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(),并发策略会创建一个内部独立的注册表,而业务层(或理解为外部)无法感知,导致并发控制形同虚设。正确做法是在配置类中声明 SessionRegistryImpl Bean,并在 maximumSessions() 的链式调用中通过 .sessionRegistry() 注入同一个实例。

另外需要确认 ConcurrentSessionFilter 是否已注册(Spring Security 5.x 通常自动注册,但自定义过滤器链时可能被覆盖),以及被挤掉后是否配置了 expiredUrl 或 expiredSessionStrategy,否则用户无感知。


版本说明:本章节基于 Spring Security 5.x(WebSecurityConfigurerAdapter + antMatchers + @EnableGlobalMethodSecurity(prePostEnabled=true) 风格)。

上一页
SessionFixation
下一页
SessionCreationPolicy