Spring Security踢出指定用户:从SessionRegistry到JWT无状态方案

发布时间:2026/10/1 3:14:03
Spring Security踢出指定用户:从SessionRegistry到JWT无状态方案 SpringSecurity踢出指定用户这个需求听起来很不起眼但真正动手做的时候你会发现它牵扯出来的问题一个比一个多会话怎么追踪、踢人之后用户下一次请求怎么被拦截、无状态 JWT 场景下又该怎么处理。我最近在一个多端登录的后台系统里被这个功能折腾了整整一轮从最基础的 SessionRegistry 到后来接入 JWT 的替代方案踩了不少坑也把原理彻底摸了一遍。这篇文章就把我实测过的东西完整记录下来包含可直接落地的代码、配置和排查思路希望能帮你少走弯路。1. 先想清楚踢出指定用户到底要解决什么问题1.1 什么场景下需要踢人功能先别急着写代码把需求翻译清楚比什么都重要。我在实际项目里遇到的踢人一般分三种情况第一种是管理员强制下线某个账号。比如运营发现某个用户批量刷接口、发布违规内容需要让他立刻停止操作或者客服接到投诉需要把某个异常登录的会话直接终止掉。第二种是账号安全处理。用户自己修改密码、找回账号之后希望所有旧设备上的登录态全部失效只保留当前这一台设备。这种情况本质上也是踢出指定用户只是触发方从管理员变成了用户自己。第三种是并发登录控制。同一个账号只允许在一处登录新的登录进来旧的被挤下去。这个需求虽然和踢人不完全一样但底层用的都是同一套 Spring Security 会话管理机制。区分清楚场景很重要因为不同场景下你要踢的目标不一样是踢掉某个用户名下的所有会话还是只踢掉某一个具体的 Session是踢完就完事还是要在踢的同时记录审计日志。我在项目里就是因为一开始没想清楚把踢所有会话和踢指定会话的逻辑写在了一起后来才不得不重构。1.2 Spring Security 的会话模型能做什么Spring Security 对会话的管理比很多人想象中要强大但前提是你得知道去哪里找这些能力。核心是三个组件SessionRegistry 是会话注册表所有活跃的 Session 信息都会被记录在里面。它维护了一张从 principal用户名/SessionId到 Session 的映射表你可以通过用户名查到这个人名下所有的会话记录。SessionInformation 是单条会话信息的封装里面保存了 SessionId、principal、最后活跃时间以及一个 expired 状态标记。这个 expired 标记很关键它就是踢人的开关。ConcurrentSessionFilter 是过滤器链上的一个环节它在每个请求进来的时候都会检查当前 Session 对应的 SessionInformation 是否被标记为 expired。如果已经过期就直接中断请求让你跳转到指定的过期页面或者返回一个 401/403 响应。这三个东西配合起来就是 Spring Security 踢出指定用户的基础框架。但这里有一个很多人会忽略的前提SessionRegistry 默认并没有注册到容器里你需要自己声明一个 Bean并且在 HttpSecurity 配置里显式开启 sessionManagement 的并发控制否则上面这套东西根本不会生效。2. 基础方案SessionRegistry 在线用户管理2.1 开启并发会话控制先看一个最基础、但也是最重要的配置。我用的是 Spring Boot 3 Spring Security 6如果你还在用 Spring Security 5.x写法上有一点差异但核心思路一致。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/login, /error).permitAll() .anyRequest().authenticated() ) .formLogin(login - login .loginProcessingUrl(/login) .defaultSuccessUrl(/index, true) ) .sessionManagement(session - session .maximumSessions(10) .maxSessionsPreventsLogin(false) .expiredUrl(/login?expiredtrue) .sessionRegistry(sessionRegistry()) ); return http.build(); } Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } }这里面最关键的几行我拆开说。maximumSessions(10) 表示每个账号最多同时存在 10 个会话。如果你把 1 传进去就是经典的单端登录效果。maxSessionsPreventsLogin(false) 表示当会话数达到上限时不阻止新登录而是把最老的那个会话踢掉。如果你设成 true新登录会被直接拒绝这种体验在大多数业务场景下都不太友好除非你有明确的产品要求。expiredUrl 是会话被标记过期之后ConcurrentSessionFilter 发现当前 Session 已经失效时重定向的地址。接口场景下你更可能想返回 JSON 而不是跳页面那就要用 expiredSessionStrategy 自定义处理逻辑我后面会讲。还有一点要注意如果你压根没有配置 sessionManagement 这一段即使你注入了 SessionRegistry Bean它也不会被 Spring Security 使用。这个坑我见过不止一次代码里注册了 SessionRegistry但踢人接口怎么调都不生效查了半天发现是过滤器链里根本没挂上并发会话管理。2.2 注册 SessionRegistry 并追踪会话配置写好了接下来要保证会话能被正确追踪。Spring Security 在登录成功的时候会通过 SessionAuthenticationStrategy 把新的 Session 注册到 SessionRegistry 里。判断一个用户是谁靠的是 principal默认情况下 principal 就是从 Authentication 里拿到的对象。这里有个非常隐蔽的坑如果你没有自定义 UserDetailsService或者 Authentication 里的 principal 类型不稳定SessionRegistry 存进去的 key 可能不是你期望的用户名。比如你对比一下会发现同一账号第一次登录和第二次登录principal 的内容不一致导致 SessionRegistry 里出现了两条不同的记录踢人的时候你按用户名去查只查到其中一部分会话。解决办法很直接自定义 UserDetailsService确保每次登录拿到的 principal 是同一个对象类型并且重写 equals 和 hashCode。最简单的方式是让 principal 就是用户名 String或者用一个固定的 UserDetails 实现类。再看一眼 SessionRegistryImpl 的源码就会发现它内部用的是 ConcurrentHashMapkey 是 principalvalue 是一个 Set。查找的时候先按 principal 找到对应的会话集合再遍历出所有 SessionInformation。所以 principal 不稳定后面所有按用户名踢人的操作都会出问题。2.3 踢出指定用户的 API 实现框架搭好以后踢人接口本身反而很简洁。下面这个是我在实际项目中使用的核心代码RestController RequestMapping(/admin) public class AdminSessionController { private final SessionRegistry sessionRegistry; public AdminSessionController(SessionRegistry sessionRegistry) { this.sessionRegistry sessionRegistry; } PostMapping(/users/{username}/kick-out) public Result kickOut(PathVariable String username) { ListSessionInformation sessions sessionRegistry.getAllSessions(username, false); if (sessions.isEmpty()) { return Result.error(该用户当前没有在线会话); } for (SessionInformation session : sessions) { session.expireNow(); } return Result.success(已踢出用户 username 的 sessions.size() 个会话); } PostMapping(/users/{username}/kick-out/{sessionId}) public Result kickOutSession(PathVariable String username, PathVariable String sessionId) { SessionInformation session sessionRegistry.getSessionInformation(sessionId); if (session null) { return Result.error(会话不存在或已过期); } if (!session.getPrincipal().equals(username)) { return Result.error(该会话不属于指定用户); } session.expireNow(); return Result.success(指定会话已下线); } }getAllSessions(username, false) 的第二个参数是 includeExpired传 false 表示只查还在活跃状态的会话。这个细节容易踩坑如果你传 true会把已经标记过期但还没有被清理掉的会话也查出来然后你对着一个已经过期的 SessionInformation 再调用 expireNow()虽然不会报错但逻辑上就很奇怪而且计数也会不准。expireNow() 只是把 SessionInformation 里的 expired 标记置为 true它不会立刻销毁 HttpSession也不会立刻断开用户的长连接。真正的拦截发生在下一次请求到达 ConcurrentSessionFilter 的时候。另外强调一点这个接口本身必须做权限控制。我在配置里用 requestMatchers(/admin/**).hasRole(ADMIN) 把它保护起来不会的话等于给攻击者留了一个全站踢人后门。3. 无状态 JWT 场景下如何踢人3.1 为什么 JWT 方案里踢人变难了如果你用的是前后端分离项目大概率你会选择无状态的 JWT 认证。Spring Security 经典教程里也会教你把 SecurityContextRepository 配成 STATELESS让服务端完全不存 Session。问题就出在这里JWT 是无状态的服务端不保存任何会话记录token 只要没过期每次请求都能通过校验。你想踢人但没有一个地方能记录这个人的所有 token 是什么、在哪台设备上。SessionRegistry 那一套在无状态模式下根本不会生效因为压根没有 HttpSession 可以追踪。我接手过一个真实项目就是这种状态登录发 JWTSecurity 配置里 sessionCreationPolicy(SessionCreationPolicy.STATELESS)结果管理员想禁用某个用户只能直接改数据库把账号冻结但已经发出的 token 在过期之前依然能正常访问接口。用户明明被冻结了还能继续调接口这就是典型的假踢人。要解决这个问题核心思路只有一个把无状态变成有状态。不是要求你重新用回 Session而是把token 是否有效这件事从纯算法校验变成需要查一下服务端状态。下面几种方案我都实测过各有取舍。3.2 几种可行的踢人落地策略第一种是 Token 版本号方案。给用户表加一个 tokenVersion 字段登录的时候把版本号写进 JWT 的 claim 里写一个过滤器在每次请求时从数据库或者缓存里读出当前用户的版本号和 JWT 里的比对不一致就直接拒绝。踢人就是给这个用户执行一次 tokenVersion 1。这个方案的好处是简单直接数据库更新一下即可所有旧 token 立刻失效。坏处是每次请求都要查一次用户状态对数据库压力大。我通常的做法是把这个版本号放到 Redis 里key 就是 userIdvalue 是当前版本号查询走缓存踢人时更新缓存并同步数据库。第二种是 Token 黑名单方案。登录成功时生成 token同时把 token 的唯一标识 jti 存一份到 Redis有效期的 key 是 token 剩余存活时间。踢人的时候把这个用户名下的所有 jti 拿出来塞进黑名单。每次请求过滤器里先判断 jti 是否在黑名单中如果在直接拒绝。这种方案在 Spring Security OAuth2.1 的生态里很常见因为 JWT 本身带 jti claim解析很方便。需要额外注意的是黑名单必须设置过期时间否则 Redis 内存会被无限制增长的 key 撑爆。我在项目里给黑名单 key 设的过期时间就等于该 token 自身的剩余有效期。第三种是用户状态位方案。给用户表加一个 status 字段禁用时改成 1。过滤器每次请求时检查 Redis 里缓存的用户状态发现被禁用就拒绝访问。这种方式本质上是把踢人从会话管理变成了账号管理粒度比较粗但实现成本最低而且天然支持踢所有设备。我用一个表格总结一下这三种方案的差异方案踢人粒度是否需要每次查状态实现成本适用场景Token 版本号踢掉某用户所有 token是低单账号多设备统一失效Token 黑名单可精确到单个 token是中需要踢指定设备用户状态位踢掉整个账号是最低账号冻结、封禁3.3 结合 Spring Authorization Server / OAuth2.1 的注意点如果你用的是 Spring Authorization ServerOAuth2.1 风格情况又不一样。这一套体系里token 的发放和管理完全交给授权服务器JWT 不是你自己手动生成的而是由 OAuth2AuthorizationService 统一记录。也就是说授权服务器实际上是有状态的它维护了 authorizationId、token 和 refreshToken 之间的关联。这种情况下踢人的思路就变成了直接删除或撤销对应的 OAuth2Authorization 记录。Spring Authorization Server 原生提供了 OAuth2TokenRevocationEndpoint支持通过 token 撤销接口来让 token 失效。但要注意OAuth2.1 的撤销端点需要传 token 本身而管理员手里通常只有用户名所以需要先用 OAuth2AuthorizationService 查询出该用户名下所有 authorization 记录再逐一撤销。我在集成的时候发现一个坑默认的 OAuth2AuthorizationService 是针对单机内存或 JDBC 实现的如果你没有主动把授权记录持久化到 Redis 等共享存储那么集群环境下某台节点上撤销了记录另一台节点的授权服务并不知道。所以做集群部署时OAuth2AuthorizationService 的实现必须选择共享存储版本。另外提醒一句OAuth2.1 默认已经不支持密码模式了Spring Authorization Server 只支持授权码、客户端凭证等模式。想靠 OAuth2.1 做到账号密码登录通常还得自己在前面套一层表单登录然后把得到的东西再和授权服务器做一次交互。这块如果项目刚起步建议先评估清楚别选型到一半才发现落地成本超出了预期。4. 实操过程与踩坑记录4.1 一个完整的踢人流程演示我把两种典型场景下的完整流程走一遍你对照着就能直接复现。先看 Session 模式。启动服务修改上面的 SecurityConfig注册 SessionRegistry。然后先登录用户 zhangsan。观察控制台输出的 Session 信息你会发现登录成功后HttpSession 被创建同时 SessionRegistryImpl 里出现了该用户的会话记录。我用一个测试接口查看在线用户GetMapping(/admin/online-users) public Result onlineUsers() { ListObject principals sessionRegistry.getAllPrincipals(); MapString, ListString result new HashMap(); for (Object principal : principals) { ListSessionInformation sessions sessionRegistry.getAllSessions(principal, false); result.put(principal.toString(), sessions.stream() .map(SessionInformation::getSessionId) .collect(Collectors.toList())); } return Result.success(result); }然后再登录一次 zhangsan另一个浏览器打开同一个系统确认两个设备都在线。调用踢出接口POST /admin/users/zhangsan/kick-out返回结果会提示踢出了 2 个会话。这时候你切回第一个浏览器随便点一个需要认证的接口你会看到请求被 ConcurrentSessionFilter 拦截重定向到 configured expiredUrl。我测试时配置的是 /login?expiredtrue所以页面直接跳回登录页了。再走一遍 JWT 黑名单模式。登录接口正常签发 JWTJWT 的 payload 里带上了 jti。我写了一个过滤器认证成功后立刻从 Redis 取黑名单纯判断Component public class JwtBlacklistFilter extends OncePerRequestFilter { private final StringRedisTemplate redisTemplate; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null) { String jti parseJti(token); String blackKey token:blacklist: jti; if (Boolean.TRUE.equals(redisTemplate.hasKey(blackKey))) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录状态已失效\}); return; } } filterChain.doFilter(request, response); } }踢人接口就把该用户 jti 列表里的 token 全部写入黑名单。实测下来踢人操作完成后的下一次请求立即返回 401效果立竿见影。4.2 常见问题与排查我把自己踩过的坑整理成了一张速查表每个问题都附上解决思路问题现象根本原因解决办法踢人接口查到 0 个会话SessionRegistry 没有注入到过滤器链检查 sessionManagement 配置是否正确开启 maximumSessions踢完人用户还能访问接口当前请求已经通过 ConcurrentSessionFilter 之后才被踢踢人的效果体现在下一次请求无法中断正在执行的请求集群下踢人只对部分节点生效SessionRegistryImpl 默认是 JVM 内存实现引入 Spring Session Redis并自定义分布式 SessionRegistry无状态模式下 SessionRegistry 不工作STATELESS 策略下根本没有 HttpSession改用 JWT 黑名单或 token 版本号方案同一账号不同设备查出来多个 principalprincipal 对象不稳定equals/hashCode 未正确实现统一 principal 类型重写 equals/hashCode踢人后 WebSocket 连接没有断开ConcurrentSessionFilter 只拦截 HTTP 请求在 WebSocket 握手或消息拦截中额外判断会话状态这里面我想单独展开说一下当前请求无法被中断这个问题。很多人测试踢人的时候发现用户在踢人接口返回后才发出的请求确实被拦截了但假如用户在踢人前一刻发起了一个请求这个请求已经进入了 Controller踢人接口执行完 expireNow() 之后那个请求依然会正常执行完并返回数据。这不是 bug而是过滤器的天然限制。如果你有强一致性的要求需要在业务层再做一层状态校验比如关键操作前检查用户是否 still active。还有 WebSocket 的问题我第一次做在线客服系统的踢人功能时被坑过一次。管理员把某个违规用户踢下线HTTP 接口确实都被拦截了但用户和客服的 WebSocket 长连接还活着消息照样收发。后来我在 WebSocketHandler 的子协议拦截器里手动查了一下 SessionRegistry发现会话处于 expired 状态就直接关闭连接兜底解决了。4.3 我的几个实操经验第一踢人功能上线前一定要设计好审计日志。谁在什么时间踢了哪个用户、踢掉了哪台设备这些信息在出问题的时候能帮你快速定位。我在踢人接口里加了一个 AuditLog 注解把操作人、目标用户、sessionId 全部记下来后来运营反馈误踢了一个重要客户靠日志立刻还原了现场。第二Session 模式的踢人有一个天然缺陷你只能踢掉通过 HttpSession 建立的会话。如果你的系统还有一个移动端用 JWT 认证或者第三方开放平台用的是 OAuth2 tokenSessionRegistry 管不到它们。我建议在线用户管理做一个统一的抽象把 SessionRegistry、JWT 黑名单、OAuth2AuthorizationService 都封装到同一个 OnlineUserService 后面对外只暴露 onlineUsers() 和 kickOut(userId) 两个方法内部按会话类型分发处理。这样在 Controller 层写业务逻辑的时候就不需要关心底层到底是哪种认证方式了。第三关于 maximumSessions 的设置我建议不要设得太小。很多项目一上来就要求一个账号只允许一个设备在线于是把 maximumSessions 设成 1。但实际上用户经常在电脑、平板、手机之间切换体验会非常差。更合理的做法是把上限设成 5 或 8然后配合踢人接口让管理员在特殊情况下手动处理这样既保证了账号安全又不会过度限制正常用户。第四如果你的系统最终既用了 Session 又用了 JWT 会怎么样这是我在一个老项目改造中遇到的问题。登录入口用的是 JWT但 Spring Security 配置忘了删掉 sessionManagement导致每次请求都会创建 HttpSessionSessionRegistry 里出现大量幽灵会话。排查了才发现是 sessionCreationPolicy 设置缺失。所以记住无状态方案下必须显式声明 sessionCreationPolicy(SessionCreationPolicy.STATELESS)。最后说一个容易被忽略的小技巧。ConcurrentSessionFilter 在发现会话过期后默认行为是处理 expiredUrl 重定向。但在前后端分离的接口场景下重定向会破坏前端对接口返回的判断。我后来用 expiredSessionStrategy 实现了自定义响应返回统一的 JSON 结构前端只需要捕获 401 状态码就会自动跳转登录页并清除本地 token。这样踢人功能才算真正完整。做踢人功能表面上是让某个人的登录失效但背后牵扯到会话状态机、无状态 token 的取舍、集群一致性、长连接处理每一个点都能单独写一篇排坑指南。上面这些内容都是我逐行代码实测出来的如果你正在做这个需求建议先把业务流程理清楚再对照我的方案选型落地。按照这个思路走大概率能避开我踩过的那些坑。