资讯动态

Spring Security并发会话控制:实现踢掉前一个登录用户的完整方案

发布时间:2026/10/16 10:34:17 来源:尧图企业网站定制
做后台管理系统尤其是做权限这块的时候大概率都会接到这样一个需求同一个账号只能在一个地方登录后登录的要把前面登录的那个给踢下线。在 Spring Security 体系里这就是典型的“并发会话控制”也就是标题里说的“SpringSecurity 实现踢掉前一个登录用户”。这个需求看着简单实际做起来会发现里面藏着不少坑。比如配置了maximumSessions(1)为什么没生效为什么后登录的用户反而被拦截了集群部署下 SessionRegistry 为什么不工作这篇文章我会从需求拆解开始把设计思路、核心配置、代码实现、踩坑记录全部串起来讲尽量让还没接触过这块的人也能照着落地。1. 需求拆解与技术路线选型1.1 这个需求本质要解决什么问题先说清楚“踢掉前一个登录用户”到底在做什么。用户 A 在浏览器登录了系统还没有退出用户 A 又在另一台电脑或者另一个浏览器标签页登录。此时系统的行为需要明确要么拒绝后来的登录“当前账号已登录请先退出”要么让先登录的会话失效“你的账号已在别处登录”。标题用的是“踢掉”也就是走第二种策略新的登录成功旧的会话被强制销毁。这里要拆出三个核心问题系统怎么知道同一个用户的多个会话分别是什么那就要给每个登录会话一个唯一标识并且记录这个“会话归属的用户主键”。新登录成功后系统怎么找到“旧会话”并将其标记为失效这需要一个注册表来维护“用户 - 会话集合”的映射关系。被踢掉的旧会话在再次发请求时系统怎么让它快速失效并给出友好提示这里要依赖 Spring Security 过滤器链中的会话过期处理机制。这三个问题对应 Spring Security 里面的三样东西HttpSessionSessionRegistryConcurrentSessionFilter。把这三者串起来就是整篇文章的技术主线。1.2 为什么不用拦截器自己写有的同学会想这不就是在登录的时候把旧的 session 干掉吗我自己写个拦截器判断一下不行吗可以但会很痛苦。因为你不仅要处理登录成功时的新旧会话替换还要处理 session 在服务端的销毁事件、并发登录时的数据同步、以及集群环境下的会话共享。这些恰恰是 Spring Security 已经沉淀好的能力只是它把入口拆得比较散需要我们组合起来。从另一个角度看Spring Security 的这种设计是有意为之的它不想在认证成功那一刻立刻去销毁旧会话因为存在分布式部署的情况旧会话可能不在当前节点上而是采用“标记过期 延迟清理”的机制。理解了这个设计意图后面很多坑你怎么踩都不会慌。1.3 整体方案概览这篇文章会讲三层递进的实现方案方案层次核心手段适用场景入门级配置文件 内置过滤器单机部署、快速上线进阶级自定义认证成功处理器 SessionRegistry 手动踢出需要定制提示、需要即时踢出、需要记住旧会话信息扩展级Redis 存储会话映射 维护令牌版本号多实例部署、需要控制多端登录如允许一个 Web 端 一个 App 端标题场景下绝大多数项目用前两层就能覆盖。第三层适合做移动端与 Web 端共存时的精细化控制我会在最后给出思路和关键代码片段。2. 第一次落地使用 Spring Security 内置的并发会话控制2.1 最小化配置maximumSessions 的威力Spring Security 提供了一个开箱即用的并发会话控制机制核心配置就两行Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .formLogin() .and() .sessionManagement() .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredUrl(/login?expiredtrue); return http.build(); } }这里有两个关键参数必须理解maximumSessions(1)同一个用户名最多允许 1 个会话。这里的“用户名”默认取的是Authentication.getPrincipal()的toString()。如果你在 UserDetails 里重写了toString()那做并发控制的 key 也会跟着变这点要特别小心。maxSessionsPreventsLogin(false)表示当旧会话存在时新的登录不被阻止而是把旧会话标记为过期。如果改为true就是反过来新登录被拒绝系统提示“当前账号已登录”。标题要求“踢掉前一个”所以这里必须为false。expiredUrl的作用是当被标记为过期的旧会话再次发起请求时会重定向到这个地址。我可以很明确地告诉你expiredUrl在前后端分离的项目里几乎没用因为后端重定向对 API 请求没有任何友好性后面会单独讲这个问题。2.2 不要忘记注册 HttpSessionEventPublisher只写上面那段配置在本地测试大概率发现“踢不掉”。这不是配置错了而是少了一个关键 BeanBean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); }HttpSessionEventPublisher的作用是把 Servlet 容器里的 session 创建、销毁事件转发给 Spring Security 的HttpSessionEventPublisher监听器。SessionRegistry需要感知 session 什么时候销毁才能把自己维护的“用户-会话”映射清理掉。没有这个 Bean 的典型症状是第一次登录能成功第二次登录后第一次的 session 确实被标记为过期了但当你手动调用sessionRegistry.getSessions(...)查看时会发现旧 session 还挂在列表里。更严重的情况是用户不点击退出、也不关闭浏览器那个旧 session 的注册信息会一直留着导致后续该用户再登录时被误判为“超出最大会话数”。这个 Bean 是全局性的只要项目里有SessionRegistry就要注册它。这个配置我建议直接放进 SecurityConfig 或者 ApplicationConfig别放在局部配置里。2.3 验证内置方案的生效链路配置完成之后整个调用流程是这样的用户 A 登录Spring Security 认证成功创建新 session。容器通过HttpSessionEventPublisher发布 SessionCreatedEventSessionRegistry登记该用户的这个 session。用户 A 在另一台机器再次登录认证成功后Spring Security 发现该用户名已有 1 个 session且maximumSessions1于是把旧 session 标记为 expired但不会立刻销毁。旧 session 再次发请求请求会经过ConcurrentSessionFilter过滤器从SessionRegistry查到该 session 已过期于是触发SessionInformationExpiredStrategy默认行为是重定向到expiredUrl。所以你看内置方案其实不是“物理踢下线”而是“延迟拦截”。旧会话只要不再发请求它其实还残留在服务端内存里直到 session 超时或者被手动销毁。这个机制我们后面会用到。3. 进阶实现登录成功后立即踢出旧会话3.1 为什么需要自定义成功处理器内置方案能满足需求但有两个体验问题旧会话要“再次发请求”才会发现被踢如果用户一直停留在旧页面上看起来就像没被踢掉。无法在踢出的时候携带业务参数比如“你已在 2025-05-20 10:30 于另一台设备登录”或者向前端返回统一的 JSON 结构。要解决这两个问题必须自己接管认证成功后的逻辑。具体做法自定义AuthenticationSuccessHandler在登录成功后手动找到旧会话调用expireNow()并且可以自定义过期后的处理策略接口。3.2 核心代码注册 SessionRegistry 并手动踢出先配置好SessionRegistry的实现类Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); }配置类里把过滤器链和 SessionRegistry 关联上Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .formLogin() .successHandler(customAuthenticationSuccessHandler()) .and() .sessionManagement() .maximumSessions(1) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry()) .expiredSessionStrategy(customSessionInformationExpiredStrategy()); return http.build(); }认证成功处理器的核心逻辑Component public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final SessionRegistry sessionRegistry; private final ObjectMapper objectMapper; public CustomAuthenticationSuccessHandler(SessionRegistry sessionRegistry, ObjectMapper objectMapper) { this.sessionRegistry sessionRegistry; this.objectMapper objectMapper; } Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // 当前新登录的用户主体 Object principal authentication.getPrincipal(); // 查找该用户所有有效的 session ListSessionInformation sessions sessionRegistry.getAllSessions(principal, false); for (SessionInformation session : sessions) { // 注意新登录的 session 此时还没被注册进 SessionRegistry // 所以这里拿到的基本都是旧会话 if (session.getSessionId().equals(request.getSession().getId())) { continue; } session.expireNow(); } // 注册新会话到 SessionRegistry sessionRegistry.registerNewSession(request.getSession().getId(), principal); // 返回统一 JSON response.setContentType(application/json;charsetUTF-8); response.getWriter().write(objectMapper.writeValueAsString( Result.success(登录成功) )); } }这里有几个细节必须说透getAllSessions(principal, false)的第二个参数includeExpired传 false表示只查有效会话。但要注意如果你之前有会话已经被标记过期但还没清理这里传 false 也能过滤掉因为我们这次要找的是“有效旧会话”。“新登录的 session 此时还没被注册进 SessionRegistry”这句话是成败关键。Spring Security 的认证成功处理器执行的时机在SessionRegistry自动注册新会话之前准确说是同一个过滤器链里后面的ConcurrentSessionFilter才会去注册。所以我们在成功处理器里手动调用registerNewSession是为了保证 SessionRegistry 内部的状态是一致的。3.3 自定义被踢后的处理策略被踢掉的旧会话再次访问时会走到SessionInformationExpiredStrategy。默认实现只是重定向我们把它替换掉Component public class CustomSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy { private final ObjectMapper objectMapper; public CustomSessionInformationExpiredStrategy(ObjectMapper objectMapper) { this.objectMapper objectMapper; } Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException { HttpServletResponse response event.getResponse(); response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(objectMapper.writeValueAsString( Result.failure(401, 您的账号已在其他设备登录请重新登录) )); } }这个策略在前后端分离项目里特别实用。前端拿到 401 状态码和业务码之后可以清空本地 token、跳转登录页、弹提示甚至可以自动用旧的sessionId清除后端残留数据。3.4 一处关键细节session 修复Spring Security 在登录成功后默认会做session fixation防护也就是变更 sessionId 防止会话固定攻击。这一点本身是好事但它会影响我们自己写的成功处理器。当你调用request.getSession()时拿到的已经是新 sessionId而SessionRegistry里记录的旧 sessionId 是变更前的。上面代码里用sessionId.equals(...)去跳过“当前会话”大概率能安全跳过但如果你在成功处理器里先执行了request.changeSessionId()再注册会话那新旧映射就比较容易出乱子。我个人的习惯是不在成功处理器里做任何 session 变更动作让 Spring Security 默认的过滤器去处理 session fixation我们只负责注册和踢出。4. 进一步扩展多端登录与 Redis 存储会话信息4.1 单机 SessionRegistry 的瓶颈在哪SessionRegistryImpl是基于内存的并发安全 Map 实现。单机、单实例、少量用户完全够用。但是一旦服务多实例部署或者使用负载均衡就会出问题用户第一次登录落在实例 ASessionRegistry 记录在实例 A 内存。用户第二次登录落在实例 B实例 B 的 SessionRegistry 查不到旧会话于是又放了新会话。最终结果是同一个用户存在 2 个有效会话都“觉得自己是唯一的”。解决办法有两个大方向。一是做 Sticky Session让同一个用户的 Session 永远绑定到同一个实例上这种方式对 Spring Security 透明但负载均衡配置和故障转移会变得麻烦。二是把会话状态集中存储也就是下面要说的 Redis 方案。4.2 基于 Redis 的 Token 版本号淘汰旧会话如果你的项目用的是 JWT 或者自定义 Token 而非 HttpSession那么 SessionRegistry 这套就不太适合了。更常见的做法是“登录版本号校验”。核心思路是这样的用户表里增加一个字段token_version每次登录成功后自增。给客户端签发的 JWT 里带上这个版本号。后端在每次请求鉴权时从 JWT 解析出版本号与数据库或 Redis里的当前版本号比对。如果不一致说明该用户的凭证已经“作废”返回 401。这样当用户第二次登录时token_version变为 2第一次登录时签发的 JWT 里存的还是 1自然就被判定为失效。效果上就是“新登录踢掉旧登录”而且是真正物理级别的失效。关键代码可以这样写// 登录成功时 String tokenVersion String.valueOf(user.getTokenVersion() 1); userMapper.updateTokenVersion(user.getId(), tokenVersion); redisTemplate.opsForValue().set(login:token:version: user.getId(), tokenVersion); String jwt Jwts.builder() .setSubject(user.getId().toString()) .claim(tokenVersion, tokenVersion) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();// 请求鉴权时 Integer currentVersion Integer.valueOf( redisTemplate.opsForValue().get(login:token:version: userId) ); Integer jwtVersion (Integer) claims.get(tokenVersion); if (!currentVersion.equals(jwtVersion)) { throw new AuthException(登录已失效请重新登录); }这种方案的优势很明显无状态、天然支持集群、淘汰旧会话是即时的。缺点是需要自己维护版本号的并发安全和过期策略。实际项目中我会在 Redis 里对login:token:version:userId设置一个略长于 Token 有效期的 TTL防止用户长期不登录时版本号丢失导致重新登录时版本号校验出问题。4.3 App Web 双端同时登录的变体需求很多项目接移动端之后需求会从“只允许一个登录”变成“Web 端只允许一个App 端和 Web 端可以共存”。在这种情况下单纯的maximumSessions(1)就不够了。做法是改造“用户会话的唯一键”。比如把 principal 拆成userId:clientType也就是 SessionRegistry 里注册的主体不再只是 userId而是userId : clientType。这样 Web 端和 App 端会被视为两个不同的 key互不踢除相同端之间才会互踢。SessionRegistry 本身不用换只需要在注册会话的时候拼上客户端类型sessionRegistry.registerNewSession( request.getSession().getId(), userId : request.getHeader(X-Client-Type) );要注意的是认证成功处理器里getAllSessions也要用同一个拼接后的 key 去查否则又会查不到。5. 常见问题与排查实录5.1 配置了 maximumSessions 却不生效这个我排过很多次基本就三个原因原因症状解决办法没注册HttpSessionEventPublisher踢出逻辑偶尔生效重启后“幽灵会话”残留增加该 Bean用户主体对象的toString()没起到唯一作用多个用户互相踢来踢去检查 principal 是否实现了稳定的toString()或指定sessionAuthenticationStrategy使用自定义 key使用的是 JWT 且不依赖 HttpSessionmaximumSessions 完全不触发放弃该方案改为 Token 版本号校验其中第二个原因比较隐蔽。默认策略RegisterSessionAuthenticationStrategy会把 principal 的toString()作为注册 key。如果 UserDetails 的toString()返回的是User{usernameadmin, password...}这类包含地址的内容那么每次登录的 key 都不同并发控制形同虚设。解决方法是重写调用方的User类的toString()或者干脆实现自己的SessionAuthenticationStrategy。5.2 被踢后返回结果太重定向默认的expiredUrl只适合服务器渲染的页面前后端分离项目请百分百替换为SessionInformationExpiredStrategy。这里提醒一下expiredSessionStrategy配置在maximumSessions之下但expiredUrl和它不能同时使用expiredUrl的优先级高于策略类。也就是说如果你同时配了expiredUrl和expiredSessionStrategy还能看到跳转先删掉expiredUrl再说。5.3 手动踢出不彻底会话还在 registry 里session.expireNow()只做了标记不会销毁 HttpSession。如果希望真正销毁还需要调用sessionRegistry.getSessionInformation(sessionId).expireNow(); // 强制销毁 session HttpSession httpSession request.getSession(false); if (httpSession ! null) { httpSession.invalidate(); }但要注意如果你在被踢的旧会话对应的请求线程里调invalidate()必须确认那确实是旧会话的请求别把新登录的会话给销毁了。所以一般建议只做 expireNow 标记配合注册的HttpSessionEventPublisher让容器在 session 周期结束时自动清理。5.4 集群下部署会话状态不一致单机方案在集群下会遇到“我踢不掉另一个节点的会话”。这个问题上我的建议是如果还在用 HttpSession直接引入spring-session-data-redis把 Session 集中存储。SessionRegistry 依然基于内存但它保存的 session 标识sessionId在 Redis 里都能查到所以能实现跨实例踢出。如果已经用了 JWT 无状态方案直接走 4.2 的版本号校验不要硬套 SessionRegistry。5.5 被迫下线后旧页面还能继续请求有同学反馈已经被踢了但旧页面上的 JS 还在定时请求后端接口偶尔还会成功。原因很可能是你后端接口没有走 Spring Security 的过滤器链某些静态资源或白名单接口没被拦截。解决办法是确认自定义SecurityFilterChain的authorizeHttpRequests覆盖了你所有需要保护的接口同时ConcurrentSessionFilter仍然在过滤链中。如果你用了EnableGlobalMethodSecurity(prePostEnabled true)那也要确认接口方法上有PreAuthorize(isAuthenticated())一类的注解否则方法级别的鉴权不会触发。6. 收尾这部分代码还可以怎么改做到这里“踢掉前一个登录用户”这个功能已经具备上线能力了。实际项目里我一般还会顺手做两件小事。第一把“被踢原因”结构化。不要只返回一句“您的账号已在其他设备登录”建议把被踢时间、登录 IP、登录设备类型都带上。做法就是在SessionInformationExpiredStrategy里根据 session 信息从 Redis 或数据库查出最后一次登录记录拼接进返回体。第二配合 WebSocket 做主动通知。如果项目里有在线通知系统可以在成功处理器里通过 WebSocket 给旧会话推送一条“你的账号已在别处登录”的消息然后前端主动跳转而不是等着下次请求才被打回。这个属于体验优化但很多甲方看到这个细节满意度会明显不一样。我把这些记下来是因为“踢掉前一个登录用户”这个需求本身很简单但牵扯出来的会话管理、集群状态、安全防护这些问题才是真正值得沉淀的。如果你照着上面的方案做下来遇到的最大难点大概率不是写代码而是理解 Spring Security 为什么会把简单的事情拆得这么“绕”。想通它的设计思路之后你会发现它其实给了你足够的自由去控制会话的一生。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑