初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
@@ -0,0 +1,102 @@
import { db, users, eq } from '~/drizzle/db'
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
import { SmtpService } from '~~/server/services/smtpService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { randomInt } from 'crypto'
export default defineEventHandler(async (event) => {
const { userId: reqUserId, token, email } = await readBody(event)
// 必须提供预认证令牌
if (!token) {
throw createError({ statusCode: 400, message: '缺少预认证令牌' })
}
let userId: number
try {
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
throw new Error('无效的预认证令牌')
}
userId = decoded.userId
} catch (e) {
throw createError({ statusCode: 401, message: '会话已失效,请重新登录' })
}
// 校验 userId 是否匹配
if (!reqUserId || Number(reqUserId) !== userId) {
throw createError({ statusCode: 403, message: '非法操作:用户ID不匹配' })
}
// 获取用户邮箱
const userResult = await db.select({
id: users.id,
email: users.email,
emailVerified: users.emailVerified,
name: users.name
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
const user = userResult[0]
// 增加 emailVerified 校验
if (!user || !user.email || !user.emailVerified) {
throw createError({ statusCode: 400, message: '用户不存在或未绑定邮箱' })
}
// 校验用户输入的邮箱是否匹配
if (!email || email.trim().toLowerCase() !== user.email.toLowerCase()) {
throw createError({ statusCode: 400, message: '邮箱地址不匹配' })
}
// 检查是否在冷却时间内
const existingCode = twoFactorCodes.get(userId)
if (existingCode && existingCode.expiresAt > Date.now()) {
// 5 * 60 * 1000 = 300000ms
const totalDuration = 5 * 60 * 1000
const timePassed = totalDuration - (existingCode.expiresAt - Date.now())
if (timePassed < 60 * 1000) { // 60秒冷却
const remainingSeconds = Math.ceil((60000 - timePassed) / 1000)
throw createError({
statusCode: 429,
message: `操作过于频繁,请等待 ${remainingSeconds} 秒后再试`
})
}
}
const code = randomInt(100000, 999999).toString()
twoFactorCodes.set(userId, {
code,
expiresAt: Date.now() + 5 * 60 * 1000,
attempts: 0
})
const clientIP = getClientIP(event)
// 发送邮件
const smtp = SmtpService.getInstance()
await smtp.initializeSmtpConfig()
const sent = await smtp.renderAndSend(
user.email,
'verification.code',
{
name: user.name || '用户',
email: user.email,
code,
expiresInMinutes: 5,
action: '登录验证'
},
clientIP
)
if (!sent) {
throw createError({ statusCode: 500, message: '验证码发送失败' })
}
return { success: true, message: '验证码已发送至您的邮箱' }
})
@@ -0,0 +1,130 @@
import otplib from 'otplib'
const { authenticator } = otplib
import { db, userIdentities, eq, and, users } from '~/drizzle/db'
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTime } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { code, type, token } = body
if (!code || !type) {
throw createError({ statusCode: 400, message: '缺少必要参数' })
}
// 验证预认证临时令牌
let targetUserId: number
if (token) {
try {
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
throw new Error('无效的预认证令牌')
}
targetUserId = decoded.userId
} catch (e) {
throw createError({ statusCode: 401, message: '会话已失效,请重新登录' })
}
} else {
// 强制要求 Token
throw createError({ statusCode: 400, message: '缺少预认证令牌,请重新登录' })
}
// 获取用户信息
const userResult = await db.select().from(users).where(eq(users.id, targetUserId)).limit(1)
const user = userResult[0]
if (!user) {
throw createError({ statusCode: 404, message: '用户不存在' })
}
if (user.status !== 'active') {
throw createError({ statusCode: 403, message: '账号已被禁用或限制访问' })
}
let verified = false
if (type === 'totp') {
const identity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, targetUserId), eq(userIdentities.provider, 'totp'))
})
if (!identity) {
throw createError({ statusCode: 400, message: '未开启TOTP验证' })
}
verified = authenticator.check(code, identity.providerUserId)
if (!verified) {
throw createError({ statusCode: 400, message: '动态验证码错误' })
}
} else if (type === 'email') {
const stored = twoFactorCodes.get(targetUserId)
if (!stored) {
throw createError({ statusCode: 400, message: '验证码已过期或不存在' })
}
if (stored.expiresAt <= Date.now()) {
twoFactorCodes.delete(targetUserId)
throw createError({ statusCode: 400, message: '验证码已过期' })
}
// 检查尝试次数
if (stored.attempts >= 5) {
twoFactorCodes.delete(targetUserId)
throw createError({ statusCode: 400, message: '验证尝试次数过多,请重新获取' })
}
if (stored.code === code) {
verified = true
twoFactorCodes.delete(targetUserId) // 验证成功后删除
} else {
// 增加尝试次数
stored.attempts++
twoFactorCodes.set(targetUserId, stored)
throw createError({ statusCode: 400, message: `验证码错误,剩余尝试次数:${5 - stored.attempts}` })
}
} else {
throw createError({ statusCode: 400, message: '不支持的验证类型' })
}
// 验证通过,更新登录信息
const clientIp = getClientIP(event)
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))
// 生成Token
const authToken = JWTEnhanced.generateToken(user.id, user.role)
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', authToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return {
success: true,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
role: user.role,
needsPasswordChange: !user.passwordChangedAt,
has2FA: true
}
}
})
@@ -0,0 +1,176 @@
import { parseState, getRedirectUri } from '~~/server/utils/oauth'
import { generateBindingToken } from '~~/server/utils/oauth-token'
import { db, eq, userIdentities } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
const query = getQuery(event)
const code = query.code as string
const stateStr = query.state as string
if (!provider) {
throw createError({ statusCode: 400, message: 'Missing provider' })
}
if (!code || !stateStr) {
throw createError({ statusCode: 400, message: 'Missing code or state' })
}
// 1. 验证 State
const csrfCookie = getCookie(event, 'oauth_csrf')
// 获取 Origin
const headers = getRequestHeaders(event)
const protocol = headers['x-forwarded-proto'] || 'http'
const host = headers['host']
const origin = `${protocol}://${host}`
const state = parseState(stateStr, origin, csrfCookie)
if (!state) {
throw createError({ statusCode: 400, message: 'Invalid or expired state' })
}
// 清除 CSRF cookie
deleteCookie(event, 'oauth_csrf')
const strategy = getOAuthStrategy(provider)
const redirectUri = getRedirectUri(provider)
// 2. 使用 Code 换取 Token
let accessToken = ''
try {
accessToken = await strategy.exchangeToken(code, redirectUri)
} catch (e: any) {
console.error(`[OAuth] ${provider} token exchange failed:`, e.message)
return sendRedirect(
event,
`/auth/error?code=TOKEN_EXCHANGE_FAILED&message=${encodeURIComponent('授权失败,无法获取访问令牌')}`
)
}
// 3. 获取用户信息
let userInfo
try {
userInfo = await strategy.getUserInfo(accessToken)
} catch (e: any) {
console.error(`[OAuth] ${provider} get user info failed:`, e.message)
return sendRedirect(
event,
`/auth/error?code=USER_INFO_FAILED&message=${encodeURIComponent('获取用户信息失败')}`
)
}
const providerUserId = userInfo.id
const providerUsername = userInfo.username
return handleUserLoginOrBind(event, provider, providerUserId, providerUsername)
})
async function handleUserLoginOrBind(
event: any,
provider: string,
providerUserId: string,
providerUsername: string
) {
// 4. 检查是否已登录(绑定模式)
const authToken = getCookie(event, 'auth-token')
let currentUser: any = null
if (authToken) {
try {
const payload = JWTEnhanced.verifyToken(authToken)
currentUser = payload
} catch (e) {
// Token 无效或已过期,忽略
}
}
// 5. 检查身份关联
const existingIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) => and(eq(t.provider, provider), eq(t.providerUserId, providerUserId)),
with: { user: true }
})
// 如果用户已登录,则是绑定操作
if (currentUser) {
if (existingIdentity) {
// 已经被绑定
if (existingIdentity.userId === currentUser.userId) {
// 已经被当前用户绑定
return sendRedirect(event, '/account?message=' + encodeURIComponent('账号已绑定'))
} else {
// 已经被其他用户绑定
return sendRedirect(event, '/account?error=' + encodeURIComponent('该账号已被其他用户绑定'))
}
} else {
// 未被绑定,直接绑定到当前用户
await db.insert(userIdentities).values({
userId: currentUser.userId,
provider: provider,
providerUserId: providerUserId,
providerUsername: providerUsername,
createdAt: new Date()
})
return sendRedirect(event, '/account?message=' + encodeURIComponent('绑定成功'))
}
}
// 未登录,则是登录或新绑定流程
if (existingIdentity && existingIdentity.user) {
// 检查用户状态
const user = existingIdentity.user
if (user.status === 'withdrawn') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_WITHDRAWN&message=${encodeURIComponent('账号已注销')}`
)
}
if (user.status === 'graduate') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_GRADUATED&message=${encodeURIComponent('该账号已毕业,限制访问')}`
)
}
if (isUserBlocked(user.id)) {
const remaining = getUserBlockRemainingTime(user.id)
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_BLOCKED&message=${encodeURIComponent(`账户处于风险控制期,请在 ${remaining} 分钟后重试`)}`
)
}
// 登录
const token = JWTEnhanced.generateToken(existingIdentity.user.id, existingIdentity.user.role)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return sendRedirect(event, '/')
} else {
// 绑定
const bindingToken = generateBindingToken({
provider: provider,
providerUserId,
providerUsername
})
// 将绑定令牌存入 cookie
setCookie(event, 'binding-token', bindingToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
return sendRedirect(
event,
`/login?action=bind&provider=${provider}&username=${encodeURIComponent(providerUsername)}`
)
}
}
@@ -0,0 +1,33 @@
import { generateState, getRedirectUri } from '~~/server/utils/oauth'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
if (!provider) {
throw createError({ statusCode: 400, message: 'Missing provider' })
}
const strategy = getOAuthStrategy(provider)
// 获取 Origin
const headers = getRequestHeaders(event)
const protocol = headers['x-forwarded-proto'] || 'http'
const host = headers['host']
const origin = `${protocol}://${host}`
const redirectUri = getRedirectUri(provider)
const { state, csrf } = generateState(origin, provider)
setCookie(event, 'oauth_csrf', csrf, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
const url = strategy.getAuthorizeUrl(redirectUri, state)
return sendRedirect(event, url)
})
+102
View File
@@ -0,0 +1,102 @@
import bcrypt from 'bcrypt'
import { db, eq, users, userIdentities } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { verifyBindingToken } from '~~/server/utils/oauth-token'
import {
isAccountLocked,
recordLoginFailure,
recordLoginSuccess
} from '~~/server/services/securityService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { username, password } = body
const bindingToken = getCookie(event, 'binding-token')
if (!bindingToken) {
throw createError({ statusCode: 400, message: '绑定会话已过期,请重新通过第三方登录发起' })
}
let payload
try {
payload = verifyBindingToken(bindingToken)
} catch (e) {
deleteCookie(event, 'binding-token')
throw createError({ statusCode: 400, message: '无效的绑定令牌' })
}
const clientIp = getClientIP(event)
// 验证用户凭据
if (isAccountLocked(username)) {
throw createError({ statusCode: 423, message: '账户已被锁定,请稍后重试' })
}
const user = await db.query.users.findFirst({
where: eq(users.username, username)
})
if (!user) {
recordLoginFailure(username, clientIp)
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
const valid = await bcrypt.compare(password, user.password)
if (!valid) {
recordLoginFailure(username, clientIp)
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
// 绑定
try {
await db.transaction(async (tx) => {
const existing = await tx.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
})
if (existing) {
// 已经被绑定,不需要再次插入
return
}
await tx.insert(userIdentities).values({
userId: user.id,
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername
})
})
} catch (e: any) {
// 如果用户尝试再次绑定相同的账户,处理唯一约束违规
if (e.code === '23505') {
// Postgres 唯一性冲突
// 这意味着身份已经绑定,这是正常情况。
// 我们可以继续执行登录流程。
} else {
// 绑定失败,清除 cookie 防止重放,或者保留允许重试?
// 如果是系统错误,保留 cookie 可能更好。
// 但如果是逻辑错误,应该清除。
// 这里我们选择抛出错误,前端处理。
throw e
}
}
recordLoginSuccess(username, clientIp)
// 登录
const token = JWTEnhanced.generateToken(user.id, user.role)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
// 清除绑定令牌
deleteCookie(event, 'binding-token')
return { success: true }
})
@@ -0,0 +1,97 @@
import bcrypt from 'bcrypt'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { recordLoginFailure, recordLoginSuccess } from '../../services/securityService'
import { updateUserPassword } from '../../services/userService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 验证用户身份
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
statusMessage: '未授权'
})
}
const body = await readBody(event)
if (!body.currentPassword || !body.newPassword) {
throw createError({
statusCode: 400,
statusMessage: '当前密码和新密码都是必需的'
})
}
try {
// 查询用户详细信息
const userDetailsResult = await db
.select({
id: users.id,
username: users.username,
password: users.password
})
.from(users)
.where(eq(users.id, user.id))
.limit(1)
const userDetails = userDetailsResult[0]
if (!userDetails) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
// 验证当前密码
const isPasswordValid = await bcrypt.compare(body.currentPassword, userDetails.password)
if (!isPasswordValid) {
// 记录安全事件
const clientIp = getClientIP(event)
recordLoginFailure(userDetails.username, clientIp)
throw createError({
statusCode: 400,
statusMessage: '当前密码不正确'
})
}
// 检查新密码是否与当前密码相同
const isSamePassword = await bcrypt.compare(body.newPassword, userDetails.password)
if (isSamePassword) {
throw createError({
statusCode: 400,
statusMessage: '新密码不能与当前密码相同'
})
}
// 更新密码
await updateUserPassword(user.id, body.newPassword)
// 记录成功的密码修改
const clientIp = getClientIP(event)
recordLoginSuccess(userDetails.username, clientIp)
return {
success: true,
message: '密码修改成功'
}
} catch (error: any) {
// 已格式化的错误直接抛出
if (error.statusCode) {
throw error
}
// 记录错误信息
console.error('修改密码过程中发生未处理的错误:', error)
// 创建错误响应
throw createError({
statusCode: 500,
statusMessage: '修改密码失败: ' + (error.message || '未知错误')
})
}
})
@@ -0,0 +1,37 @@
import { db, eq, and, userIdentities } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
const identities = await db.query.userIdentities.findMany({
where: eq(userIdentities.userId, user.id),
columns: {
id: true, // 需要 id 以便单独删除
provider: true,
providerUsername: true,
createdAt: true
}
})
return identities.map(identity => {
if (identity.provider === 'webauthn') {
try {
const data = JSON.parse(identity.providerUsername)
return {
...identity,
providerUsername: data.label || 'WebAuthn 设备',
// 可以在这里把 label 改为 userFriendlyName
}
} catch (e) {
return {
...identity,
providerUsername: '未知设备'
}
}
}
return identity
})
})
+234
View File
@@ -0,0 +1,234 @@
import bcrypt from 'bcrypt'
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
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 = JWTEnhanced.generateToken(user.id, user.role)
// 自动判断是否需要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,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
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: '登录过程中发生未知错误'
})
}
})
@@ -0,0 +1,57 @@
export default defineEventHandler(async (event) => {
try {
console.log('[Auth] User logout requested')
// 获取当前用户信息(如果存在)
const user = event.context.user
// 清除用户认证缓存
if (user) {
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.delete(`auth:user:${user.id}`)
console.log(`[Cache] 用户认证缓存已清除(用户登出): ${user.id}`)
} catch (cacheError) {
console.warn('[Cache] 清除用户认证缓存失败:', cacheError)
}
}
// 清除cookie
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', '', {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 0,
path: '/'
})
return {
success: true,
message: '登出成功'
}
} catch (error: any) {
console.error('Logout error:', error)
// 出错时也要清除cookie
try {
setCookie(event, 'auth-token', '', {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 0,
path: '/'
})
} catch (cookieError) {
console.error('Failed to clear cookie:', cookieError)
}
// 返回成功状态
return {
success: true,
message: '登出成功'
}
}
})
@@ -0,0 +1,67 @@
import bcrypt from 'bcrypt'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
try {
// 检查认证
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权'
})
}
const body = await readBody(event)
if (!body.newPassword) {
throw createError({
statusCode: 400,
message: '新密码不能为空'
})
}
// 获取用户信息
const currentUserResult = await db.select().from(users).where(eq(users.id, user.id)).limit(1)
const currentUser = currentUserResult[0]
if (!currentUser) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
// 检查是否需要设置初始密码
if (currentUser.passwordChangedAt) {
throw createError({
statusCode: 400,
message: '您已经设置过密码,请使用修改密码功能'
})
}
// 加密新密码
const hashedPassword = await bcrypt.hash(body.newPassword, 10)
// 更新密码
await db
.update(users)
.set({
password: hashedPassword,
passwordChangedAt: getBeijingTime(),
forcePasswordChange: false
})
.where(eq(users.id, user.id))
return {
success: true,
message: '初始密码设置成功'
}
} catch (error: any) {
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '初始密码设置失败'
})
}
})
@@ -0,0 +1,29 @@
import { db, eq, and, userIdentities } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
const body = await readBody(event)
const { provider, id } = body
if (!provider) {
throw createError({ statusCode: 400, message: '缺少提供商参数' })
}
// 如果提供了 ID,按 ID 解绑
if (id) {
await db
.delete(userIdentities)
.where(and(eq(userIdentities.id, id), eq(userIdentities.userId, user.id)))
} else {
// 否则解绑该 Provider 下的所有绑定
await db
.delete(userIdentities)
.where(and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, provider)))
}
return { success: true }
})
+141
View File
@@ -0,0 +1,141 @@
import jwt from 'jsonwebtoken'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
import { eq } from 'drizzle-orm'
// 用户认证缓存(永久缓存,登出或权限变更时主动失效)
export default defineEventHandler(async (event) => {
try {
const token =
getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: '未提供认证令牌'
})
}
// 验证JWT令牌
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number }
const userId = decoded.userId
// 优先从Redis缓存获取用户认证状态
if (isRedisReady()) {
const cachedUser = await executeRedisCommand(async () => {
const client = (await import('../../utils/redis')).getRedisClient()
if (!client) return null
const cacheKey = `auth:user:${userId}`
const userData = await client.get(cacheKey)
if (userData) {
console.log(`[API] 用户认证缓存命中: ${userId}`)
return JSON.parse(userData)
}
return null
})
if (cachedUser) {
// 为缓存的用户数据添加字段
const userWithDetails = {
id: cachedUser.id,
username: cachedUser.username,
name: cachedUser.name,
grade: cachedUser.grade,
class: cachedUser.class,
role: cachedUser.role,
requirePasswordChange: cachedUser.forcePasswordChange || !cachedUser.passwordChangedAt,
has2FA: cachedUser.identities?.some((id: any) => id.provider === 'totp') || false,
avatar: cachedUser.identities?.find((id: any) => id.provider === 'github')?.providerUsername
? `https://github.com/${cachedUser.identities.find((id: any) => id.provider === 'github').providerUsername}.png`
: null
}
return {
user: userWithDetails,
valid: true
}
}
}
// 缓存未命中或Redis不可用,从数据库获取用户信息
// 同时查询所有身份绑定信息
const userResult = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
id: true,
username: true,
name: true,
grade: true,
class: true,
role: true,
forcePasswordChange: true,
passwordChangedAt: true
},
with: {
identities: {
columns: {
provider: true,
providerUsername: true
}
}
}
})
const dbUser = userResult || null
if (!dbUser) {
throw createError({
statusCode: 401,
statusMessage: '用户不存在'
})
}
// 构建返回的用户对象,只包含需要的字段
const githubIdentity = dbUser.identities?.find((id: any) => id.provider === 'github')
const user = {
id: dbUser.id,
username: dbUser.username,
name: dbUser.name,
grade: dbUser.grade,
class: dbUser.class,
role: dbUser.role,
requirePasswordChange: dbUser.forcePasswordChange || !dbUser.passwordChangedAt,
has2FA: dbUser.identities?.some((id: any) => id.provider === 'totp') || false,
// 动态生成 GitHub 头像 URL
avatar: githubIdentity?.providerUsername
? `https://github.com/${githubIdentity.providerUsername}.png`
: null
}
// 将用户认证状态缓存到Redis(如果可用)- 永久缓存
if (isRedisReady()) {
await executeRedisCommand(async () => {
const client = (await import('../../utils/redis')).getRedisClient()
if (!client) return
const cacheKey = `auth:user:${userId}`
// 缓存完整的数据库用户信息,用于后续验证
await client.set(cacheKey, JSON.stringify(dbUser))
console.log(`[API] 用户认证状态已缓存: ${userId}`)
})
}
return {
user,
valid: true
}
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
throw createError({
statusCode: 401,
statusMessage: '令牌无效或已过期'
})
}
throw error
}
})
@@ -0,0 +1,17 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
export default defineEventHandler(async (event) => {
const { rpID } = getWebAuthnConfig(event)
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
allowCredentials: [],
})
setWebAuthnChallenge(event, options.challenge, 'authentication')
return options
})
@@ -0,0 +1,106 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, eq, and, userIdentities } from '~/drizzle/db'
import { createError, defineEventHandler, setCookie } from 'h3'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const challengeData = getWebAuthnChallenge(event)
if (!challengeData) {
throw createError({ statusCode: 400, message: 'Challenge 已失效' })
}
// 通过 credentialID 查找对应的用户身份
const credentialID = body.id
if (!credentialID) {
throw createError({ statusCode: 400, message: '缺少 Credential ID' })
}
const identity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.provider, 'webauthn'), eq(userIdentities.providerUserId, credentialID)),
with: { user: true }
})
if (!identity) {
throw createError({ statusCode: 400, message: '未找到该 Passkey 关联的账号' })
}
// 检查用户账号状态
if (identity.user.status !== 'active') {
throw createError({ statusCode: 403, message: '账号已被禁用或注销' })
}
// 解析存储的凭证数据
let credentialData
try {
credentialData = JSON.parse(identity.providerUsername)
} catch {
throw createError({ statusCode: 500, message: '凭证数据损坏' })
}
const { publicKey, counter } = credentialData
// 验证公钥存在
if (!publicKey) {
throw createError({ statusCode: 500, message: '凭证数据缺失 publicKey' })
}
const { rpID, origin } = getWebAuthnConfig(event)
// 构造 WebAuthn 凭证对象
const credential = {
id: credentialID,
publicKey: Buffer.from(publicKey, 'base64url'),
counter: Number(counter || 0),
transports: credentialData.transports
}
try {
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential
})
if (verification.verified) {
const newCounter = verification.authenticationInfo.newCounter
const currentCounter = Number(counter || 0)
// 防重放攻击:检查计数器是否递增
if (currentCounter > 0 && newCounter <= currentCounter) {
console.error('WebAuthn 计数器未递增:', { currentCounter, newCounter, credentialID })
throw createError({ statusCode: 400, message: '认证无效:凭证计数器未递增' })
}
credentialData.counter = newCounter
await db.update(userIdentities)
.set({ providerUsername: JSON.stringify(credentialData) })
.where(eq(userIdentities.id, identity.id))
// 签发登录 Token
const token = JWTEnhanced.generateToken(identity.user.id, identity.user.role)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
clearWebAuthnChallenge(event)
return { success: true, redirect: '/' }
} else {
throw createError({ statusCode: 400, message: '验证失败' })
}
} catch (error) {
console.error('WebAuthn 登录验证失败:', error)
const message = error instanceof Error ? error.message : '验证失败'
throw createError({ statusCode: 400, message })
}
})
@@ -0,0 +1,60 @@
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { isoUint8Array } from '@simplewebauthn/server/helpers'
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, eq, and, userIdentities } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
// 获取该用户已绑定的 WebAuthn 凭证
const existingCredentials = await db.query.userIdentities.findMany({
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'webauthn')),
columns: {
providerUserId: true,
providerUsername: true
}
})
// 防止重复注册相同凭证,使用存储的实际 transports 信息
const excludeCredentials = existingCredentials.map(cred => {
let transports: string[] | undefined
try {
if (cred.providerUsername) {
const data = JSON.parse(cred.providerUsername)
transports = data.transports
}
} catch (e) {
console.error('解析 WebAuthn 凭证数据失败:', e)
}
return {
id: cred.providerUserId,
type: 'public-key' as const,
transports
}
})
const { rpID } = getWebAuthnConfig(event)
const rpName = 'VoiceHub'
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: isoUint8Array.fromUTF8String(user.id.toString()),
userName: user.username || user.email,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
})
// 存储注册挑战到 Cookie
setWebAuthnChallenge(event, options.challenge, user.id.toString())
return options
})
@@ -0,0 +1,83 @@
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, userIdentities } from '~/drizzle/db'
const VALID_TRANSPORTS = ['internal', 'hybrid', 'usb', 'nfc', 'ble']
function sanitizeTransports(transports: unknown): string[] {
if (!Array.isArray(transports)) return []
return transports.filter((t): t is string =>
typeof t === 'string' && VALID_TRANSPORTS.includes(t)
)
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
const body = await readBody(event)
const challengeData = getWebAuthnChallenge(event)
if (!challengeData || challengeData.userId !== user.id.toString()) {
throw createError({ statusCode: 400, message: 'Challenge 已失效或不匹配' })
}
const { rpID, origin } = getWebAuthnConfig(event)
try {
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
})
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo
// 提取凭证数据
const { id: credentialID, publicKey: credentialPublicKey, counter, transports } = credential
if (!credentialID || !credentialPublicKey) {
throw new Error('注册信息缺少凭证 ID 或公钥')
}
// 转换为 Base64URL 格式存储
const credentialIDBase64 = credentialID
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64url')
if (!publicKeyBase64) {
throw new Error('生成的公钥为空,注册失败')
}
// 构造凭证数据
const credentialData = {
label: body.label || 'WebAuthn 设备',
publicKey: publicKeyBase64,
counter: Number(counter),
transports: sanitizeTransports(transports)
}
// 存储到数据库
await db.insert(userIdentities).values({
userId: user.id,
provider: 'webauthn',
providerUserId: credentialIDBase64,
providerUsername: JSON.stringify(credentialData),
createdAt: new Date()
})
clearWebAuthnChallenge(event)
return { success: true }
} else {
throw createError({ statusCode: 400, message: '验证失败' })
}
} catch (error) {
console.error('WebAuthn 验证失败:', error)
const message = error instanceof Error ? error.message : '验证失败'
throw createError({ statusCode: 400, message })
}
})
@@ -0,0 +1,51 @@
import { db, eq, and, userIdentities, sql } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
interface RenameRequestBody {
id: string
name: string
}
const body = await readBody<RenameRequestBody>(event)
const { id, name: rawName } = body
const name = typeof rawName === 'string' ? rawName.trim() : ''
if (!id || !name) {
throw createError({ statusCode: 400, message: '参数错误' })
}
if (name.length > 50) {
throw createError({ statusCode: 400, message: '设备名称过长' })
}
// 查找对应的 Passkey
const identity = await db.query.userIdentities.findFirst({
where: and(
eq(userIdentities.id, id),
eq(userIdentities.userId, user.id),
eq(userIdentities.provider, 'webauthn')
)
})
if (!identity) {
throw createError({ statusCode: 404, message: '未找到该设备' })
}
try {
// 使用 jsonb_set 原子更新 label,避免覆盖 concurrent login 产生的 counter 更新
await db.update(userIdentities)
.set({
providerUsername: sql`jsonb_set(${userIdentities.providerUsername}::jsonb, '{label}', ${JSON.stringify(name)}::jsonb)::text`
})
.where(eq(userIdentities.id, id))
return { success: true }
} catch (e) {
console.error('更新设备名称失败:', e)
throw createError({ statusCode: 500, message: '更新失败' })
}
})