前段时间,我们遭遇了一起非常恶劣的安全事故。由于前端同学在本地存储(LocalStorage)里硬编码保存了用户凭证,导致某个高权限用户的 JWT Token 泄露。黑客利用这个泄露的 Token,在深夜疯狂调用我们的敏感 API 窃取数据。 虽然安全组同学通过行为审计迅速锁定了受害账号,并在管理后台直接点击了“冻结账户”,但令人崩溃的一幕发生了:由于我们的网关和服务完全基于无状态(Stateless)的 JWT 进行鉴权,只要 Token 没过期,鉴权中间件就依然一路绿灯放行。黑客整整折腾了三个多小时,直到 Token自然过期才停止攻击。
这就是盲目追求“纯无状态” JWT 带来的致命缺陷。一旦颁发,除非到期,否则你对它完全失去控制。要解决这个问题,直接回到有状态(Stateful)的 Session 鉴权模式,显然是对高并发分布式架构的妥协。在各种权衡后,我们最终落地了一套“强实时 + 低延迟”的 Redis Token 吊销黑名单与用户状态校验机制。
基本思路是:每个颁发的 JWT 都必须携带一个唯一的 jti(JWT ID)。当用户主动退出登录、修改密码或后台冻结账号时,将该 jti 作为 Key 写入 Redis 缓存,过期时间(TTL)直接设为该 Token 剩余的有效时间。网关或拦截器鉴权时,双重核验 Redis 中是否存在该黑名单 Key。
以下是我们在网关拦截器(基于 TypeScript 和 ioredis)中落地的一套高度优化的鉴权逻辑代码:
import { Redis } from 'ioredis';import * as jwt from 'jsonwebtoken';
const redis = new Redis({ host: '127.0.0.1', port: 6379, maxRetriesPerRequest: 3,});
interface JwtPayload { sub: string; // userId jti: string; // Token唯一标识 exp: number; // 过期戳(秒) role: string;}
export async function verifyAndCheckBlacklist(token: string): Promise<JwtPayload | null> { try { // 1. 快速校验 Token 签名与基础时效 const decoded = jwt.verify(token, 'your-jwt-secret-key-do-not-leak') as JwtPayload; if (!decoded.jti || !decoded.exp) { return null; }
// 2. 检查 Redis 中是否存在该 Token 的吊销黑名单记录 const blacklistKey = `jwt:blacklist:${decoded.jti}`; const isBlacklisted = await redis.exists(blacklistKey); if (isBlacklisted === 1) { console.warn(`[Auth] 拦截到已被吊销的泄露 Token, JTI: ${decoded.jti}`); return null; }
// 3. 检查用户账号是否被封禁/限制 // 为了防止每次 API 请求都查 DB,我们在此处对用户状态做一层秒级缓存 const userStatusKey = `user:status:${decoded.sub}`; let userStatus = await redis.get(userStatusKey);
if (!userStatus) { // 缓存未命中时去查 DB(此处以模拟代替) const realStatusFromDb = await mockGetUserStatusFromDb(decoded.sub); userStatus = realStatusFromDb; // 缓存 5 秒,平衡数据实时性与高并发下的 Redis 负载 await redis.set(userStatusKey, realStatusFromDb, 'EX', 5); }
if (userStatus === 'frozen') { console.warn(`[Auth] 拒绝访问,用户账户已冻结, UID: ${decoded.sub}`); return null; }
return decoded; } catch (err) { console.error('[Auth] Token 校验抛出异常:', err); return null; }}
// 模拟账号注销/封禁时写入 Redis 黑名单的逻辑export async function revokeToken(token: string): Promise<boolean> { try { const decoded = jwt.decode(token) as JwtPayload; if (!decoded || !decoded.jti || !decoded.exp) { return false; } const now = Math.floor(Date.now() / 1000); const timeLeft = decoded.exp - now;
if (timeLeft > 0) { const blacklistKey = `jwt:blacklist:${decoded.jti}`; // 将 Token 写入黑名单,生存时间刚好等于 Token 剩余的有效期 await redis.set(blacklistKey, 'revoked', 'EX', timeLeft); console.log(`[Auth] 成功吊销 Token, JTI: ${decoded.jti}, 剩余有效时间: ${timeLeft} 秒`); } return true; } catch (err) { console.error('[Auth] 吊销 Token 失败:', err); return false; }}
async function mockGetUserStatusFromDb(userId: string): Promise<string> { return new Promise((resolve) => { setTimeout(() => { resolve(userId === '10086' ? 'frozen' : 'active'); }, 20); });}引入 Redis 校验后,鉴权系统从纯无状态退化成了“弱有状态”模式。虽然解决了即时封禁和退出登录的时效性痛点,但心智负担和接口延迟也有所增加。在大流量场景下,每次网关都要多查一次 Redis,对 Redis 的并发读性能要求极高。
为了进一步优化,我们考虑在网关进程内存中缓存一层秒级的黑名单 LocalCache,虽然存在极短的同步时差,但能让 90% 以上的请求不需要去查 Redis 物理网络。
你们在项目中是怎么平衡 JWT 的“无状态”与安全审计的“即时性”要求的?是像我们这样退化成 Redis 校验,还是干脆上全套 OIDC 的 Back-Channel Logout 机制?