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 即可(见 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: '登录过程中发生未知错误' }) } })