当前位置: 首页 > news >正文

无状态的“皇帝新装”:JWT 注销、续签与黑名单的终极拷问

写在前面

你好,我是 Evan。

“JWT 不是无状态的吗?那用户退出登录后,Token 为什么还能用?”

这是我在一次 Code Review 中被问住的问题。我当时自信满满地设计了一套 JWT 认证方案——Access Token 有效期 30 分钟,Refresh Token 有效期 7 天。登录、鉴权一切正常,直到产品经理提出一个看似简单需求:“用户退出登录后,Token 要立即失效。”我愣住了。JWT 一旦签发,在过期之前它就是“活”的。服务器端根本没有存储它的状态,谈何“失效”?删除客户端的 Token?那只是掩耳盗铃——被删掉的 Token 照样能通过服务端认证。注销、改密码、踢人下线……这些再正常不过的业务需求,在无状态的 JWT 面前,变成了一道无解的题。

后来我才明白:JWT 的无状态是优势,也是枷锁。想要它“有状态”的能力,就必须付出“有状态”的代价。今天这篇文章,我想用一次完整的生产级实践,聊聊 JWT 的“不可能三角”——如何在不破坏无状态架构的前提下,实现可注销、可续签、可管控的令牌体系。

一、JWT 的“皇帝新装”:无状态的光环与阴影

1.1 为什么 JWT 如此受欢迎?

JWT(JSON Web Token)之所以成为分布式系统的认证标配,核心在于它的无状态性

  • 服务端不需要存储 Session,水平扩展零障碍

  • Token 自包含用户信息和签名,一次验证即可信任

  • 天然适合微服务、跨域、移动端等场景

但无状态的另一面是:一旦签发,在 exp 时间到达之前,这个 Token 就是“不死之身”

1.2 无状态的“三宗罪”

一句话总结:JWT 的“无状态”让认证变得简单,却让注销变得困难。你无法“撤销”一个已经发出去的 Token,只能等它自己过期。

这就引出了我们今天要解决的核心问题:如何在保留 JWT 无状态优势的同时,获得“有状态”的控制能力?

二、破局之道:JWT + Redis 的“有状态无状态”混合架构

2.1 核心思路:用 Redis 给 JWT 加一个“开关”

JWT 本身无状态,但我们可以借助外部存储(Redis)来记录 Token 的“生死状态”。这样既保留了 JWT 的自包含和分布式优势,又获得了主动撤销的能力。

关键设计原则:

  • 最小化存储:Redis 只存必要的状态标记,不存完整的用户数据

  • TTL 对齐:Redis 键的过期时间严格对齐 JWT 的exp时间,避免永久堆积

  • 异步清理:黑名单过期后自动删除,无需人工维护

2.2 白名单 vs 黑名单:选哪个?

黑名单的优势在于:注销是低频操作,而正常请求是高频操作。用黑名单,99% 的请求不需要查 Redis(或者只需要查一次黑名单),而白名单每次都要查。在生产环境中,黑名单是绝对的主流方案

三、黑名单实战:从理论到代码

3.1 JWT 中的 jti:为每个 Token 贴上“身份证”

要实现黑名单,首先要让每个 Token 可被唯一标识。JWT 标准中定义了jti(JWT ID)字段,专门用于此目的。

// 生成 JWT 时注入唯一 jti String jwt = Jwts.builder() .setId(UUID.randomUUID().toString()) // 关键:唯一令牌ID .setSubject("user123") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 30分钟 .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

3.2 注销接口:将 jti 加入黑名单

@PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) { String token = authHeader.replace("Bearer ", ""); Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); String jti = claims.getId(); Date exp = claims.getExpiration(); long ttl = exp.getTime() - System.currentTimeMillis(); // 将 jti 存入 Redis 黑名单,TTL = Token 剩余有效期 + 缓冲时间 // 使用 SET 命令,key 为 blacklist:{jti},过期时间对齐 Token 剩余时间[reference:17] if (ttl > 0) { redisTemplate.opsForValue().set( "blacklist:" + jti, "revoked", ttl + 30_000, // 多留 30 秒缓冲,避免时钟偏差 TimeUnit.MILLISECONDS ); } return ResponseEntity.ok("注销成功"); }

3.3 鉴权过滤器:每次请求检查黑名单

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private RedisTemplate<String, String> redisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String token = extractToken(request); if (token == null) { chain.doFilter(request, response); return; } // 1. 解析 JWT,获取 jti Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); String jti = claims.getId(); // 2. 检查黑名单[reference:18] Boolean isBlacklisted = redisTemplate.hasKey("blacklist:" + jti); if (Boolean.TRUE.equals(isBlacklisted)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } // 3. 放行 chain.doFilter(request, response); } }

3.4 黑名单的“生命周期管理”

黑名单最怕的就是无限膨胀。想象一下,如果每个注销的 Token 都永久留在 Redis 里,几百万用户注销后,Redis 就爆了。

解决方案:Redis 键的 TTL 严格对齐 JWT 的剩余有效期。Token 过期后,黑名单中的记录也自动消失。

// TTL 计算:Token 剩余有效期 + 30 秒缓冲 long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); redisTemplate.opsForValue().set( "blacklist:" + jti, "revoked", Math.max(ttl, 0) + 30_000, // 至少 30 秒 TimeUnit.MILLISECONDS );

四、续签的艺术:Access Token + Refresh Token 双令牌机制

黑名单解决了“注销”问题,但还有一个更常见的场景:Token 过期了怎么办?总不能让用户每隔 30 分钟就重新登录一次吧。

4.1 双令牌架构

4.2 核心设计要点

4.3 Refresh Token 轮转实现

@PostMapping("/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); // 1. 检查 Refresh Token 是否在黑名单中 String jti = extractJti(refreshToken); if (redisTemplate.hasKey("blacklist:refresh:" + jti)) { return ResponseEntity.status(401).body("Refresh Token 已失效"); } // 2. 校验 Refresh Token 签名和有效期 Claims claims = validateToken(refreshToken); // 3. 将旧的 Refresh Token 加入黑名单[reference:28] redisTemplate.opsForValue().set( "blacklist:refresh:" + jti, "revoked", getRemainingTTL(claims) + 30_000, TimeUnit.MILLISECONDS ); // 4. 生成新的 Access Token + Refresh Token[reference:29] String newAccessToken = generateAccessToken(claims.getSubject()); String newRefreshToken = generateRefreshToken(claims.getSubject()); return ResponseEntity.ok(new TokenPair(newAccessToken, newRefreshToken)); }

为什么要把旧的 Refresh Token 加入黑名单?如果不这样做,一个 Refresh Token 可以被无限次使用来换取新的 Access Token——相当于 Refresh Token 永不失效。

五、进阶方案:用户级版本号——一票否决所有 Token

黑名单方案的问题在于:每个 Token 需要单独存储。如果用户在多设备登录,注销时需要把每个设备的 Token 都加入黑名单,操作繁琐。

更好的方案:用户级版本号(user_version)

核心逻辑

  • 每个用户在 Redis 中维护一个version计数器

  • 签发 Token 时,将当前version写入 Token 的 Claims

  • 每次请求验证时,对比 Token 中的version和 Redis 中的最新version

  • 不一致则拒绝

优点:一次操作(version++)即可让该用户所有Token 失效,无需遍历黑名单。每个用户只占用一个 Redis Key,内存开销极小。

适用场景:修改密码、账号封禁、强制所有设备下线。

六、完整架构总览

七、常见陷阱与最佳实践

陷阱 1:黑名单无过期策略

错误做法:将注销的 Token 永久存入 Redis。
正确做法:TTL 严格对齐 Token 剩余有效期。

陷阱 2:Refresh Token 不轮转

错误做法:每次刷新只换 Access Token,Refresh Token 不变。
正确做法:每次刷新生成新的 Refresh Token,旧 Token 加入黑名单。

陷阱 3:黑名单查询影响性能

错误做法:每次请求都查两次 Redis(黑名单 + user_version)。
正确做法:将黑名单查询结果缓存到 ThreadLocal 或本地缓存(Caffeine),减少 Redis 压力。

陷阱 4:把敏感信息放入 Payload

错误做法:在 JWT Payload 中存放密码、身份证号等敏感信息。
正确做法:Payload 只存放非敏感的用户标识(如 userId、role),敏感信息走数据库查询。

陷阱 5:密钥硬编码

错误做法secretKey = "mySecret"写在代码里。
正确做法:使用 KMS / Vault 托管密钥,支持密钥轮换。

八、总结:JWT 不是银弹,但用对组合就是神器

回到开头的问题:JWT 如何实现注销、续签和黑名单?

答案不是“不用 JWT”,而是“JWT + Redis”的混合架构

  • 黑名单(按 jti):解决单 Token 注销问题

  • Refresh Token 轮转:解决安全续签问题

  • 用户级版本号(user_version):解决批量失效问题

这套方案既保留了 JWT 的无状态优势(水平扩展、跨域、自包含),又通过 Redis 获得了“有状态”的控制能力(注销、踢人、改密码)。

最后送你一张决策表

需求推荐方案实现成本
用户退出登录jti 黑名单
修改密码/封号user_version 版本号
Token 过期续签Access + Refresh 双 Token + 轮转
多设备登录控制Redis 存储设备列表
强制所有设备下线user_version + 1

JWT 不是银弹,但用对组合,它就是神器。

http://www.jsqmd.com/news/1100452/

相关文章:

  • CAD快捷键
  • 手把手教你用Cheat Engine 7.5修改《魔法工艺》0.82.7版:无限金币、钥匙与生命值
  • 别再死记硬背了!用Python+Excel搞定二级计量师核心公式(附实战代码)
  • 别再死记硬背公式了!用Python+Matlab手把手复现AM包络调制与解调全过程
  • 爬虫开发实战:识别与规避反爬蜜罐(Web陷阱)的技术指南
  • RTK8852BE蓝牙驱动的安装问题
  • L3/L4 自动驾驶强制国标落地:座舱交互与仿真测试的实时系统支撑方案
  • 3D点云处理入门:从ICP配准到PointNet分类的完整实践指南
  • 别再手动算排名了!用Matlab实现TOPSIS评价模型,5分钟搞定水质评估案例
  • 别再死记硬背公式了!用Python+Matplotlib动态可视化AM包络调制全过程
  • 爱博精电助力北京中海金融中心,打造首都核心区绿色建筑能源管理新标杆
  • YOLO目标检测入门教程:从环境搭建到自定义训练全流程
  • 安吉哪里可以晚托的权威机构
  • 零甲醛儿童房艺术漆推荐
  • 第一章Netty,Selector key用完要remove
  • 勒索攻击防御三大认知误区:备份神话、侥幸心理与赎金陷阱
  • 别再只用OCV了!Primetime POCV实战:从变量设置到看懂报告,一次搞定
  • 云克隆神经相关原代细胞:以高保真细胞模型驱动神经科学研究新突破
  • 别再写if-else了!用PySide6信号槽重构你的登录跳转逻辑,代码更清晰
  • HarmonyOS NEXT开发环境搭建(2026最新版)——从下载安装到运行第一个项目,全网最详细教程
  • 前期准备:
  • Codex快速入门了解指南
  • 你拖一下,3D 轨迹凭什么就转了?一文看懂 WebGL 渲染管线
  • 一线观察:长期体验后发现的奥迪改装市场底层现象
  • 2026年节假日聚餐后脾虚湿热调养指南:辨证与方案详解
  • Gemini镜像站 解决 Linux 运维难题:2026 年镜像站的 Shell 自动化与故障排查实战
  • Postman便携版:Windows开发者必备的无安装API测试解决方案
  • 【技术白皮书】全自动焊线机选型参数基准:以铭硕智造为参照的0.1mm精密焊线标准解析
  • nRF52840开发实战:用GPIOTE事件触发实现低功耗按键检测(附完整代码)
  • 保姆级教程:用ADB命令调试MTK手机MFNR多帧降噪,从开关控制到图片Dump