做后台管理系统,尤其是做权限这块的时候,大概率都会接到这样一个需求:同一个账号只能在一个地方登录,后登录的要把前面登录的那个给踢下线。在 Spring Security 体系里,这就是典型的“并发会话控制”,也就是标题里说的“SpringSecurity 实现踢掉前一个登录用户”。
这个需求看着简单,实际做起来会发现里面藏着不少坑。比如配置了maximumSessions(1)为什么没生效?为什么后登录的用户反而被拦截了?集群部署下 SessionRegistry 为什么不工作?这篇文章我会从需求拆解开始,把设计思路、核心配置、代码实现、踩坑记录全部串起来讲,尽量让还没接触过这块的人也能照着落地。
1. 需求拆解与技术路线选型
1.1 这个需求本质要解决什么问题
先说清楚“踢掉前一个登录用户”到底在做什么。用户 A 在浏览器登录了系统,还没有退出;用户 A 又在另一台电脑或者另一个浏览器标签页登录。此时系统的行为需要明确:要么拒绝后来的登录(“当前账号已登录,请先退出”),要么让先登录的会话失效(“你的账号已在别处登录”)。标题用的是“踢掉”,也就是走第二种策略:新的登录成功,旧的会话被强制销毁。
这里要拆出三个核心问题:
- 系统怎么知道同一个用户的多个会话分别是什么?那就要给每个登录会话一个唯一标识,并且记录这个“会话归属的用户主键”。
- 新登录成功后,系统怎么找到“旧会话”并将其标记为失效?这需要一个注册表来维护“用户 -> 会话集合”的映射关系。
- 被踢掉的旧会话在再次发请求时,系统怎么让它快速失效并给出友好提示?这里要依赖 Spring Security 过滤器链中的会话过期处理机制。
这三个问题对应 Spring Security 里面的三样东西:HttpSession+SessionRegistry+ConcurrentSessionFilter。把这三者串起来,就是整篇文章的技术主线。
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?expired=true"); return http.build(); } }这里有两个关键参数必须理解:
maximumSessions(1):同一个用户名最多允许 1 个会话。这里的“用户名”,默认取的是Authentication.getPrincipal()的toString()。如果你在 UserDetails 里重写了toString(),那做并发控制的 key 也会跟着变,这点要特别小心。maxSessionsPreventsLogin(false):表示当旧会话存在时,新的登录不被阻止,而是把旧会话标记为过期。如果改为true,就是反过来:新登录被拒绝,系统提示“当前账号已登录”。标题要求“踢掉前一个”,所以这里必须为false。
expiredUrl的作用是:当被标记为过期的旧会话再次发起请求时,会重定向到这个地址。我可以很明确地告诉你,expiredUrl在前后端分离的项目里几乎没用,因为后端重定向对 API 请求没有任何友好性,后面会单独讲这个问题。
2.2 不要忘记注册 HttpSessionEventPublisher
只写上面那段配置,在本地测试大概率发现“踢不掉”。这不是配置错了,而是少了一个关键 Bean:
@Bean 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发布 SessionCreatedEvent,SessionRegistry登记该用户的这个 session。 - 用户 A 在另一台机器再次登录,认证成功后,Spring Security 发现该用户名已有 1 个 session,且
maximumSessions=1,于是把旧 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 List<SessionInformation> 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;charset=UTF-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;charset=UTF-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 实现。单机、单实例、少量用户完全够用。但是一旦服务多实例部署,或者使用负载均衡,就会出问题:
- 用户第一次登录落在实例 A,SessionRegistry 记录在实例 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 且不依赖 HttpSession | maximumSessions 完全不触发 | 放弃该方案,改为 Token 版本号校验 |
其中第二个原因比较隐蔽。默认策略RegisterSessionAuthenticationStrategy会把 principal 的toString()作为注册 key。如果 UserDetails 的toString()返回的是User{username=admin, 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 为什么会把简单的事情拆得这么“绕”。想通它的设计思路之后,你会发现,它其实给了你足够的自由去控制会话的一生。