90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
import bcrypt from 'bcryptjs'
|
|
import { db } from '~/drizzle/db'
|
|
import { passwordAuditLogs, users } from '~/drizzle/schema'
|
|
import { and, eq, sql } from 'drizzle-orm'
|
|
import { getBeijingTime } from '~/utils/timeUtils'
|
|
import { createApiError } from '~~/server/utils/apiError'
|
|
|
|
interface PasswordAuditContext {
|
|
action: string
|
|
ipAddress: string
|
|
userAgent: string | null
|
|
actorId?: number | null
|
|
}
|
|
|
|
interface UpdateUserPasswordOptions {
|
|
forceReset?: boolean
|
|
expectedTokenVersion?: number
|
|
auditContext?: PasswordAuditContext
|
|
}
|
|
|
|
/**
|
|
* 更新用户密码
|
|
* 包含:加密密码、更新密码修改时间
|
|
* @param userId 用户ID
|
|
* @param newPassword 新密码
|
|
* @param options 密码更新选项,可用 tokenVersion 做并发条件更新
|
|
*/
|
|
export async function updateUserPassword(
|
|
userId: number,
|
|
newPassword: string,
|
|
options: UpdateUserPasswordOptions = {}
|
|
): Promise<{ passwordChangedAt: Date; tokenVersion: number }> {
|
|
// 1. 加密新密码
|
|
const hashedNewPassword = await bcrypt.hash(newPassword, 12)
|
|
|
|
// 2. 更新数据库
|
|
const passwordChangedAt = getBeijingTime()
|
|
const updated = await db.transaction(async (tx) => {
|
|
const result = await tx
|
|
.update(users)
|
|
.set({
|
|
password: hashedNewPassword,
|
|
passwordChangedAt,
|
|
forcePasswordChange: options.forceReset ?? false,
|
|
tokenVersion: sql`${users.tokenVersion} + 1`
|
|
})
|
|
.where(
|
|
options.expectedTokenVersion === undefined
|
|
? eq(users.id, userId)
|
|
: and(eq(users.id, userId), eq(users.tokenVersion, options.expectedTokenVersion))
|
|
)
|
|
.returning({
|
|
passwordChangedAt: users.passwordChangedAt,
|
|
tokenVersion: users.tokenVersion
|
|
})
|
|
|
|
const updatedUser = result[0]
|
|
if (!updatedUser) {
|
|
if (options.expectedTokenVersion !== undefined) {
|
|
throw createApiError(409, 'AUTH_PASSWORD_STATE_CHANGED', '密码状态已变化,请重新验证后再试')
|
|
}
|
|
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
|
}
|
|
|
|
return updatedUser
|
|
})
|
|
|
|
// 3. 审计日志在事务外写入,失败不影响密码修改主流程
|
|
if (options.auditContext) {
|
|
try {
|
|
await db.insert(passwordAuditLogs).values({
|
|
userId,
|
|
actorId: options.auditContext.actorId ?? null,
|
|
action: options.auditContext.action,
|
|
success: true,
|
|
ipAddress: options.auditContext.ipAddress,
|
|
userAgent: options.auditContext.userAgent,
|
|
failureReason: null
|
|
})
|
|
} catch (error) {
|
|
console.error('[PasswordAudit] 写入密码审计日志失败:', error)
|
|
}
|
|
}
|
|
|
|
return {
|
|
passwordChangedAt: updated.passwordChangedAt || passwordChangedAt,
|
|
tokenVersion: updated.tokenVersion
|
|
}
|
|
}
|