
做过后台管理系统的人基本都遇到过这个需求运营在后台点一下“踢出该用户”让某个违规账号立刻失效不能再访问接口。SpringSecurity 做登录鉴权本身不复杂但踢人这个功能一到动手阶段就到处都是坑。尤其是项目采用 SpringSecurity 整合 JWT 之后问题从“怎么清理服务端 session”变成“怎么让一个不在服务端存储的 token 失效”很多人在这里卡了很久。这篇文章把这件事彻底讲清楚。先拆解踢人背后的核心模型再分别给出 Spring Security 在 Session 模式和 JWT 模式下的完整实现附带可直接改用的代码和我在实际项目里踩过的坑。适合正在做后台管理系统、运营平台或者准备将 SpringSecurity 整合 JWT 做登录鉴权的后端开发参考。1. 先从需求聊起踢出指定用户到底在解决什么问题1.1 拆成两个子问题后思路才清晰“踢出指定用户”听起来是个很简单的操作但做的时候会发现它其实包含两个独立的问题。第一个问题怎么找到指定用户当前持有的有效凭证。无论是老式的 Session ID还是新式的 JWT用户登录后都会持有某个凭证。但这个凭证可能在服务端有记录也可能只在客户端手里而且同一个用户可能同时在 PC、App、小程序多端登录每端一个凭证。如果连凭证都找不到后面的“踢出”就是空谈。第二个问题怎么让这些凭证立即失效。Session 模式下可以销毁服务端会话但 JWT 场景下凭证本身存在客户端你无法主动删掉用户浏览器或 App 里的那个 token只能通过其他手段让它在服务端校验时被拒绝。想清楚这两点再去设计方案就不会被各种框架配置绕晕。后面所有代码和配置本质都是在回答这两个问题。1.2 两条技术路线的分水岭有状态会话和无状态认证SpringSecurity 整合 JWT 之后为什么“踢人”这个功能变得格外别扭根源在于认证状态由谁保存。传统 Session 模式是典型的有状态认证。用户登录后服务端的内存或 Redis 里存了一份 Session 记录浏览器只拿一个 Session ID。服务端对会话有着绝对的控制权想让你下线直接销毁这条记录就行主动权完全在自己手里。JWT 模式则默认是无状态认证。JWT 本身是一个自包含的、带签名的 JSON 串服务端通过验签来确认 token 是否合法天然不保存任何会话记录。token 一旦签发出去除非过期否则服务端在验签时都会认为它是有效的。这就像电影票你卖出去一张有效期两小时的票理论上除非等到时间结束否则你不能撕掉观众手里那张票。所以要做 JWT 场景下的踢人必须接受一个事实我们不是真的去删除那个 token而是给服务端增加一道“准入校验”。这道校验能识别出“这个 token 曾经有效但现在我应该拒绝它”。2. Session 模式下的踢人实操SpringSecurity 自带能力就够如果你的项目还在用 Session 做登录态那恭喜你SpringSecurity 本身就提供了一整套会话管理能力。这个模式前提是服务端持有 Session 记录所以踢人实现起来很直接。2.1 三个关键组件SessionRegistry、事件发布器、并发会话过滤器SpringSecurity 里与 Session 管理相关的核心组件有三个搞清楚它们的分工配置就不会乱。SessionRegistry是会话注册表负责记录当前系统中有哪些 principal通常就是用户主体对应哪些 Session。SpringSecurity 默认实现是SessionRegistryImpl它把会话信息存在内存 Map 里。HttpSessionEventPublisher是会话事件发布器它监听 Servlet 容器里 Session 的创建和销毁事件把这些事件同步给 SpringSecurity 的SessionRegistry。没有它SessionRegistry里永远没有数据后面自然查不到在线用户。ConcurrentSessionFilter是并发会话过滤器每个请求经过它时会检查当前 Session 对应的SessionInformation是否已被标记为过期。如果过期就执行配置好的过期策略通常是跳转登录页或返回 401。2.2 配置一个可控的 Session 环境在 Spring Boot 中配置方式很简单先注册两个 BeanBean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); }然后在SecurityFilterChain里开启会话管理http .sessionManagement(session - session .maximumSessions(1000) .sessionRegistry(sessionRegistry()) .expiredUrl(/login?expiredtrue) ) .authorizeHttpRequests(auth - auth .requestMatchers(/login, /logout).permitAll() .anyRequest().authenticated() ) .formLogin(login - login.loginPage(/login).permitAll());这里有个容易误解的点maximumSessions(1000)并不是要限制用户最多只能登录 1000 次而是为了触发 SpringSecurity 的 Session 管理机制。如果不配置这个参数SessionRegistry很难在会话创建时获得通知在线列表就建立不起来。实际项目中如果你的业务不允许同一用户无限多开就把这个值改成业务上限如果不想限制就设一个很大的值。2.3 拿到在线用户列表有了SessionRegistry查询在线用户就变成查表操作public ListOnlineUserVO listOnlineUsers() { ListOnlineUserVO result new ArrayList(); ListObject principals sessionRegistry.getAllPrincipals(); for (Object principal : principals) { if (!(principal instanceof UserDetails)) { continue; } UserDetails userDetails (UserDetails) principal; ListSessionInformation sessions sessionRegistry.getAllSessions(principal, false); for (SessionInformation session : sessions) { OnlineUserVO vo new OnlineUserVO(); vo.setUsername(userDetails.getUsername()); vo.setSessionId(session.getSessionId()); vo.setLastRequestTime(session.getLastRequest()); vo.setExpired(session.isExpired()); result.add(vo); } } return result; }注意getAllSessions(principal, false)的第二个参数传false表示只要未过期的会话。如果你想连已经踢掉的会话也展示出来比如在页面上标识“已下线”就传true。2.4 把指定用户踢下线踢人这一步核心是拿到会话信息后调用expireNow()public void kickUserByUsername(String username) { ListObject principals sessionRegistry.getAllPrincipals(); for (Object principal : principals) { if (!(principal instanceof UserDetails)) { continue; } UserDetails userDetails (UserDetails) principal; if (username.equals(userDetails.getUsername())) { ListSessionInformation sessions sessionRegistry.getAllSessions(principal, false); for (SessionInformation session : sessions) { session.expireNow(); } } } }expireNow()做的事情是把SessionInformation标记为过期而不是直接销毁底层的HttpSession。为什么这样设计因为直接暴力销毁 Session 可能引发容器层面的并发问题而且 SpringSecurity 需要让当前请求结束后、下一个请求进来时由ConcurrentSessionFilter统一拦截。这就带来一个表现用户被踢之后正在进行的请求可能还能执行完但下一次请求就会被拦下来。如果需求要求“立刻生效”可以在expireNow()之后再调用sessionRegistry.removeSessionInformation(sessionId)或者request.getSession().invalidate()但这样会丢失“该用户已被踢出”的标记页面就看不到历史记录。所以我的建议是保留expireNow()的标记语义让前端在收到 401 后主动跳转登录页。Session 模式虽然简单但也有明显天花板SessionRegistryImpl的内存实现没法在多个应用实例之间共享数据。如果你部署了多个后端实例需要把会话信息放到 Redis 里要么用spring-session-data-redis统一管理 Session要么自己实现一个基于 Redis 的SessionRegistry。否则会出现用户在 A 实例登录管理员在 B 实例查询时看不到这个用户的情况。3. JWT 模式SpringSecurity 整合 JWT 后的踢人设计如果项目已经改成 SpringSecurity 整合 JWT前面的 Session 方案就没法直接用了。JWT 的核心特点就是服务端不保存状态这既是它的优点也是“踢人”功能难做的根因。3.1 无状态认证的代价你无法销毁不在自己手里的凭证JWT 的校验过程通常是这样客户端把 token 放在 Authorization Header 里后端验签通过后从 token 里解析出用户信息然后放行。整个过程中服务端根本没有一条“这个 token 是否有效”的记录。所以要让一个已经签发的 JWT 失效只能改变校验规则让服务端在“验签通过”之外额外再查一次“这个 token 是否被标记为不可用”。这个思路本质上就是把无状态认证做了“有状态化”改造只是改造的幅度可大可小。3.2 四种常见方案的横向对比围绕“怎么让 token 失效”业界常见的做法有内存黑名单、Redis 黑名单、Redis 版本号、RefreshToken 撤销。我整理了一个对比表方案核心思路实现成本适用场景主要缺点内存黑名单把要踢掉的 token 放进程内 Map低单机 demo、测试环境重启即失效多实例不共享Redis 黑名单token 的唯一 ID 作为 key 存 Redis设置剩余有效期中精确吊销某一个 token需要拿到 token 的唯一 ID且要管理过期时间Redis 版本号每个用户对应一个版本号token 里保存签发时的版本踢人时版本号加 1中按用户维度踢全部端同一用户所有 token 会被一起踢掉无法精确到单端RefreshToken 撤销只撤销 refresh token让 access token 快速过期高移动端、长会话场景access token 存在最长约 2 小时的失效窗口如果业务单纯是“管理员点一下指定用户所有端全部下线”我优先推荐Redis 版本号方案。它逻辑清晰、实现简单天然适配“按用户维度踢人”的场景。3.3 实际推荐组合Redis 版本号 短时效 accessToken可能有人会问黑名单方案同样能实现为什么不选它黑名单方案的问题是你需要知道某个 token 的 jti唯一 ID才能把它拉黑。而运营后台通常只有用户 ID、用户名这些信息并没有用户当前持有的 token 值。要拿到 jti还得维护“用户 - 活跃 token 集合”的映射这个数据模型比版本号方案复杂得多。版本号方案则完全绕开了这个问题。核心数据模型只有一条login:token:version:{userId} - 版本号。用户登录时把当前版本号写进 JWT 的一个自定义 claim 里每次请求进来过滤器解析 token 后拿 token 里的版本号和 Redis 里的当前版本号比对不一致就拒绝访问。踢人时只要对 Redis 里的版本号执行自增该用户手里所有旧 token 瞬间全部失效。用户重新登录后版本号不会重置而是沿用自增后的新值所以新签发的 token 会带新版本号又恢复了正常访问。这套机制既能实现“踢出全部端”也能应对用户反复登录的场景。4. SpringSecurity 整合 JWT 踢人的完整实操下面给出一个可以直接抄作业的版本基于 Spring Boot 3.x Spring Security 6.x jjwt 0.11.5 Redis。如果你的项目是 Spring Boot 2.7 或较低的版本写法差异不大核心逻辑完全可以平移。4.1 登录签发带版本号的 token登录认证通过之后先确保 Redis 里有版本号再生成 tokenService public class TokenVersionService { private static final String VERSION_KEY login:token:version:; private final StringRedisTemplate stringRedisTemplate; public TokenVersionService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } public void initVersion(String userId) { String key VERSION_KEY userId; // 关键点必须用 setIfAbsent不能直接 set stringRedisTemplate.opsForValue().setIfAbsent(key, 1); } public long currentVersion(String userId) { String value stringRedisTemplate.opsForValue().get(VERSION_KEY userId); return value null ? 0L : Long.parseLong(value); } public long kickUser(String userId) { Long version stringRedisTemplate.opsForValue().increment(VERSION_KEY userId); return version null ? -1L : version; } }登录接口里调用initVersion之后取版本号生成 JWTString userId user.getUserId(); tokenVersionService.initVersion(userId); long version tokenVersionService.currentVersion(userId); String token Jwts.builder() .setSubject(userId) .claim(ver, version) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 30 * 60 * 1000L)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact();这里有个非常隐蔽的坑我必须单独拿出来说initVersion里不要用set(key, 1)一定要用setIfAbsent。原因很好解释假设用户当前版本号是 1踢人后版本号变成 2。用户重新登录时如果initVersion把版本号强制重置为 1而用户手里那个旧 token 里存的版本号恰好也是 1那么旧 token 就会被误认为有效踢人操作等于白做了。用setIfAbsent保证版本号只增不减才能让“旧的永远失效新的拿着最新值”。4.2 鉴权每次请求校验版本号自定义一个 JWT 过滤器放在 Spring Security 过滤器链里。核心逻辑就是解析 token、查用户、比对版本号Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final TokenVersionService tokenVersionService; private final UserDetailsService userDetailsService; private final SecretKey secretKey; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token null) { filterChain.doFilter(request, response); return; } try { Claims claims Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); String userId claims.getSubject(); long tokenVersion Long.parseLong(claims.get(ver).toString()); long currentVersion tokenVersionService.currentVersion(userId); if (tokenVersion ! currentVersion) { throw new InvalidTokenException(token version expired); } UserDetails userDetails userDetailsService.loadUserByUsername(userId); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception ex) { SecurityContextHolder.clearContext(); writeUnauthorized(response); return; } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }注意 token 校验失败时我选择了直接返回 401 而不是继续走filterChain.doFilter。因为这个请求带上了一个明确的、但已失效的 token继续往后放行只会让后续接口在未认证状态下执行纯属浪费性能还容易产生奇怪的安全漏洞。在前后端分离项目里这里的 401 响应应该是一段 JSON而不是 Spring Security 默认跳转登录页。所以使用response.getWriter().write(...)或者注入 ObjectMapper 输出统一格式。4.3 踢人Redis 版本号加一全网立即失效踢人接口本身不复杂核心就一行Service public class UserKickService { private final TokenVersionService tokenVersionService; public UserKickService(TokenVersionService tokenVersionService) { this.tokenVersionService tokenVersionService; } public boolean kickUser(String userId) { long version tokenVersionService.kickUser(userId); // 记录操作日志、通知前端等 return version 0; } }Controller 层做好权限控制和参数校验RestController RequestMapping(/admin/user) public class AdminUserController { private final UserKickService userKickService; PostMapping(/kick) public ResultVoid kick(RequestBody Valid KickRequest request) { userKickService.kickUser(request.getUserId()); return Result.success(); } }请求到达这个接口后Redis 中login:token:version:{userId}的值从 1 变成 2。用户手里所有包含ver1的 token在下一次请求时都会被过滤器识别出来返回 401。这个过程不需要遍历任何会话表也不需要知道用户在哪些设备上登录过一条自增命令就把所有端一起踢掉了。4.4 安全配置怎么配合调整SecurityFilterChain里要做两件关键事把过滤器挂到链上关闭 SessionBean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.stateless()) .authorizeHttpRequests(auth - auth .requestMatchers(/login, /captcha).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling(handler - handler .authenticationEntryPoint((request, response, ex) - writeJson(response, 401, 未登录或登录已失效)) .accessDeniedHandler((request, response, ex) - writeJson(response, 403, 没有权限)) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }这里有两个细节值得注意。第一既然用了 JWT就必须把 Session 设置为stateless()否则 Spring Security 还是会按 Session 方式处理认证状态导致明明 token 已失效但后续请求拿着旧 Session 照样能通过。第二踢人接口本身必须有权限保护通常要配hasRole(ADMIN)同时确认/admin/user/kick这个路径不会意外地被前面某个permitAll()规则放行掉。4.5 多端与单端的粒度选择版本号方案默认是“同账号所有端一起踢”。但有些业务场景只要求踢掉 PC 端App 端继续保留这时候可以把版本号的 key 从按用户拆成按用户加端类型login:token:version:{userId}:{clientType}登录时在前端传一个clientType参数token 里也保存这个值。踢人时后台只对指定的clientType执行版本号自增其他端不受影响。这个扩展非常轻量只需要在 key 的拼接上多花一点功夫。5. 常见问题与排查实录这套方案在实际落地时很多问题不是方案本身的问题而是集成过程中的环境问题。我把踩过的坑按“现象 - 原因 - 解决”的方式整理成一张速查表现象可能原因解决方案踢完用户后旧 token 仍然能正常访问接口Redis 里没有版本号或者 token 里没有携带verclaim过滤器没有执行校验逻辑检查登录签发逻辑和过滤器是否生效确认 Redis key 命名一致token 失效后前端收到的是登录页 HTML 而不是 401 JSON没有配置authenticationEntryPointSpring Security 默认做页面跳转自定义入口处理器返回 JSON用户重新登录后旧 token 又“活”了initVersion用了set而不是setIfAbsent版本号被重置改为setIfAbsent保证版本号只增不减多实例部署时某个实例踢人其他实例不生效每个实例用了不同的 Redis 库或 key 前缀统一 Redis 配置建议 key 前缀带环境名JWT 校验没走到自定义过滤器过滤器顺序不对或/login等白名单路径提前返回确认addFilterBefore绑定到正确的过滤器类位置踢出接口本身被匿名访问安全配置里permitAll()范围过大检查规则顺序确保/admin/**优先级正确5.1 排查技巧实录先确认 Redis 数据再看过滤器链遇到“踢了没用”的反馈我一般按三步走。第一步先在 Redis 里查版本号。用 Redis Desktop Manager 或者命令行执行GET login:token:version:{userId}看这个值是否在踢人之后成功自增。如果连 Redis 里的值都没变问题出在踢人接口本身可能是 user ID 传错或 key 写错。第二步查看登录时生成的 token。把 JWT 放到 jwt.io 里解析看 payload 里有没有ver字段值是多少。这个值应该与 Redis 里初始版本号一致。如果不一致说明登录时取版本号的逻辑有问题比如先init后current的顺序反了。第三步打开 Spring Security 的 debug 日志。在application.yml里配置logging.level.org.springframework.security: DEBUG看请求在过滤器链上走到哪个位置被打断的。如果自定义过滤器压根没执行优先检查addFilterBefore的位置和 Bean 是否被 Spring 管理。5.2 设计上的两个取舍建议第一JWT 过期时间不建议设置太长。版本号方案再可靠如果 accessToken 的有效期是 7 天踢人后用户手里的 token 依然是一个具有签名合法性的字符串虽然每次请求都会被打回但用户在 7 天内反复拿旧 token 请求会造成无意义的 Redis 查询和 401 响应。实际项目中我通常把 accessToken 设成 30 分钟同时配一个 refreshToken 做续期。这样即使某一次踢人存在极端并发问题失效窗口也能控制在半小时内。第二Redis 里的版本号 key 要设置过期时间吗我的习惯是在initVersion时配合expire(key, Duration.ofDays(7))让长期不活跃用户的版本号自动清理避免 Redis 里堆积无用的 key。但要注意如果 key 过期了而某个旧 token 还在有效期内过滤器查询版本号时会得到 0旧 token 里哪怕是 1 也会被拒绝。这正好符合安全预期不会误放行。5.3 本地缓存兜底不是好主意有同学为了减轻 Redis 压力在本地 JVM 里维护一份 userId - version 的缓存只有在缓存不存在时才查 Redis。这个小优化在单机环境没太大问题但在多实例环境下会带来严重的不一致A 实例踢人改了 RedisB 实例本地缓存还保留着旧版本号B 实例处理的请求就会放过旧 token。踢人这种低频操作Redis 的压力本来就几乎可以忽略完全没必要用本地缓存引入一致性风险。正确做法是在过滤器里每次都查 Redis这既能保证强一致也可以利用 Redis 本身的响应速度优势。写在最后的一点个人体会技术方案本身不难难的是想清楚当前系统的认证边界。Session 模式天然适合踢人因为它把会话状态牢牢握在服务端手里JWT 模式则必须通过版本号、黑名单这类手段给无状态认证找回一点“有状态”的控制力。不要盲目追求流程上的统一先看业务里“在线”的语义是什么如果是后台管理系统用户量不大Session 完全够用如果要做分布式、跨端、小程序和 App 共享登录态那尽早把 SpringSecurity 整合 JWT 的方案落地并把版本号踢人这个机制设计进去后面运营提需求时会省很多事。另外还有一个非常实际的经验踢人功能一定要记录操作日志。谁踢的、什么时候踢的、踢的是哪个用户、当时 Redis 版本号变成了多少都要留下来。遇到线上扯皮时这些日志比数据库审计记录还管用。我见过不止一次运营反馈“用户被踢了还是能登录”最后排查出来是用户侧又走了第三方登录入口跟 token 校验压根没关系。没有日志佐证这种问题说不清楚。把踢人的语义定义清楚把权限控制做到位把 Redis 的版本号维护好这个功能就会非常省心。后续如果还想支持“踢单端”“踢指定设备”“用户被封禁后自动踢”都可以在这个版本号模型上叠加扩展不用推翻重来。