实战:刷新 Token 与退出登录
本章基于前面定义的 users、authSessions、signAccessToken()、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 时,内存示例可能都在更新前查到会话并成功,这是竞态条件。
数据库实现应把以下操作放进事务:
- 按旧摘要查找并锁定会话。
- 确认会话有效。
- 用条件更新替换摘要。
- 只有一个请求能成功提交。
一种思路是:
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。
更完整的设计会保存:
- Token 家族 ID。
- 当前 Token ID 或版本。
- 被替换 Token 的历史或重用标记。
若检测到旧 Token 重放,则撤销整个 Token 家族,要求重新登录。该方案更安全,但数据库模型和并发控制也更复杂,适合作为进阶练习。
8. 修改密码和封禁账号
常见安全策略:
- 修改密码后撤销该用户所有会话。
- 管理员封禁账号后撤销所有会话。
- 高风险操作要求重新输入密码或执行多因素认证。
只修改 passwordHash 不会自动让已有 Token 失效,必须明确处理会话状态。