Files
BYW-VoiceHub/server/services/passwordSecurityService.ts
T

116 lines
3.6 KiB
TypeScript

import { getRequestHeader } from 'h3'
import { db } from '~/drizzle/db'
import { passwordAuditLogs, passwordRateLimits } from '~/drizzle/schema'
import { lte, sql } from 'drizzle-orm'
import { getClientIP } from '~~/server/utils/ip-utils'
export const PASSWORD_AUDIT_ACTIONS = {
INITIAL_PASSWORD: 'INITIAL_PASSWORD',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
RESET_PASSWORD: 'RESET_PASSWORD'
} as const
const PASSWORD_RATE_WINDOW_SECONDS = 10 * 60
const PASSWORD_IP_LIMIT_MULTIPLIER = 20
export async function consumePasswordRateLimit(
userId: number,
ipAddress: string,
action: string,
limit: number
): Promise<{ allowed: boolean; retryAfterSeconds: number }> {
const now = new Date()
const resetAt = new Date(now.getTime() + PASSWORD_RATE_WINDOW_SECONDS * 1000)
const keys = [`user:${action}:${userId}`, `ip:${action}:${ipAddress}`]
const comparisonNow = sql.param(now, passwordRateLimits.resetAt)
const buckets = await db.transaction(async (tx) => {
const values: Array<{ count: number; resetAt: Date }> = []
for (const key of keys) {
// 使用 Node 统一生成的时间,并通过时间列编码器传入 SQL,避免数据库时区影响比较结果。
const [result] = await tx
.insert(passwordRateLimits)
.values({ key, count: 1, resetAt })
.onConflictDoUpdate({
target: passwordRateLimits.key,
set: {
count: sql<number>`CASE
WHEN ${passwordRateLimits.resetAt} <= ${comparisonNow} THEN 1
ELSE ${passwordRateLimits.count} + 1
END`,
resetAt: sql<Date>`CASE
WHEN ${passwordRateLimits.resetAt} <= ${comparisonNow} THEN excluded."resetAt"
ELSE ${passwordRateLimits.resetAt}
END`
}
})
.returning({
count: passwordRateLimits.count,
resetAt: passwordRateLimits.resetAt
})
values.push({
count: result?.count ?? 1,
resetAt: result?.resetAt ?? resetAt
})
}
// 惰性清理过期键,避免攻击者通过大量伪造 IP 使限流表无限增长。
if (Math.random() < 0.02) {
await tx.delete(passwordRateLimits).where(lte(passwordRateLimits.resetAt, now))
}
return values
})
const [userBucket, ipBucket] = buckets
const exceededBuckets = [
userBucket && userBucket.count > limit ? userBucket : null,
ipBucket && ipBucket.count > limit * PASSWORD_IP_LIMIT_MULTIPLIER ? ipBucket : null
].filter((bucket): bucket is { count: number; resetAt: Date } => !!bucket)
const retryAfterSeconds = exceededBuckets.length
? Math.max(
1,
...exceededBuckets.map((bucket) =>
Math.ceil((new Date(bucket.resetAt).getTime() - now.getTime()) / 1000)
)
)
: 0
return {
allowed: exceededBuckets.length === 0,
retryAfterSeconds
}
}
export async function recordPasswordAudit(
event: any,
userId: number,
action: string,
success: boolean,
failureReason?: string,
actorId?: number | null
): Promise<void> {
try {
await db.insert(passwordAuditLogs).values({
userId,
actorId: actorId ?? null,
action,
success,
ipAddress: getClientIP(event),
userAgent: getRequestHeader(event, 'user-agent') || null,
failureReason: failureReason || null
})
} catch (error) {
// 审计失败不应覆盖密码操作结果,但必须保留服务端告警便于补偿。
console.error('[PasswordAudit] 写入密码审计日志失败:', error)
}
}
export function getPasswordAuditContext(event: any) {
return {
ipAddress: getClientIP(event),
userAgent: getRequestHeader(event, 'user-agent') || null
}
}