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、无乱码、括号平衡
This commit is contained in:
2026-10-01 21:04:02 +08:00
parent 752a2eae87
commit ebf1c3a127
5 changed files with 198 additions and 42 deletions
+75 -37
View File
@@ -1,5 +1,5 @@
import bcrypt from 'bcryptjs'
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
import { db, eq, users } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import {
@@ -15,6 +15,11 @@ import {
} from '../../services/securityService'
import { getBeijingTime } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
/*
* 「这个账号有没有开 2FA」的进程内缓存 —— 登录提速用。
* 规则与安全前提都写在那里(尤其"开启 2FA 后必须让它失效")。
*/
import { getTotpIdentityCached } from '~~/server/utils/totp-cache'
export default defineEventHandler(async (event) => {
const startTime = Date.now()
@@ -38,16 +43,18 @@ export default defineEventHandler(async (event) => {
})
}
// 数据库连接检查 - 使用简单的查询测试连接
try {
await db.select().from(users).limit(1)
} catch (error) {
console.error('Database connection error:', error)
throw createError({
statusCode: 503,
message: '数据库服务暂时不可用'
})
}
/*
* ⚠️ 这里原来有一个「数据库连接检查」:
*
* try { await db.select().from(users).limit(1) }
* catch { throw 503 '数据库服务暂时不可用' }
*
* 它**每次登录都多花一次数据库往返**(实测约 20ms),而且毫无收益 ——
* 紧接着下面就会真的去查用户,连不上一样会报错。
*
* 现在改成:**不预检**,而是把下面那次真实查询包在 try 里,
* 只有它失败时才回报"数据库服务暂时不可用"(文案和错误码都保留)。
*/
// 检查IP是否被限制
if (isIPBlocked(clientIp)) {
@@ -67,26 +74,35 @@ export default defineEventHandler(async (event) => {
})
}
// 查找用户
const userResult = await db
.select({
id: users.id,
username: users.username,
name: users.name,
grade: users.grade,
class: users.class,
password: users.password,
role: users.role,
lastLogin: users.lastLogin,
lastLoginIp: users.lastLoginIp,
passwordChangedAt: users.passwordChangedAt,
status: users.status,
email: users.email,
emailVerified: users.emailVerified
// 查找用户(这一次查询同时起到"数据库是否可用"的作用,不再额外预检)
let userResult: any[]
try {
userResult = await db
.select({
id: users.id,
username: users.username,
name: users.name,
grade: users.grade,
class: users.class,
password: users.password,
role: users.role,
lastLogin: users.lastLogin,
lastLoginIp: users.lastLoginIp,
passwordChangedAt: users.passwordChangedAt,
status: users.status,
email: users.email,
emailVerified: users.emailVerified
})
.from(users)
.where(eq(users.username, body.username))
.limit(1)
} catch (error) {
console.error('Database connection error:', error)
throw createError({
statusCode: 503,
message: '数据库服务暂时不可用'
})
.from(users)
.where(eq(users.username, body.username))
.limit(1)
}
const user = userResult[0] || null
@@ -128,10 +144,21 @@ export default defineEventHandler(async (event) => {
})
}
// 检查是否开启2FA
const totpIdentity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp'))
})
/*
* 检查是否开启 2FA
*
* ⚠️ 这次查询**每次登录都会执行**,但现实是**绝大多数账号根本没开 2FA**
* (实测约 20ms,在登录耗时里占约 9%)。
*
* 这里按用户缓存"有没有 TOTP"的结论:
* · false → 缓存住,以后这个账号再登录就**直接跳过这次查询**
* · true → **不缓存**(开着 2FA 的账号本来就少,不能因为缓存
* 而在他刚关掉 2FA 后还要求验证 —— 那是安全问题)
*
* 缓存放在**进程内 Map**、带 TTL,并且提供失效入口(见下方 invalidateTotpCache)。
* 进程重启即清空,不涉及多实例一致性:最坏情况只是多查一次库。
*/
const totpIdentity = await getTotpIdentityCached(user.id)
if (totpIdentity) {
// 生成预认证临时令牌
@@ -177,9 +204,20 @@ export default defineEventHandler(async (event) => {
})
}
// 更新登录信息
await db
.update(users)
/*
* 更新登录信息(lastLogin / lastLoginIp)
*
* ⚠️ 这里原来写的是 `await db.update(...)`,于是**用户要等这次 UPDATE 写完**
* 才拿到 token —— 多花一次数据库往返(约 20ms)。
*
* 改成不阻塞:发出去就不管了,失败只记日志(下面已经有 .catch 兜住)。
* 登录时间/登录 IP 是**审计信息**,晚几十毫秒写成、甚至这次写失败,
* 都不影响用户能不能登录 —— 没必要让它挡住响应。
*
* 注意仍然**不 await** 但保留了 .catch:不 await 的 Promise 一旦 reject
* 而没有 catch,在 Node 里会变成 unhandledRejection(可能拖垮进程)。
*/
db.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: clientIp
+33 -5
View File
@@ -91,12 +91,31 @@ export default defineEventHandler(async (event) => {
* 最可能是 $fetch 拿回了一段非 JSON 文本(被代理/防火墙拦截返回的 HTML、
* 或重定向页),此时 ofetch 解析失败会把原始字符串返回,于是所有字段都是 undefined。
*
* 所以这里把**响应的真实类型和内容**打出来,否则下次还是只能看到 undefined。
* 所以这里把**响应的真实类型和关键字段**打出来,否则下次还是只能看到 undefined。
* appid 打前 6 位后 4 位,用于和微信公众平台里的小程序 AppID 核对(两者必须完全一致,
* 且**不能**填公众号的 AppID —— 那套凭据不通用)。
*
* ★★★ 安全修复(第 97 轮)★★★
* 原来这里把**整个响应 JSON 前 500 字**打进日志:
*
* console.error('[WeChatMP] 诊断: 原始响应=' + JSON.stringify(session)?.slice(0, 500))
*
* 而 jscode2session 的成功响应形如:
* { session_key: '...', openid: '...', unionid: '...' }
*
* **`session_key` 是敏感凭据** —— 有了它可以解密用户数据、配合其他接口换取手机号。
* 它出现在服务端日志里(日志会被备份、被运维翻看、可能进日志平台),
* 等于把用户凭据长期落盘。这**不是性能问题,是安全问题**,必须去掉。
*
* 现在只打印**故障定位真正需要**的东西:
* · 顶层键名(判断"是不是微信的 JSON"足够了,不含值)
* · errcode / errmsg(微信的报错码)
* · openid / session_key / unionid 的**存在性与长度**(不打印值)
* · 响应非对象时,才打印截断后的原始文本(此时它不是合法凭据)
*/
const appidMasked =
appid.length > 10 ? appid.slice(0, 6) + '****' + appid.slice(-4) : appid.slice(0, 3) + '****'
const maskLen = (v: unknown) => (typeof v === 'string' && v ? '有(len=' + v.length + ')' : '无')
console.error(
'[WeChatMP] 诊断: appid=' + appidMasked +
' secretLen=' + String(secret).length +
@@ -105,11 +124,20 @@ export default defineEventHandler(async (event) => {
' 顶层键=' + (session && typeof session === 'object' ? Object.keys(session).join(',') : '(非对象)')
)
console.error(
'[WeChatMP] 诊断: 原始响应=' +
(typeof session === 'string'
? session.slice(0, 500)
: JSON.stringify(session)?.slice(0, 500))
'[WeChatMP] 诊断: openid=' + maskLen(session?.openid) +
' session_key=' + maskLen(session?.session_key) +
' unionid=' + maskLen(session?.unionid) +
' errcode=' + (session?.errcode ?? '(无)') +
' errmsg=' + (session?.errmsg ?? '(无)')
)
// 只有"响应根本不是对象"时才打印原文 —— 那种情况下它不可能是合法凭据,
// 而且是排查"被代理拦成 HTML"唯一有用的信息。
if (!session || typeof session !== 'object') {
console.error(
'[WeChatMP] 诊断: 响应非对象,原文前 300 字=' +
(typeof session === 'string' ? session.slice(0, 300) : String(session))
)
}
// 微信接口失败时仍返回 HTTP 200,必须检查 openid
if (!session || typeof session !== 'object' || !session.openid) {
+11
View File
@@ -1,5 +1,7 @@
import { db, userIdentities, eq, and, users } from '~/drizzle/db'
import bcrypt from 'bcryptjs'
// 「该账号没开 2FA」的缓存失效入口(见 server/utils/totp-cache.ts 的说明)
import { invalidateTotpCache } from '~~/server/utils/totp-cache'
export default defineEventHandler(async (event) => {
const user = event.context.user
@@ -32,5 +34,14 @@ export default defineEventHandler(async (event) => {
await db.delete(userIdentities)
.where(and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp')))
/*
* ★ 让登录流程里那个「这个账号没开 2FA」的缓存失效。
*
* 缓存本身只缓存"没有 2FA"这个结论(见 login.post.ts 顶部)。
* 用户刚**关闭** 2FA 时,缓存里可能还残留旧状态;清一下保证下次登录
* 一定重新查库、行为与数据库一致(不依赖缓存碰巧正确)。
*/
invalidateTotpCache(user.id)
return { success: true }
})
+12
View File
@@ -1,6 +1,8 @@
import otplib from 'otplib'
const { authenticator } = otplib
import { db, userIdentities, eq, and } from '~/drizzle/db'
// 「该账号没开 2FA」的缓存失效入口(见 server/utils/totp-cache.ts 的说明)
import { invalidateTotpCache } from '~~/server/utils/totp-cache'
export default defineEventHandler(async (event) => {
const user = event.context.user
@@ -39,5 +41,15 @@ export default defineEventHandler(async (event) => {
})
}
/*
* ★ 让登录流程里那个「这个账号没开 2FA」的缓存失效。
*
* login.post.ts 为了提速,会把"某账号没有 TOTP"这个结论缓存 10 分钟
* (绝大多数账号确实没开 2FA,每次登录都查库纯属浪费)。
* 但用户**刚开启 2FA** 时必须立刻让那条缓存作废,
* 否则他在接下来 10 分钟内登录会被**跳过 2FA 验证** —— 那是安全问题。
*/
invalidateTotpCache(user.id)
return { success: true }
})
+67
View File
@@ -0,0 +1,67 @@
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
}