
做后台管理的同学应该都收到过类似需求“把某个刚注册的账号踢下去”“让那个异常登录的用户立刻失效”。在 SpringSecurity 的语境里这就是“踢出指定用户”而一旦你的项目是 SpringSecurity 整合 JWT 的无状态认证模式这个需求就会变成一道让人头疼的题目JWT 本身不保存状态token 发出去之后服务端既没有 session 可以销毁也不知道用户是不是还拿着它在到处访问。怎么把指定用户精准下线这篇文章就从需求拆解讲到方案选型再给一套能直接落地的 Redis JWT 实现最后把我踩过的坑一并倒出来。先说清楚这篇文章能解决什么问题。如果你正在做后台管理系统、会员系统或者 To B 平台恰好又用了 SpringSecurity 做认证授权、JWT 做登录凭证现在产品经理提出要“强制下线某个用户”或者“在设备管理里踢掉一台设备”那这篇文章就是给你准备的。无论你是刚接手这种需求的新人还是已经写了不少 Spring Security 配置的熟手都能从这里面找到一套不用大改架构就能实现的思路。1. 需求拆解先把“踢出指定用户”翻译成技术问题1.1 产品口中的“踢人”跟你想的一样吗产品经理说“踢人”背后往往藏了三种不同的业务场景。第一种是账号风控后台发现某个用户存在异常行为需要立刻终止其所有会话让他在任意设备上都不能继续操作。第二种是客服介入用户打电话说账号被盗了客服在后台帮他重置登录状态把他手机上的旧会话强制清掉。第三种是设备管理用户自己在App的“设置-登录设备”页面里想把某台旧手机上的登录状态退出只保留当前设备。这三种场景对应的技术操作完全不一样。第一种是“按人踢全端”第二种可能既涉及全端又涉及单端第三种则是非常明确的“按会话踢单端”。如果一开始没把这些场景拆开很容易写出一个只支持“全端踢”的接口结果产品说“我只想把他iPad上那台踢掉手机还要保持登录”你就得返工。从技术层面翻译一下所谓“踢出指定用户”本质上是让某个用户对应的一份或多份认证凭证在下一次访问时立即失效。注意“下一次访问”这四个字因为 HTTP 本身是无状态的服务端只能等用户发请求过来时才能拦截不可能像打电话一样“让他的手机立刻断线”。如果你的系统有 WebSocket 或者服务端推送能力那是另一回事但最基础的功能一定是建立在“请求到达校验时发现凭证已失效”这个机制上的。1.2 为什么 JWT 让这个需求变得棘手传统的 Session 方案里用户登录成功后服务端会存一份 Session浏览器的 Cookie 里只保存一个 SessionId。想踢人直接在服务端把 Session 删掉或者标记过期就行一删一个准。但 JWT 完全不同服务端在登录成功后签发的是一段自包含的 token用户信息、过期时间、自定义字段全都在 token 里。服务端在接口校验时只做两件事验签名、看过期时间。只要这两个条件满足不管你之前是不是把这用户拉黑了请求都会被认为是合法的。用一个生活化的类比来说Session 方案像酒店前台管理房卡前台有一本登记簿客人每次进房门服务员可以查登记簿发现你被注销了就直接不让你进。JWT 方案则像是给客人发了一张带防伪标识的通行证门口的保安只看防伪标识和有效期根本不去查登记簿。如果安保系统只知道“这张通行证是真的、还没过期”那前台想“让某个客人立刻滚蛋”就很麻烦——除非修改规则让保安每次都在登记簿上确认一下“此人是否还在允许入内的名单里”。所以问题的核心不是“怎么让 JWT 消失”而是“怎么在 JWT 之外补一份状态记录让服务端重新掌握对用户会话的生杀大权”。这也正是为什么很多项目在 SpringSecurity 整合 JWT 之后会发现“登录容易、踢人难”的原因。1.3 想要优雅落地先看清这几条路目前业界比较常见的做法有三类。第一类是沿用 Session 思路用 Spring Security 自带的 SessionRegistry 管理会话。第二类是维护一份 token 黑名单把被踢的 token 标记为失效。第三类是维护一份会话白名单服务端记录所有“当前有效”的会话标记踢人时把对应标记从名单里移除。这三类方案各有各的适用场景和致命伤。SessionRegistry 方案只适合传统“Cookie Session”项目前后端分离和移动端场景基本没法用。黑名单方案改动小但遇到“踢指定用户”这种需求时你得先想办法找到这个用户名下所有的 token而且被踢 token 还必须一直保留到过期时间存储成本会持续累积。白名单方案是目前我在实际项目里最推荐的做法它能精确控制到“某一个会话”或“某一个用户的所有会话”而且天然支持在线设备列表这种衍生功能。接下来我展开讲讲为什么这么选。2. 方案选型Session、黑名单、白名单到底怎么选2.1 老一代 Session 方案的适用边界很多同学在网上搜“Spring Security 强制下线”会搜到 SessionRegistry 和session.invalidate()之类的写法。Spring Security 确实提供了SessionInformation.expireNow()这样的 API可以让一个指定用户的所有 session 立刻失效。这个思路在传统的单体后台项目里是没问题的用户登录走的是 HttpSession所有 session 都存在服务端内存里SessionRegistry能拿到当前所有在线 session管理员点一下“踢人”对应 session 就被标废用户下一次请求时 Session 已经被销毁自然就跳回登录页了。但这个方案放到现在的前后端分离架构里问题就会一个个冒出来。前端和后端分离部署通常是两台服务器HttpSession 存在后端进程内存里前端不可能通过 Cookie 来回带着 SessionId 访问。就算你是单体应用只要一上分布式、多实例部署session 还要想办法做会话共享要么粘性会话要么把 session 丢进 Redis。而 JWT 之所以流行恰恰是因为它不需要这些复杂的状态管理。既然你已经选了 JWT 路线再回头用 SessionRegistry 来踢人相当于为了一个踢人功能把整套认证机制拉回了有状态时代成本太高不值当。2.2 Token 黑名单方案看起来直接坑却不少黑名单方案的思路很直观被踢的 token 不是还能验签通过吗那我把它存到一个“黑名单”里以后每次请求都先看看这个 token 在不在黑名单中在就拒绝。这个方案唯一的优点是实现简单登录逻辑不用动只需要在踢人时把 token 塞进 Redis 黑名单。但它有两个非常现实的痛点。第一个痛点是“踢指定用户”时你手上不一定有这个用户当前的 token。用户登录以后token 存在他自己那边服务端又没记录。管理员在后台说“把用户 123 踢了”你要么要求前端把该用户所有设备上的 token 上报回来要么就得在登录时就额外存一份“用户 ID 到 token 的映射”这等于又回到了白名单的思路上。第二个痛点是黑名单的过期问题。JWT 的过期时间通常设置为几十天甚至更长如果踢一个用户就把他的 token 放进黑名单、一直保留到他 token 自然过期那黑名单就会无限膨胀。你可以给黑名单手动设置一个比 token 剩余时间稍长的 TTL但这么设计以后逻辑就没那么干净了如果你记错 TTL或者 token 刷新过就会出现黑名单先过期、旧 token 又复活的情况。2.3 我更推荐的版本号 在线会话白名单方案白名单方案的核心是服务端不再对“谁登录过”一无所知而是主动记录一份“当前有效会话清单”。每个会话在登录成功时生成一个唯一 ID比如sid把它同时写进 JWT 的 claim 和 Redis 的会话 key 里。请求过来的时候过滤器解析 JWT 拿到用户 ID 和会话 ID去 Redis 里查一下这个会话是否还在有效名单中在就放行不在就拒绝。踢人的时候你只需要把对应用户的会话记录从 Redis 里删掉。这个方案有几个好处。第一你在任何时候都能回答“这个用户有哪些设备在线”这种灵魂拷问因为每个在线设备的会话记录都存在 Redis 里。第二粒度可控你可以指定踢某一个sid也可以把某用户的所有sid全部删掉这就是“踢单端”和“踢全端”的区别。第三它没有黑名单那种无限膨胀的问题Redis 里存的是有效会话用户退出、token 过期、被踢对应的 key 都删掉了剩余数据量大约等于在线设备数非常可控。有人可能会说这不是又把 JWT 变成有状态了吗其实不必把它看成“推翻 JWT”更好的理解是分层JWT 继续负责“身份验证”解决“这个 token 是不是合法签发、有没有被篡改”的问题Redis 会话记录负责“会话状态管理”解决“这个身份当前是否被允许访问”的问题。两者各司其职既保留了 JWT 天然的跨端、跨服务优势又把“踢人”这种状态管理能力补了回来。下面这套实操就是沿着这个思路来落地的。3. 手把手实现基于 Redis JWT Spring Security 精准踢人3.1 数据模型与 Redis Key 设计先不急着写代码把存储结构想清楚后面会顺很多。我的设计是每个在线会话对应一条 Redis 记录同时为每个用户维护一个“会话索引集合”用来支持全端踢。用途Redis KeyValue 类型TTL在线会话记录login:session:{userId}:{sid}String可存登录时间与 JWT 过期时间一致建议多加 5 分钟冗余用户会话索引login:user:{userId}:sidsSet存放该用户所有 sid长期保留随成员增减使用的时候登录成功就新生成一个sid把login:session:{userId}:{sid}写入 Redis同时把sid加入用户索引集合。踢单端时删除单个login:session:{userId}:{sid}并把该sid从索引集合里移除。踢全端时遍历索引集合里的所有 sid逐个删除会话记录最后删除索引集合本身。这样 Redis 里的数据始终是“有效在线会话”的镜像不会有任何残留的黑名单垃圾。关于 TTL这里有个细节需要注意。JWT 的exp是它的绝对过期时间Redis 会话记录的 TTL 应该设置为“从当前时刻起到 JWT 过期时刻为止再延长 5 分钟左右”。为什么要延长时间因为网络有延迟JWT 可能还差几秒才真正过期如果 Redis 里的 key 抢先被删了用户拿着一个还没过期的合法 token 来做一次操作就会被误认为“被踢”体验很差。所以我在代码里统一做了一个ttl jwtExpirationMillis - 当前耗时 5 * 60 * 1000L的补偿虽然多占了几分钟存储空间但能有效避免误杀。3.2 登录认证给 JWT 打上会话标记登录逻辑的整体流程是校验用户名密码 → 生成sid→ 签发 JWT → 写入 Redis 会话记录和用户索引 → 返回 token 给前端。下面是一段基于 Spring Boot 2.7、Spring Security 5.x、jjwt 0.9.x 的核心代码你可以根据自己项目的版本做调整。Service RequiredArgsConstructor public class AuthService { private final RedisTemplateString, Object redisTemplate; private final AuthenticationManager authenticationManager; public LoginResponse login(LoginRequest request) { // 1. 交给 Spring Security 做用户名密码认证 authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); // 2. 查询用户信息 User user userMapper.selectByUsername(request.getUsername()); // 3. 生成会话ID并写入JWT的claim String sid UUID.randomUUID().toString().replace(-, ); long tokenTtlMillis 24 * 60 * 60 * 1000L; Date now new Date(); Date expireAt new Date(now.getTime() tokenTtlMillis); String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(sid, sid) .setIssuedAt(now) .setExpiration(expireAt) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); // 4. 写入Redis在线会话 用户会话索引 String sessionKey login:session: user.getId() : sid; long redisTtlMillis tokenTtlMillis 5 * 60 * 1000L; redisTemplate.opsForValue().set(sessionKey, String.valueOf(now.getTime()), Duration.ofMillis(redisTtlMillis)); redisTemplate.opsForSet().add(login:user: user.getId() :sids, sid); return LoginResponse.builder() .token(token) .expiresAt(expireAt) .build(); } }代码里有两处容易忽略。第一处sid必须放 JWT 的 claim 里而且后续踢人接口也要用到它所以前端需要把sid一并返回。如果你不想让前端额外保存sid也可以要求前端在调用踢人接口时把当前登录 token 传过来解析 token 来提取sid。第二处如果业务要求“账号同时只能在一个设备登录”那登录时就不能直接新增会话得先把这个用户所有的旧 session 全部踢掉再写入新 session。这正好可以复用后面踢人接口的逻辑。3.3 踢人接口单端踢与全端踢踢人接口是后台管理功能必须加权限控制不能让随便什么人调用。我用PreAuthorize(hasRole(ADMIN))来限制实际项目里你也可以根据角色权限掐得更细。接口的入参设计上我通常会接收userId和可选的sidsid为空代表全端踢有值代表只踢指定设备。RestController RequestMapping(/admin/kick) RequiredArgsConstructor public class KickController { private final RedisTemplateString, Object redisTemplate; PostMapping PreAuthorize(hasRole(ADMIN)) public ResultVoid kickUser(RequestBody KickRequest request) { Long userId request.getUserId(); String sid request.getSid(); if (StringUtils.hasText(sid)) { // 单端踢删除这条会话记录并从索引集合移除 redisTemplate.delete(login:session: userId : sid); redisTemplate.opsForSet().remove(login:user: userId :sids, sid); return Result.ok(); } // 全端踢遍历用户索引删除所有会话记录 SetObject sidSet redisTemplate.opsForSet().members(login:user: userId :sids); if (sidSet ! null) { for (Object s : sidSet) { redisTemplate.delete(login:session: userId : s); } } redisTemplate.delete(login:user: userId :sids); return Result.ok(); } }这段代码里有两个细节值得强调。第一个是索引集合的维护一定要和会话记录的删除保持一致。实际开发中我见过不少项目踢人的时候只删了login:session开头的 key忘记维护login:user:{userId}:sids这个集合结果索引集合越来越大里面全是已经失效的 sid全端踢时遍历到一个不存在的 key 虽然不会报错但性能会慢慢退化而且排查问题时数据混乱。第二个是如果产品那边还需要“查询用户在线设备列表”的功能这个索引集合就是现成的数据源你可以遍历 sid、把每个会话的登录时间拿出来组装成设备列表返回给前端完全不需要额外设计存储。3.4 核心过滤器每次请求都检查“还有效吗”踢人接口负责删 Redis key真正拦截用户还得靠过滤器。过滤器的作用是在 SpringSecurity 处理链中把传来的 JWT 解析出来然后去 Redis 查会话记录。查得到就正常构建Authentication并放进SecurityContextHolder查不到就什么都不放让后续的AuthenticationEntryPoint返回 401。Component RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final RedisTemplateString, Object redisTemplate; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (StringUtils.hasText(token)) { try { Claims claims Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); Long userId Long.valueOf(claims.getSubject()); String sid claims.get(sid, String.class); // 核心校验Redis里是否存在这条在线会话 Boolean exists redisTemplate.hasKey(login:session: userId : sid); if (Boolean.TRUE.equals(exists)) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userId, null, loadAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (ExpiredJwtException e) { // token过期不设置认证后续会走401 } catch (JwtException e) { // 签名错误或token非法同样不设置认证 } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String authHeader request.getHeader(Authorization); if (StringUtils.hasText(authHeader) authHeader.startsWith(Bearer )) { return authHeader.substring(7); } return null; } }这是整个方案里最关键的一段也是拦截逻辑的核心。有一个地方需要提醒从loadAuthorities()里加载用户角色时如果每次都去数据库查性能会受影响。比较常见的做法是把角色 ID、角色编码也放进 JWT 的 claim 里或者用 Redis 缓存用户角色集合。我在项目里是把用户 ID 放进Authentication的 principal后续需要角色信息时再走一次UserDetailsService或者缓存读取既能保证权限判断可靠也不会每一步都在数据库上打一次全表查询。3.5 接入 Spring Security 配置过滤器写完之后要在 Spring Security 配置里把它加到过滤器链的最前面。换句话说只要请求带着 JWT先经过JwtAuthenticationFilter校验结果决定是否注入认证信息如果没认证成功请求到达FilterSecurityInterceptor那一步就会被拦下来。配置大概是下面这样Configuration EnableWebSecurity RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/login).permitAll() .anyRequest().authenticated() .and() .exceptionHandling() .authenticationEntryPoint(restAuthenticationEntryPoint()) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }配置里把 session 策略设为STATELESS因为这套方案本质上不需要服务端 sessionJWT Redis 已经承担了认证和会话管理的职责。/login和 Swagger 这类公开接口要记得放行其它接口一律authenticated()。被踢用户下一次发请求时过滤器发现 Redis key 不存在SecurityContextHolder里没有认证信息Spring Security 就会调用我们自定义的AuthenticationEntryPoint通常在这里统一返回401以及一个约定好的业务编码方便前端识别“token 过期”和“被踢下线”的区别。4. 我踩过的坑常见问题与排查实录4.1 用户被踢了但另一个端也跟着掉线这个坑很容易出现在“全端踢”和“单端踢”共用同一个 Redis key 设计的时候。如果你把会话记录设计成了login:session:{userId}也就是一个用户只存一条记录那么不管用户在哪台设备登录新的 token 都会覆盖旧 token。表面上看起来很省事实际上只要用户在手机登录了一下他电脑上的会话也会因为 key 被覆盖而失效。换句话说你本来只是想让用户在手机端登录结果电脑端被迫下线了。解决方式就是用我前面说的login:session:{userId}:{sid}这种带设备维度的 key每个设备一个 sid。你会发现“踢单端”本质上不是复杂的技术纯粹是当初 key 设计粒度太粗。4.2 登录成功后立刻被自己的旧 Token 顶掉有一种情况是用户在 A 设备登录又去 B 设备登录结果 A 设备立刻 401。很多人第一反应是“是不是我的 key 设计有问题”其实这取决于业务需求。如果你的产品明确要求“同一时间只能一个设备在线”那这是正确行为不是 bug。但如果你只是想让用户能正常多端登录那就不能把“登录”和“递增版本号”捆在一起。我见过一个比较尴尬的折中方案每次登录都递增login:user:{userId}:versionJWT 里放当前 version过滤器拿它和 Redis 里的最新版本比对。这个方案本身没问题它天然适合“单设备登录”但如果你想放行多设备就千万别用这个方案换成 session 白名单反而是更顺路的选择。4.3 Redis TTL 与 JWT 过期时间不一致导致误杀真实踩坑现场是这样的有人给 Redis 会话记录设置的 TTL 是 30 分钟给 JWT 设置的过期时间是 1 小时。用户登录后过了 40 分钟他的 token 还是合法的但 Redis 里的会话 key 已经消失了过滤器判定“会话不存在”直接把他踢下线。严格来说这不是“踢人”逻辑的问题而是 TTL 设计的问题。我的建议是Redis 会话记录的有效时长一定要大于或等于 JWT 的剩余有效时长最好再多留几分钟。如果你用 TTL 来控制会话生命周期那就把 token TTL 和 Redis TTL 抽成同一个配置来源别一个地方改一个地方漏改。4.4 过滤器放行顺序不对导致踢人无效还有一次折腾我比较久的问题过滤器加了但被踢用户依然能访问接口。排查了半天才发现新加的JwtAuthenticationFilter没有被加入到SecurityFilterChain中等于过滤器根本没生效。Spring Security 提供了一个非常方便的addFilterBefore方法可以在某个过滤器之前插入自定义过滤器。但如果你在一个独立的配置类里定义了过滤器 Bean却没有在 Security 配置里注册它就不会进入过滤器链。另一个常见问题是过滤器确实注册了但配置里把.anyRequest().permitAll()写成了全部放行那后端自然没有任何拦截能力踢人也就形同虚设。建议你在验证踢人功能时先用 Postman 拿到一个登录 token调一次正常接口确认能通再调用踢人接口把该用户踢掉立刻拿同样的 token 再调一次接口观察返回值。如果第二次依然返回 200优先排查过滤器是否注册、过滤器顺序、Redis key 拼写这三个地方。5. 把踢人能力做成通用模块5.1 事件驱动与操作审计当踢人功能从一个简单接口变成业务的一部分时建议不要直接在主流程里写一堆日志和通知代码。更好的做法是引入 Spring 事件机制踢人接口只负责更新 Redis 状态并发布一个UserKickEvent由监听器负责写操作日志、发通知消息、清理相关缓存。这样做的好处是后续如果再增加“踢人后给用户发短信提醒”之类的需求你只需要新增一个监听器不用改动原有踢人逻辑。我大概会这样设计事件类UserKickEvent包含userId、sid、operatorId、kickType、kickTime几个字段。发布事件后一个监听器负责记录审计日志另一个监听器负责通过 WebSocket 推送一条“你的账号已被管理员强制下线”的消息。WebSocket 推送需要注意一个细节如果推送只发给指定的sid那对被踢用户最友好因为他能看到明确的提示如果只靠 HTTP 请求时的 401 来感知体验会延迟很多。5.2 前端如何区分“被踢”和“token 过期”前面提到过被踢用户下一次请求会收到 401但前端的统一拦截器往往会把所有 401 都当成“登录过期”然后跳转到登录页、清理本地用户信息。这在体验上问题不大但如果你想让用户知道“我不是因为闲置被登出而是被管理员踢了”那就得在响应体里约定一个业务编码比如bizCode 4001表示被踢下线bizCode 4002表示 token 过期。前端拦截器拿到 401 时先看bizCode如果是 4001弹一个“您的账号已在其他设备登录或被管理员强制下线”的提示但不要清空用户本地的草稿数据如果是 4002再走正常的重新登录逻辑。我实际做过的项目里前端很容易忽略这个区分导致被踢用户需要重新填写一堆表单数据给客服带来一大堆“我怎么被清空了”的工单。所以这个细节一定值得你提前设计掉。5.3 后续扩展从踢人到在线状态管理当你已经实现了会话白名单你会发现手头握着一套在线会话数据很多功能都可以顺手展开。比如用户中心里展示“最近 5 台登录设备”列表、异地登录提醒、同账号超时自动下线、根据 IP 风控临时封禁指定会话等等。这些功能本质上都是在操作同一批 Redis 数据只是换了一个触发入口。如果以后想把这个能力做成 Spring Boot Starter也完全可行把JwtAuthenticationFilter、踢人 Service、事件发布器、Redis key 枚举抽象到一个独立模块通过配置开关控制是否开启“在线会话管理”。这样新项目引入这个模块后不需要再去理解 JWT 校验和 Redis 操作的细节只要在application.yml里配一个auth.enable-session-onlinetrue就能拥有踢人能力。最后再分享一个我印象很深的细节。最开始我只实现了“删除 Redis key”就以为万事大吉上线之后才发现用户被踢后发起请求虽然能返回 401但用户的下一个操作可能是打开某个页面而页面里根本没有走认证接口的请求导致他一直停留在上一个页面直到下一次主动操作才“真正觉得自己被踢了”。后来我在项目里加了 WebSocket 或者短轮询的在线状态检查让页面在后台定期确认会话是否还有效体验才终于顺畅。如果你的项目是纯内网后台拉一个 30 秒心跳也能接受如果是高实时性场景就建议把在线状态推送当成必备能力一起做进去。