架构决策记录 — 从 session 认证迁移到 JWT + refresh token 方案
本文档是组件展示页。部分章节仅为了演示特定 Prism 组件而存在,因此可能缺乏叙事连贯性。在实际文档中,这些元素会自然地融入上下文,或被省略。
迁移的核心问题有两个:使用什么 token 格式,以及客户端如何存储 token。第三种方案(保留服务端 session)已评估并排除。
Access token 15 分钟过期;refresh token 单次使用,7 天滑动窗口。在安全性(短暴露窗口)和用户体验(无需频繁登录)之间取得平衡。
倾向于使用 httpOnly cookie — 现有的 CSRF 中间件可以直接复用,避免 in-memory 方案刷新页面丢失状态的问题。
需要引入 Redis 或等价的 session 存储,增加基础设施复杂度。团队目标是减少有状态依赖,而非增加。
认证流程有两条路径:热路径(每个认证请求都走)和冷路径(token 刷新,每个客户端约 15 分钟一次)。
JWT 校验是 CPU 密集但很快的操作。核心逻辑在
export async function verifyAuth(req: Request) {
const token = extractBearerToken(req);
// token = "eyJhbGciOiJSUzI1NiIs..."
if (!token) throw new AuthError('missing_token');
const payload = await verifyJWT(token);
// payload = { sub: "user_8f3a", scopes: ["read", "write"], exp: 1716582400 }
return { userId: payload.sub, scopes: payload.scopes };
}
单次校验耗时 ~0.3ms,相比当前 session 查询的 2.5ms 是主要的性能提升点。
轮换逻辑在
export async function rotateRefreshToken(token: string) {
const record = await db.refreshToken.findByHash(hash(token));
if (!record) throw new AuthError('invalid_refresh');
if (record.consumedAt) {
await db.refreshToken.revokeFamily(record.familyId);
throw new AuthError('token_reuse_detected');
}
await db.refreshToken.markConsumed(record.id);
return issueTokenPair(record.userId);
}
如果一个已消费的 token 再次出现,整个 token 家族将被撤销(可能存在 token 盗用)。
JWT 方案用基础设施简化换取了一些新的关注点 — 主要是撤销机制和 header 体积。
性能对比 — JWT 校验 vs 现有 session 查询:
Review 中发现以下问题,按优先级排序。
影响所有多 tab 用户。两个 tab 同时刷新 token 时,第二个请求被误判为 token 盗用,导致整个 token 家族(包括第一个 tab 刚拿到的新 token)被撤销。
用户在两个浏览器 tab 中同时触发 token 刷新。两个请求携带相同的 refresh token T1 = "dGhpcyBpcyBh..."。
进入 token = "dGhpcyBpcyBh...":
export async function rotateRefreshToken(token: string) {
const record = await db.refreshToken.findByHash(hash(token));
// record = { id: 42, familyId: 7, consumedAt: null }
if (!record) throw new AuthError('invalid_refresh');
if (record.consumedAt) { // null → 跳过
await db.refreshToken.revokeFamily(record.familyId);
throw new AuthError('token_reuse_detected');
}
await db.refreshToken.markConsumed(record.id); // id=42 标记为已消费
return issueTokenPair(record.userId); // → 签发新 token 对 T2
}
Tab A 正常拿到新 token 对 T2。
再次进入同一函数,参数仍然是 token = "dGhpcyBpcyBh...":
export async function rotateRefreshToken(token: string) {
const record = await db.refreshToken.findByHash(hash(token));
// record = { id: 42, familyId: 7, consumedAt: "2025-05-24T10:32:01Z" }
// ↑ 已被 Tab A 消费
if (!record) throw new AuthError('invalid_refresh');
if (record.consumedAt) { // 不为 null → 进入分支
await db.refreshToken.revokeFamily(record.familyId);
// familyId=7 的所有 token 被撤销,包括 Tab A 刚拿到的 T2
throw new AuthError('token_reuse_detected'); // ← Bug 在这里
}
// ...不会执行到这里
}
根本原因:rotateRefreshToken 无法区分"合法并发"和"token 被盗后重放",对两者执行了相同的撤销逻辑。修复方向:在 markConsumed 时使用乐观锁(WHERE consumedAt IS NULL),并发时第二个请求直接返回新 token 而不撤销。
黑名单在 Set 只增不减,长期运行后内存会持续增长。
const blocklist = new Set<string>();
export function blockToken(jti: string) {
blocklist.add(jti);
// 没有 TTL,没有过期清理
// 运行 30 天后 blocklist.size ≈ 180,000
}
export function isBlocked(jti: string): boolean {
return blocklist.has(jti);
}
建议改用 Map<string, number> 存储 jti → expiresAt,定期清理过期条目;或直接用 Redis 的 SETEX。
POST /api/auth/refresh 未经过
const rateLimitedPaths = [
'/api/auth/login',
'/api/auth/register',
// '/api/auth/refresh' ← 缺失
];
本次迁移涉及四个端点的变更。
| 端点 | 变更 | 状态 |
|---|---|---|
POST /api/auth/login |
返回 JWT 对,不再设置 session cookie | |
POST /api/auth/refresh |
新增端点,用于 token 轮换 | |
POST /api/auth/logout |
将 token 加入黑名单 | |
GET /api/auth/me |
直接从 JWT payload 读取 |
验证用户凭证,返回 token 对。
{
"email": "user@example.com",
"password": "••••••••"
}
{
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
"refreshToken": "dGhpcyBpcyBhIHJlZnJl...",
"expiresIn": 900
}
router.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.user.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
throw new AuthError('invalid_credentials', 401);
}
const { accessToken, refreshToken } = await issueTokenPair(user.id);
res.json({ accessToken, refreshToken, expiresIn: 900 });
});
使用有效的 refresh token 换取新的 token 对。
{ "refreshToken": "dGhpcyBpcyBhIHJlZnJl..." }
{
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
"refreshToken": "bmV3IHJlZnJlc2ggdG9r...",
"expiresIn": 900
}
共五个步骤,目前进行到第三步。前两步已合并并在 feature flag 后上线。
每个步骤的回退操作:
不可逆操作:步骤 2 的 migration 在 30 天后会 drop sessions 表。如果已执行,回滚需要从备份恢复 sessions 数据。
本次迁移涉及 3 个模块、共 9 个文件。
以下模块与本次迁移有直接依赖关系。
必须在 auth 中间件之前执行。当前基于 IP 限流,迁移后可加入基于 userId 的限流。
用于 logout 和密码变更后的 token 撤销。当前实现为内存 Set,存在内存泄漏风险。
基于生产环境最近 7 天的数据,各类请求的占比如下:
除了最终采纳的 JWT 方案外,评估了两个替代方案。以下并列展示三条技术路线的完整评估。
签发短生命周期 JWT access token + 单次使用 refresh token 轮换。校验完全无状态。
签发随机 opaque token,服务端在 Redis 中存储 session 数据。每次请求查 Redis 校验。
保持当前架构,session 数据存在 PostgreSQL 的 sessions 表。优化查询性能。
EXPLAIN ANALYZE
SELECT * FROM sessions WHERE token_hash = $1 AND expires_at > NOW();
主流程之外需要关注的 edge case。已处理的标注了对应的守卫代码,未处理的标红。
同一用户多 tab 同时刷新 token —
JWT 校验超时 — 已处理。
const payload = await verifyJWT(token, {
maxTokenAge: '15m',
clockTolerance: '30s', // 允许 30 秒时钟偏移
});
JWT payload 过大被 nginx 截断 — 已处理。
function buildPayload(user: User) {
return {
sub: user.id, // 8 bytes
scopes: user.scopes, // ~20 bytes
// 不放 email、name 等大字段,避免 header 超过 nginx 默认 8KB 限制
};
}
微服务间时钟不同步导致 token 提前/延迟过期 — 已处理,clockTolerance: '30s'(见上方 jwt.ts:22)。
请求完全没有 Authorization header — 已处理,extractBearerToken 返回 null 后抛出 missing_token。
本次迁移引入了一个新依赖。
锁定 ^5.2.0(semver range),5.x 是当前 stable line。
"dependencies": {
"jose": "^5.2.0"
}
MIT — 与项目兼容,无 copyleft 风险。
按功能点列出测试状态。未覆盖的关键场景标红。
it('returns token pair on valid credentials')it('rejects invalid password with 401')it('returns 401 when Authorization header is missing')it('rejects expired access token')it('rejects token with tampered signature')it('rotates refresh token and returns new pair')it('rejects expired refresh token')describe('POST /api/auth/refresh', () => {
it('rotates refresh token and returns new pair', async () => {
const { refreshToken } = await loginAs(testUser);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken });
expect(res.status).toBe(200);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).not.toBe(refreshToken);
});
it('rejects expired refresh token', async () => {
const expiredToken = await createExpiredRefreshToken(testUser);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: expiredToken });
expect(res.status).toBe(401);
});
// TODO: 并发 refresh 测试
// TODO: 已消费 token 触发家族撤销
});
it('blocks token after logout')以下参数可以实时调整。调整完毕后点击复制导出。
鼠标悬停查看术语解释。首次出现的术语带 · 圆点标记。
认证系统使用
同一个词在不同语境下的不同含义 — 不是优劣对比,而是中性的语义差异。
用已知概念解释新概念。
Token 校验的决策树 — 不同条件走不同处理路径。
所有现有 session 将被失效。用户会看到"会话已过期"的提示,需要重新登录。
可以。在
// 过渡期双模式:同时接受 session cookie 和 JWT header
const AUTH_DUAL_MODE = process.env.AUTH_DUAL_MODE === 'true';
// 建议过渡窗口:2 周
// 过渡期结束后设为 false 并移除 session 相关代码
部署协调:移动端团队必须在后端迁移上线前发布客户端更新。Session 失效是不可逆的。
对认证系统迁移方案的多维度并行评估。每个轨道是独立的评审维度,不是先后步骤。
见 P0 issue:rotateRefreshToken 无法区分合法并发和 token 盗用重放。