Files
BYW-VoiceHub/server/api/auth/reset-password.post.ts
T

107 lines
3.9 KiB
TypeScript

import crypto from 'node:crypto'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { updateUserPassword } from '~~/server/services/userService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { createApiError } from '~~/server/utils/apiError'
import { getPasswordPolicyViolation } from '~/utils/password-policy'
import {
PASSWORD_AUDIT_ACTIONS,
getPasswordAuditContext,
recordPasswordAudit
} from '~~/server/services/passwordSecurityService'
export default defineEventHandler(async (event) => {
const clientIP = getClientIP(event)
// IP 级别限流:每小时最多 10 次密码重置尝试
const rateLimitKey = `reset_password_ip:${clientIP}`
const limitResult = await checkDistributedRateLimit(rateLimitKey, 10, 60 * 60 * 1000)
if (!limitResult.isAllowed) {
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
throw createApiError(
429,
'AUTH_RESET_PASSWORD_TOO_MANY',
`重置密码尝试次数过多,请等待 ${waitMinutes} 分钟后再试`,
{ params: [waitMinutes] }
)
}
const body = await readBody<Record<string, unknown> | null>(event)
const token = typeof body?.token === 'string' ? body.token : ''
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
if (!token || !newPassword) {
throw createApiError(400, 'AUTH_INCOMPLETE_PARAMS', '参数不完整')
}
const policyViolation = getPasswordPolicyViolation(newPassword)
if (policyViolation) {
throw createApiError(400, policyViolation.code, policyViolation.message)
}
let auditUserId: number | null = null
let passwordUpdated = false
try {
// 验证并解码token
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'password_reset' || !decoded.userId || !decoded.hash) {
throw createApiError(400, 'AUTH_INVALID_RESET_LINK', '无效的重置链接')
}
// 获取最新用户信息
const userResult = await db.select().from(users).where(eq(users.id, decoded.userId)).limit(1)
const user = userResult[0]
if (!user) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
auditUserId = user.id
// 验证 hash 是否匹配当前密码的前10位
// 如果用户已经修改过密码,则 user.password 发生变化,旧 token 失效
// 使用恒定时间比较,避免通过时序侧信道泄露密码是否已被修改
const expectedHash = Buffer.from(user.password.substring(0, 10), 'utf8')
const providedHash = Buffer.from(typeof decoded.hash === 'string' ? decoded.hash : '', 'utf8')
if (
expectedHash.length !== providedHash.length ||
!crypto.timingSafeEqual(expectedHash, providedHash)
) {
throw createApiError(400, 'AUTH_RESET_LINK_INVALIDATED', '该重置链接已失效(密码已被修改)')
}
// 更新密码
await updateUserPassword(user.id, newPassword, {
expectedTokenVersion: user.tokenVersion,
auditContext: {
action: PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
...getPasswordAuditContext(event)
}
})
passwordUpdated = true
return { success: true, message: '密码重置成功,请使用新密码登录' }
} catch (error: any) {
if (auditUserId && !passwordUpdated) {
await recordPasswordAudit(
event,
auditUserId,
PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
false,
error.message || '重置密码失败'
)
}
if (error.name === 'TokenExpiredError') {
throw createApiError(400, 'AUTH_RESET_LINK_EXPIRED', '重置链接已过期,请重新申请')
}
if (error.statusCode) throw error
throw createApiError(400, 'AUTH_INVALID_RESET_LINK', '无效的重置链接')
}
})