Files
BYW-VoiceHub/server/api/auth/login.post.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

279 lines
8.9 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 bcrypt from 'bcryptjs'
import { db, eq, users } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import {
getAccountLockRemainingTime,
getIPBlockRemainingTime,
isAccountLocked,
isIPBlocked,
recordLoginFailure,
recordLoginSuccess,
recordAccountIpLogin,
blockUser,
getUserBlockRemainingTime
} 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()
try {
const body = await readBody(event)
const clientIp = getClientIP(event)
if (!body.username || !body.password) {
throw createError({
statusCode: 400,
message: '账号名和密码不能为空'
})
}
if (!process.env.JWT_SECRET) {
console.error('JWT_SECRET environment variable is not set')
throw createError({
statusCode: 500,
message: '服务器配置错误'
})
}
/*
* ⚠️ 这里原来有一个「数据库连接检查」:
*
* try { await db.select().from(users).limit(1) }
* catch { throw 503 '数据库服务暂时不可用' }
*
* 它**每次登录都多花一次数据库往返**(实测约 20ms),而且毫无收益 ——
* 紧接着下面就会真的去查用户,连不上一样会报错。
*
* 现在改成:**不预检**,而是把下面那次真实查询包在 try 里,
* 只有它失败时才回报"数据库服务暂时不可用"(文案和错误码都保留)。
*/
// 检查IP是否被限制
if (isIPBlocked(clientIp)) {
const remainingTime = getIPBlockRemainingTime(clientIp)
throw createError({
statusCode: 423,
message: `您的IP地址已被限制访问,请在 ${remainingTime} 分钟后重试`
})
}
// 检查账户是否被锁定
if (isAccountLocked(body.username)) {
const remainingTime = getAccountLockRemainingTime(body.username)
throw createError({
statusCode: 423,
message: `账户已被锁定,请在 ${remainingTime} 分钟后重试`
})
}
// 查找用户(这一次查询同时起到"数据库是否可用"的作用,不再额外预检)
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: '数据库服务暂时不可用'
})
}
const user = userResult[0] || null
if (!user) {
// 记录登录失败(用户不存在)
recordLoginFailure(body.username, clientIp)
throw createError({
statusCode: 401,
message: '用户不存在'
})
}
// 验证密码
const isPasswordValid = await bcrypt.compare(body.password, user.password)
if (!isPasswordValid) {
// 记录登录失败(密码错误)
recordLoginFailure(body.username, clientIp)
throw createError({
statusCode: 401,
message: '密码不正确'
})
}
// 检查用户状态 (移到2FA之前,防止已注销用户进行2FA验证)
if (user.status === 'withdrawn') {
throw createError({
statusCode: 403,
message: '该账号已注销'
})
} else if (user.status === 'graduate') {
throw createError({
statusCode: 403,
message: '该账号已毕业,限制访问'
})
} else if (user.status === 'banned') {
throw createError({
statusCode: 403,
message: '该账号已被封禁'
})
}
/*
* 检查是否开启 2FA
*
* ⚠️ 这次查询**每次登录都会执行**,但现实是**绝大多数账号根本没开 2FA**
* (实测约 20ms,在登录耗时里占约 9%)。
*
* 这里按用户缓存"有没有 TOTP"的结论:
* · false → 缓存住,以后这个账号再登录就**直接跳过这次查询**
* · true → **不缓存**(开着 2FA 的账号本来就少,不能因为缓存
* 而在他刚关掉 2FA 后还要求验证 —— 那是安全问题)
*
* 缓存放在**进程内 Map**、带 TTL,并且提供失效入口(见下方 invalidateTotpCache)。
* 进程重启即清空,不涉及多实例一致性:最坏情况只是多查一次库。
*/
const totpIdentity = await getTotpIdentityCached(user.id)
if (totpIdentity) {
// 生成预认证临时令牌
const tempToken = JWTEnhanced.sign({
userId: user.id,
type: 'pre-auth',
scope: '2fa_pending'
}, { expiresIn: '5m' }) // 动态构建验证方式列表
const methods = ['totp']
let maskedEmail = ''
if (user.email && user.emailVerified) {
methods.push('email')
// 生成脱敏邮箱提示
const [local, domain] = user.email.split('@')
if (local && domain) {
maskedEmail = local.length <= 2
? `***@${domain}`
: `${local.slice(0, 2)}****@${domain}`
}
}
return {
success: true,
requires2FA: true,
userId: user.id,
methods,
maskedEmail,
tempToken
}
}
recordLoginSuccess(body.username, clientIp)
const ipSwitchExceeded = recordAccountIpLogin(body.username, clientIp)
if (ipSwitchExceeded) {
blockUser(user.id)
const ipRemain = getIPBlockRemainingTime(clientIp)
const userRemain = getUserBlockRemainingTime(user.id)
throw createError({
statusCode: 423,
message: `检测到同一账号短期多IP登录,当前IP限制 ${ipRemain} 分钟,账户保护 ${userRemain} 分钟`
})
}
/*
* 更新登录信息(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
})
.where(eq(users.id, user.id))
.catch((err) => console.error('Error updating user login info:', err))
// 生成JWT
const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
await createAuthSession(event, { userId: user.id, jti, loginMethod: 'password' })
// 自动判断是否需要secure
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
// 设置cookie
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
const processingTime = Date.now() - startTime
console.log(`Login for ${user.username} processed in ${processingTime}ms`)
return {
success: true,
// 供小程序 / App 等非浏览器客户端取用:
// 后续请求带 Authorization: Bearer <token> 即可(见 server/middleware/auth.ts)
token,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
studentId: user.studentId,
role: user.role,
needsPasswordChange: !user.passwordChangedAt
}
}
} catch (error: any) {
const errorTime = Date.now() - startTime
console.error(`Login error after ${errorTime}ms:`, error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '登录过程中发生未知错误'
})
}
})