前两天帮朋友看一个后台系统,他问了个问题:用户点了「退出登录」,前端把 token 删了,但如果这个 token 之前已经被人抓包拿走,拿着它还能调接口吗?
答案是能,一直能用到它过期为止。这是 JWT 最常被问到、也最容易被忽略的一件事:JWT 本身是无状态的,服务端验的只是签名和过期时间,它不知道「这个用户已经退出了」。
这篇用一个不依赖任何第三方库的 Python 脚本,把这件事和四种常见的处理办法都跑一遍,最后说各自的代价。
先复现问题
为了不依赖 PyJWT,这里手写一个最小的 HS256 签发和校验(只用于演示,生产环境请用成熟的库):
import base64, hashlib, hmac, json, time, uuid SECRET = b"demo-secret-only-for-this-post" def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def unb64(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def sign(payload): head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) body = b64(json.dumps(payload).encode()) sig = hmac.new(SECRET, f"{head}.{body}".encode(), hashlib.sha256).digest() return f"{head}.{body}.{b64(sig)}" def verify(token): head, body, sig = token.split(".") expect = hmac.new(SECRET, f"{head}.{body}".encode(), hashlib.sha256).digest() if not hmac.compare_digest(expect, unb64(sig)): raise ValueError("bad signature") p = json.loads(unb64(body)) if p["exp"] < time.time(): raise ValueError("expired") return p def login(uid, ttl=3600, **extra): now = int(time.time()) return sign({"sub": uid, "iat": now, "exp": now + ttl, "jti": uuid.uuid4().hex, **extra})然后模拟「退出登录」:
t = login("u1") print("[0] 退出前:", verify(t)["sub"]) # 用户点了"退出登录",前端删掉 token……但如果 token 已经被别人拿到: print("[0] 退出后同一个 token:", verify(t)["sub"], "<- 依然有效")输出:
[0] 退出前: u1 [0] 退出后同一个 token: u1 <- 依然有效这就是问题本身。下面四种办法,本质上都是在「无状态」里加回一点状态。
办法一:jti 黑名单
每个 token 带一个唯一的jti。退出登录时把它记进黑名单,校验时多查一步。
blacklist = {} # jti -> exp,真实项目放 Redis,TTL = exp - now def logout_blacklist(token): p = verify(token) blacklist[p["jti"]] = p["exp"] def verify_bl(token): p = verify(token) if p["jti"] in blacklist: raise ValueError("revoked") return p[1] 黑名单后: revoked关键细节是黑名单的过期时间设成 token 剩余的有效期。token 本来就会过期,过期之后黑名单里那条记录也就没用了,Redis 的 TTL 正好自动清理,黑名单不会无限膨胀。
代价:每个请求多一次 Redis 查询。本地用 dict 测不出差别(我测了一下,纯验签和验签加查 dict 都在 4~5 微秒,这个对比没有意义),真实开销是那一次网络往返。
办法二:用户级 token 版本号
黑名单只能踢掉「这一个」token。如果场景是「改了密码,所有设备都要重新登录」,就需要版本号:
token_ver = {"u1": 1} # 真实项目存用户表或 Redis def login_v(uid): return login(uid, ver=token_ver[uid]) def verify_v(token): p = verify(token) if p["ver"] != token_ver[p["sub"]]: raise ValueError("version mismatch") return p phone, laptop = login_v("u1"), login_v("u1") token_ver["u1"] += 1 # 改密码 / "退出所有设备"[2] phone: version mismatch [2] laptop: version mismatch [2] 新登录: u1一次+1,这个用户之前签发的所有 token 同时失效。存储量是「每用户一个整数」,比黑名单省得多。
缺点是粒度粗:没法只踢掉某一台设备。想要设备粒度,就得把版本号细化到「用户 + 设备」,其实就慢慢变回 session 了。
办法三:短 access + 可撤销的 refresh
这是目前最常见的组合:access token 很短(几分钟到十几分钟),不做任何撤销检查;refresh token 存在服务端,可以删。
refresh_store = {} # refresh_id -> uid,真实项目存库/Redis def login_pair(uid): rid = uuid.uuid4().hex refresh_store[rid] = uid return login(uid, ttl=2), rid # access 只活 2 秒,方便演示 def refresh(rid): uid = refresh_store.pop(rid, None) # 用一次就作废(轮换) if uid is None: raise ValueError("refresh revoked or reused") return login_pair(uid) access, rid = login_pair("u1") refresh_store.pop(rid) # 退出登录:删掉 refresh[3] 退出后 access 仍可用: u1 [3] 2 秒后 access: expired [3] 拿旧 refresh 换新: refresh revoked or reused注意第一行:退出之后,access token 在它剩余的有效期内依然能用。这个方案不是「立即失效」,而是「最多再活 N 分钟」。对大部分业务,这个窗口是可以接受的;对金融、后台管理这种场景,可能不行,要叠加办法一。
refresh里那个「用一次就作废」叫轮换(rotation)。它顺带能发现 refresh token 被盗:如果合法用户和攻击者先后拿同一个 refresh 来换,后到的那个一定失败,这时候可以直接把这个用户的所有 refresh 都吊销掉。
办法四:别用 JWT 做会话
说句可能不讨喜的:如果你的系统是单体应用、有 Redis、需要「退出立即失效」「踢人」「看在线设备列表」,那服务端 session + 随机 session id 就是更合适的方案。上面三种办法加到最后,你会发现自己在 JWT 外面又实现了一遍 session。
JWT 真正省事的场景是:多个服务之间传递身份、服务方不方便访问同一个存储、或者 token 的有效期本来就很短。
怎么选
| 需求 | 建议 |
|---|---|
| 退出后「最多再有效几分钟」可以接受 | 办法三:短 access + 可撤销 refresh |
| 退出必须立即失效 | 办法三 + 办法一(只在退出时写黑名单) |
| 改密码后所有设备下线 | 办法二,或者吊销该用户全部 refresh |
| 需要在线设备列表、单设备踢出 | 老老实实用 session |
局限说在前面
文中的签发和校验是为了演示手写的,没有处理alg字段校验、nbf、时钟偏差这些,生产环境请用成熟的库,并且显式指定允许的算法。性能那一段只是本机粗测,真实开销要看你的 Redis 部署。
我调试 token 的时候习惯把它贴进 forxi.cn 的 JWT 解析工具里看一眼 payload(在浏览器本地解析),exp、jti、ver这些字段对不对,一眼就能看出来,比自己 base64 解码快。不过要提醒一句:线上用户的真实 token 别往任何在线工具里贴,包括我这个。