前段时间,我们遭遇了一起非常恶劣的安全事故。由于前端同学在本地存储(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 机制?