认证系统重构方案

架构决策记录 — 从 session 认证迁移到 JWT + refresh token 方案

本文档是组件展示页。部分章节仅为了演示特定 Prism 组件而存在,因此可能缺乏叙事连贯性。在实际文档中,这些元素会自然地融入上下文,或被省略。


核心决策

迁移的核心问题有两个:使用什么 token 格式,以及客户端如何存储 token。第三种方案(保留服务端 session)已评估并排除。

Access token 15 分钟过期;refresh token 单次使用,7 天滑动窗口。在安全性(短暴露窗口)和用户体验(无需频繁登录)之间取得平衡。

倾向于使用 httpOnly cookie — 现有的 CSRF 中间件可以直接复用,避免 in-memory 方案刷新页面丢失状态的问题。

需要引入 Redis 或等价的 session 存储,增加基础设施复杂度。团队目标是减少有状态依赖,而非增加。已排除


架构

认证流程有两条路径:热路径(每个认证请求都走)和冷路径(token 刷新,每个客户端约 15 分钟一次)。

热路径 — 请求认证

客户端 Auth 中间件 路由处理

完整请求生命周期

浏览器 Nginx Rate Limiter CORS Auth 路由处理 PostgreSQL 浏览器

JWT 校验是 CPU 密集但很快的操作。核心逻辑在 中 — 提取 Bearer token、校验签名、返回用户上下文:

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 };
}
Access token 过期 客户端发送 refresh token 服务端校验 + 轮换 返回新的 token 对

轮换逻辑在 中实现。每个 refresh token 只能使用一次,轮换时旧 token 标记为已消费,签发新的 token 对:

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);
}

方案权衡

JWT 方案用基础设施简化换取了一些新的关注点 — 主要是撤销机制和 header 体积。

性能对比 — JWT 校验 vs 现有 session 查询:


已知问题

Review 中发现以下问题,按优先级排序。

影响所有多 tab 用户。两个 tab 同时刷新 token 时,第二个请求被误判为 token 盗用,导致整个 token 家族(包括第一个 tab 刚拿到的新 token)被撤销。

触发条件

用户在两个浏览器 tab 中同时触发 token 刷新。两个请求携带相同的 refresh token T1 = "dGhpcyBpcyBh..."

Tab A 请求先到达

进入 ,参数 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。

Tab B 请求随后到达(同一个 T1)

再次进入同一函数,参数仍然是 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 在这里
  }
  // ...不会执行到这里
}

结果

黑名单在 中实现,但 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);
}

POST /api/auth/refresh 未经过 中间件保护。攻击者可以高频调用消耗数据库资源。

const rateLimitedPaths = [
  '/api/auth/login',
  '/api/auth/register',
  // '/api/auth/refresh' ← 缺失
];

API 端点变更

本次迁移涉及四个端点的变更。

端点 变更 状态
POST /api/auth/login 返回 JWT 对,不再设置 session cookie 破坏性
POST /api/auth/refresh 新增端点,用于 token 轮换 新增
POST /api/auth/logout 将 token 加入黑名单 破坏性
GET /api/auth/me 直接从 JWT payload 读取 修改

POST /api/auth/login

验证用户凭证,返回 token 对。

请求体

{
  "email": "user@example.com",
  "password": "••••••••"
}

响应 200

{
  "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 });
});

POST /api/auth/refresh

使用有效的 refresh token 换取新的 token 对。

请求体

{ "refreshToken": "dGhpcyBpcyBhIHJlZnJl..." }

响应 200

{
  "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 — 未处理(见 P0 issue)

超时

JWT 校验超时 — 已处理。 中设置了 5 秒超时:

const payload = await verifyJWT(token, {
  maxTokenAge: '15m',
  clockTolerance: '30s',  // 允许 30 秒时钟偏移
});

Token 体积

JWT payload 过大被 nginx 截断 — 已处理。 中限制了 payload 字段:

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

请求完全没有 Authorization header — 已处理,extractBearerToken 返回 null 后抛出 missing_token


依赖变更

本次迁移引入了一个新依赖。

为什么选 jose 而不是 jsonwebtoken

版本选择

锁定 ^5.2.0(semver range),5.x 是当前 stable line。

package.json 变更

"dependencies": {
  "jose": "^5.2.0"
}

License

MIT — 与项目兼容,无 copyleft 风险。


测试覆盖

按功能点列出测试状态。未覆盖的关键场景标红。

认证流程

有效凭证登录返回 200 + token 对 — it('returns token pair on valid credentials') 无效密码返回 401 — it('rejects invalid password with 401') 缺少 Authorization header 返回 401 — it('returns 401 when Authorization header is missing') 过期 access token 被拒绝 — it('rejects expired access token') 篡改 token 签名被拒绝 — it('rejects token with tampered signature')

Token 刷新

有效 refresh token 换取新 token 对 — it('rotates refresh token and returns new pair') 过期 refresh token 返回 401 — it('rejects expired refresh token') 并发 refresh 不会误撤销 token 家族 缺失 已消费 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 触发家族撤销
});

黑名单

Logout 后 token 被加入黑名单 — it('blocks token after logout') 黑名单条目在 token 过期后被清理 缺失

交互式参数调整

以下参数可以实时调整。调整完毕后点击复制导出。


术语内联定义

鼠标悬停查看术语解释。首次出现的术语带 · 圆点标记。

认证系统使用 JWT 作为 access token。每次请求在 Authorization header 中携带 token,Auth 中间件提取并校验后将用户上下文注入到后续处理中。


概念对照

同一个词在不同语境下的不同含义 — 不是优劣对比,而是中性的语义差异。


类比映射

用已知概念解释新概念。


分支流程

Token 校验的决策树 — 不同条件走不同处理路径。

请求没有 Authorization header Authorization: Bearer eyJ...

误解澄清


所有现有 session 将被失效。用户会看到"会话已过期"的提示,需要重新登录。

可以。在 中设置双模式开关:

// 过渡期双模式:同时接受 session cookie 和 JWT header
const AUTH_DUAL_MODE = process.env.AUTH_DUAL_MODE === 'true';
// 建议过渡窗口:2 周
// 过渡期结束后设为 false 并移除 session 相关代码

部署协调:移动端团队必须在后端迁移上线前发布客户端更新。Session 失效是不可逆的。


评估轨道

对认证系统迁移方案的多维度并行评估。每个轨道是独立的评审维度,不是先后步骤。

JWT 签名校验 + refresh token 轮换 + 黑名单机制,覆盖了 OWASP 认证 checklist 的核心项。 两个 tab 同时刷新会触发误撤销。 3 个破坏性变更。移动端客户端需要在后端上线前发布更新。