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?expired=true") .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 的源码就会发现,它内部用的是 ConcurrentHashMap,key 是 principal,value 是一个 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) { List<SessionInformation> 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 可以追踪。
我接手过一个真实项目,就是这种状态:登录发 JWT,Security 配置里 sessionCreationPolicy(SessionCreationPolicy.STATELESS),结果管理员想禁用某个用户,只能直接改数据库把账号冻结,但已经发出的 token 在过期之前依然能正常访问接口。用户明明被冻结了,还能继续调接口,这就是典型的"假踢人"。
要解决这个问题,核心思路只有一个:把"无状态"变成"有状态"。不是要求你重新用回 Session,而是把"token 是否有效"这件事从纯算法校验变成"需要查一下服务端状态"。下面几种方案我都实测过,各有取舍。
3.2 几种可行的踢人落地策略
第一种是 Token 版本号方案。给用户表加一个 tokenVersion 字段,登录的时候把版本号写进 JWT 的 claim 里,写一个过滤器在每次请求时从数据库或者缓存里读出当前用户的版本号,和 JWT 里的比对,不一致就直接拒绝。踢人就是给这个用户执行一次 tokenVersion + 1。
这个方案的好处是简单直接,数据库更新一下即可,所有旧 token 立刻失效。坏处是每次请求都要查一次用户状态,对数据库压力大。我通常的做法是把这个版本号放到 Redis 里,key 就是 userId,value 是当前版本号,查询走缓存,踢人时更新缓存并同步数据库。
第二种是 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 Server(OAuth2.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() { List<Object> principals = sessionRegistry.getAllPrincipals(); Map<String, List<String>> result = new HashMap<>(); for (Object principal : principals) { List<SessionInformation> 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?expired=true,所以页面直接跳回登录页了。
再走一遍 JWT 黑名单模式。登录接口正常签发 JWT,JWT 的 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;charset=UTF-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 版本号方案 |
| 同一账号不同设备查出来多个 principal | principal 对象不稳定,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 token,SessionRegistry 管不到它们。我建议在线用户管理做一个统一的抽象,把 SessionRegistry、JWT 黑名单、OAuth2AuthorizationService 都封装到同一个 OnlineUserService 后面,对外只暴露 onlineUsers() 和 kickOut(userId) 两个方法,内部按会话类型分发处理。这样在 Controller 层写业务逻辑的时候就不需要关心底层到底是哪种认证方式了。
第三,关于 maximumSessions 的设置,我建议不要设得太小。很多项目一上来就要求"一个账号只允许一个设备在线",于是把 maximumSessions 设成 1。但实际上用户经常在电脑、平板、手机之间切换,体验会非常差。更合理的做法是把上限设成 5 或 8,然后配合"踢人"接口让管理员在特殊情况下手动处理,这样既保证了账号安全,又不会过度限制正常用户。
第四,如果你的系统最终既用了 Session 又用了 JWT 会怎么样?这是我在一个老项目改造中遇到的问题。登录入口用的是 JWT,但 Spring Security 配置忘了删掉 sessionManagement,导致每次请求都会创建 HttpSession,SessionRegistry 里出现大量"幽灵会话"。排查了才发现是 sessionCreationPolicy 设置缺失。所以记住:无状态方案下必须显式声明 sessionCreationPolicy(SessionCreationPolicy.STATELESS)。
最后说一个容易被忽略的小技巧。ConcurrentSessionFilter 在发现会话过期后,默认行为是处理 expiredUrl 重定向。但在前后端分离的接口场景下,重定向会破坏前端对接口返回的判断。我后来用 expiredSessionStrategy 实现了自定义响应,返回统一的 JSON 结构,前端只需要捕获 401 状态码,就会自动跳转登录页并清除本地 token。这样踢人功能才算真正完整。
做踢人功能,表面上是"让某个人的登录失效",但背后牵扯到会话状态机、无状态 token 的取舍、集群一致性、长连接处理,每一个点都能单独写一篇排坑指南。上面这些内容都是我逐行代码实测出来的,如果你正在做这个需求,建议先把业务流程理清楚,再对照我的方案选型落地。按照这个思路走,大概率能避开我踩过的那些坑。