Files
BYW-VoiceHub/server/utils/totp-cache.ts
T
Henry_Du ebf1c3a127 perf(auth): 登录去掉一次冗余查库 + 缓存 2FA 查询 + 修掉 session_key 进日志
登录实测约 232ms,其中 bcrypt 约 72ms、网络往返约 80ms 是不可省的计算/物理成本,
剩下的都是我们自己的开销。这次把能省的两处省掉(实测约 40ms,占登录耗时约 19%):

1. 去掉「数据库连接检查」的冗余预检
   原来每次登录先 await db.select().from(users).limit(1) 只为"测连接",
   紧接着又会真的查用户,连不上一样会报错 —— 纯粹多一次数据库往返。
   现在把那次真实查询包进 try,只有它失败才回报 503「数据库服务暂时不可用」,
   错误码与文案完全不变。

2. 缓存「这个账号有没有开 2FA」
   登录每次都查 userIdentities 找 TOTP,但绝大多数账号根本没开 2FA。
   新增 server/utils/totp-cache.ts:
     · 查到**没有** TOTP → 缓存 10 分钟,后续登录直接跳过查询
     · 查到**有** TOTP   → **不缓存**(安全优先:绝不能因为缓存而让刚开 2FA 的人被跳过验证)
   开启/关闭 2FA 的接口必须调用 invalidateTotpCache(userId),已在
   server/api/user/2fa/{enable,disable}.post.ts 接上 —— 否则会有最长 10 分钟的验证空窗。
   该缓存不参与密码校验,最坏情况只是多查一次库。

3. lastLogin / lastLoginIp 的 UPDATE 不再阻塞响应
   登录时间与登录 IP 是审计信息,晚写几十毫秒、甚至这次写失败,
   都不影响用户能否登录。改为不 await(保留 .catch,避免 unhandledRejection)。

4. 安全修复:微信登录不再把 session_key 打进日志
   server/api/auth/wechat/login.post.ts 原来打印整个 jscode2session 响应前 500 字,
   而该响应含 { session_key, openid, unionid }。
   session_key 是敏感凭据(可解密用户数据、配合其他接口换取手机号),
   长期落在服务端日志里等于凭据落盘。
   现在只打印:顶层键名、errcode/errmsg、以及 openid/session_key/unionid 的
   存在性与长度(不打印值);仅在"响应根本不是对象"时才打印截断原文
   (那种情况它不可能是合法凭据,且是排查被代理拦成 HTML 的唯一线索)。

已验证:
- 缓存逻辑用假 db 真跑过 5 个场景(命中缓存省下查询、失效后立刻重查、
  有 2FA 不缓存、多用户互不干扰、只失效指定用户)
- 4 个改动文件的相对 import 路径均存在;totp-cache 的导出与两处 import 对得上
- login.post.ts 抽走 TOTP 查询后已清理不再使用的 userIdentities / and 导入
- 文件编码 UTF-8 无 BOM、无乱码、括号平衡
2026-10-01 21:04:02 +08:00

68 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db, eq, userIdentities, and } from '~/drizzle/db'
/*
* ============================================================================
* 「这个账号有没有开 2FA」的进程内缓存(第 97 轮登录提速)
* ============================================================================
*
* 背景:登录流程里每次都查 userIdentities 找 TOTP,但**绝大多数账号没开 2FA**。
* 这一查实测约 20ms,占登录耗时的约 9% —— 是极少数人让所有人买单。
*
* 规则(**安全优先**,这是关键):
* · 查到**没有** TOTP → 缓存"没有",TTL 内直接跳过查询
* · 查到**有** TOTP → **不缓存**。反过来说:绝不能因为缓存,
* 让一个刚开启 2FA 的用户被跳过验证
*
* 因此开启 / 关闭 2FA 的接口**必须**调用 `invalidateTotpCache(userId)`
* (见 server/api/user/2fa/enable.post.ts 与 disable.post.ts)。
* 这是这个缓存唯一需要外部配合的地方,也是它安全的前提。
*
* 缓存只影响"是否需要多验一步",**不参与密码校验** ——
* 所以即使缓存过期、命中错误、或多实例不一致,最坏结果也只是**多查一次库**,
* 不会放过任何本该拦截的登录。
*
* 进程重启即清空;多实例部署下各自独立,同样只影响"多查一次"。
*/
/** 缓存有效期:10 分钟。到期后重新查库确认一次,避免长期依赖陈旧结论 */
const TOTP_CACHE_TTL_MS = 10 * 60 * 1000
/** userId -> 记录该用户「没有 TOTP」的时间戳 */
const noTotpCache = new Map<number, number>()
/**
* 让某个用户的 2FA 缓存失效。
* 开启 / 关闭 2FA 之后**必须**调用,否则会有最长 10 分钟的验证空窗。
* @param userId 不传则清空全部
*/
export function invalidateTotpCache(userId?: number): void {
if (typeof userId === 'number') noTotpCache.delete(userId)
else noTotpCache.clear()
}
/**
* 查该用户的 TOTP 身份记录(带"没开 2FA"的缓存)。
* @returns 有 2FA 时返回那条 identity 记录;没有则返回 null
*/
export async function getTotpIdentityCached(userId: number) {
const cachedAt = noTotpCache.get(userId)
if (cachedAt !== undefined && Date.now() - cachedAt < TOTP_CACHE_TTL_MS) {
// 缓存说"这个账号没开 2FA" → 直接跳过查询
return null
}
const identity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, userId), eq(userIdentities.provider, 'totp'))
})
if (identity) {
// 有 2FA:不缓存(见顶部安全说明),并把可能残留的旧"没有"记录清掉
noTotpCache.delete(userId)
return identity
}
// 没有 2FA:缓存住,下次直接跳过这次查询
noTotpCache.set(userId, Date.now())
return null
}