登录实测约 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、无乱码、括号平衡
279 lines
8.9 KiB
TypeScript
279 lines
8.9 KiB
TypeScript
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: '登录过程中发生未知错误'
|
||
})
|
||
}
|
||
})
|