实战:刷新 Token 与退出登录

本章基于前面定义的 usersauthSessionssignAccessToken()createRefreshToken()hashRefreshToken()

1. 读取 Refresh Token

教学示例从 JSON 请求体读取:

function readRefreshToken(body: unknown): string | null {
  if (typeof body !== "object" || body === null) {
    return null;
  }

  const value = (body as Record<string, unknown>).refreshToken;

  if (typeof value !== "string" || value.length < 32) {
    return null;
  }

  return value;
}

生产浏览器应用更常从 HttpOnly Cookie 获取,不能照搬这个传输方式。

2. 刷新 Token

export function refreshTokens(
  request: Request,
  response: Response
): void {
  const oldRefreshToken = readRefreshToken(request.body);

  if (!oldRefreshToken) {
    response.status(401).json({ message: "刷新凭证无效" });
    return;
  }

  const oldTokenHash = hashRefreshToken(oldRefreshToken);
  const session = authSessions.find(
    (item) => item.refreshTokenHash === oldTokenHash
  );

  if (
    !session ||
    session.revokedAt !== null ||
    session.expiresAt.getTime() <= Date.now()
  ) {
    response.status(401).json({ message: "刷新凭证无效或已过期" });
    return;
  }

  const user = users.find((item) => item.id === session.userId);

  if (!user) {
    session.revokedAt = new Date();
    response.status(401).json({ message: "用户状态无效" });
    return;
  }

  // Refresh Token Rotation:旧摘要被新摘要覆盖。
  const newRefreshToken = createRefreshToken();
  session.refreshTokenHash = hashRefreshToken(newRefreshToken);
  session.updatedAt = new Date();

  const newAccessToken = signAccessToken(user, session.id);

  response.status(200).json({
    tokenType: "Bearer",
    accessToken: newAccessToken,
    expiresIn: 15 * 60,
    refreshToken: newRefreshToken,
  });
}

旧 Token 再次计算出的摘要已经找不到对应会话,因此无法继续刷新。

这里采用“滑动会话但不延长总过期时间”:刷新时不会修改 expiresAt。用户最长登录 7 天,之后需要重新输入密码。若业务选择延长过期时间,应设置绝对最大生命周期,避免会话永不结束。

3. 并发刷新问题

两个请求同时使用旧 Refresh Token 时,内存示例可能都在更新前查到会话并成功,这是竞态条件。

数据库实现应把以下操作放进事务:

  1. 按旧摘要查找并锁定会话。
  2. 确认会话有效。
  3. 用条件更新替换摘要。
  4. 只有一个请求能成功提交。

一种思路是:

UPDATE auth_sessions
SET refresh_token_hash = ?, updated_at = NOW()
WHERE id = ?
  AND refresh_token_hash = ?
  AND revoked_at IS NULL
  AND expires_at > NOW();

检查受影响行数是否为 1。这属于乐观并发控制。

4. 退出当前设备

export function logout(
  request: Request,
  response: Response
): void {
  const refreshToken = readRefreshToken(request.body);

  if (refreshToken) {
    const tokenHash = hashRefreshToken(refreshToken);
    const session = authSessions.find(
      (item) => item.refreshTokenHash === tokenHash
    );

    if (session && session.revokedAt === null) {
      session.revokedAt = new Date();
      session.updatedAt = new Date();
    }
  }

  // 幂等:即使 Token 已无效,也可以视为已经退出。
  response.status(200).json({ message: "已退出登录" });
}

退出接口设计为幂等,可以避免向调用者暴露某个 Refresh Token 是否存在。

5. 退出全部设备

该接口先通过 Access Token 认证:

export function logoutAll(
  request: Request,
  response: Response
): void {
  const auth = request.auth;

  if (!auth) {
    response.status(401).json({ message: "请先登录" });
    return;
  }

  const now = new Date();

  for (const session of authSessions) {
    if (session.userId === auth.userId && session.revokedAt === null) {
      session.revokedAt = now;
      session.updatedAt = now;
    }
  }

  response.status(200).json({ message: "所有设备已退出登录" });
}

所有 Refresh Token 都不能再换取新 Access Token,但已签发的 Access Token 仍可能持续到过期。如果必须立即失效,认证中间件需要查询会话撤销状态,或使用用户级 Token 版本等方案。

6. 路由

authRouter.post("/token/refresh", refreshTokens);
authRouter.post("/logout", logout);
authRouter.post(
  "/logout-all",
  authenticateAccessToken,
  logoutAll
);

7. Refresh Token 重放检测

基础轮换只会让旧 Token 查找失败,无法区分:

更完整的设计会保存:

若检测到旧 Token 重放,则撤销整个 Token 家族,要求重新登录。该方案更安全,但数据库模型和并发控制也更复杂,适合作为进阶练习。

8. 修改密码和封禁账号

常见安全策略:

只修改 passwordHash 不会自动让已有 Token 失效,必须明确处理会话状态。