Files
BYW-VoiceHub/server/api/auth/login.post.ts
T
Henry_Du a140ce8621 fix(open-api): 修复开放API两处缺陷并支持小程序取 token
- songs.get.ts: 引用未定义变量 songsWithDetails 导致 /api/open/songs 缓存未命中时必然 500,改为 formattedSongs;并让缓存分支兼容 requester 为字符串的情况,避免返回 { name: undefined }
- schedules.get.ts: 数据库分支补齐 sequence / played / song.voteCount,与此前只有缓存分支才返回这些字段的行为对齐(缺少 sequence 会让前端无法显示歌曲序号)
- login.post.ts: 响应体返回 token,便于小程序/App 通过 Authorization: Bearer 访问(不影响原有 httpOnly cookie 行为)
2026-09-26 13:48:27 +08:00

241 lines
6.8 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, userIdentities, and } 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'
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 (error) {
console.error('Database connection error:', error)
throw createError({
statusCode: 503,
message: '数据库服务暂时不可用'
})
}
// 检查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} 分钟后重试`
})
}
// 查找用户
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
})
.from(users)
.where(eq(users.username, body.username))
.limit(1)
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
const totpIdentity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp'))
})
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} 分钟`
})
}
// 更新登录信息
await 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: '登录过程中发生未知错误'
})
}
})