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() /** * 让某个用户的 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 }