import { randomInt, timingSafeEqual } from 'node:crypto' import { getServerTimestamp } from '~~/server/utils/serverTime' import { getRedisClient, buildRedisKey } from '~~/server/utils/redis' const EMAIL_CODE_TTL_MS = 5 * 60 * 1000 const EMAIL_CODE_COOLDOWN_MS = 60 * 1000 const EMAIL_CODE_MAX_ATTEMPTS = 5 const EMAIL_CODE_DAILY_LIMIT = 5 const EMAIL_CODE_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000 type EmailCodeEntry = { code: string expiresAt: number createdAt: number attempts: number } const emailCodes = new Map() const dailySendCounts = new Map() const normalizeEmail = (email: string) => email.trim().toLowerCase() const getDayKey = () => { const now = new Date(getServerTimestamp()) return `${now.getUTCFullYear()}-${now.getUTCMonth() + 1}-${now.getUTCDate()}` } const codeRedisKey = (email: string) => buildRedisKey('email-code', 'code', normalizeEmail(email)) const attemptsRedisKey = (email: string) => buildRedisKey('email-code', 'attempts', normalizeEmail(email)) const cooldownRedisKey = (email: string) => buildRedisKey('email-code', 'cooldown', normalizeEmail(email)) const dailyRedisKey = (email: string) => buildRedisKey('email-code', 'daily', normalizeEmail(email), getDayKey()) const VERIFY_EMAIL_CODE_SCRIPT = ` local stored = redis.call('GET', KEYS[1]) if not stored then return { 0, 0 } end local attempts = redis.call('INCR', KEYS[2]) if attempts == 1 then redis.call('EXPIRE', KEYS[2], 300) end if ARGV[1] == stored then redis.call('DEL', KEYS[1], KEYS[2]) return { 1, attempts } end if attempts >= tonumber(ARGV[2]) then redis.call('DEL', KEYS[1], KEYS[2]) return { 2, attempts } end return { 0, attempts } ` const safeCodeEqual = (a: string, b: string): boolean => { if (a.length !== b.length) return false return timingSafeEqual(Buffer.from(a), Buffer.from(b)) } export function generateEmailCode(): string { return String(randomInt(100000, 1000000)) } export async function storeEmailCode(email: string, code: string): Promise { const normalized = normalizeEmail(email) const now = getServerTimestamp() emailCodes.set(normalized, { code, expiresAt: now + EMAIL_CODE_TTL_MS, createdAt: now, attempts: 0 }) const dayKey = getDayKey() const record = dailySendCounts.get(normalized) if (!record || record.dayKey !== dayKey) { dailySendCounts.set(normalized, { count: 1, dayKey }) } else { record.count++ } let client: Awaited> | null = null try { client = getRedisClient() if (!client) return const ttlSeconds = Math.ceil(EMAIL_CODE_TTL_MS / 1000) await client.set(codeRedisKey(email), code, { EX: ttlSeconds }) await client.set(cooldownRedisKey(email), '1', { EX: Math.ceil(EMAIL_CODE_COOLDOWN_MS / 1000) }) const dailyKey = dailyRedisKey(email) const dailyCount = await client.incr(dailyKey) if (dailyCount === 1) { await client.expire(dailyKey, Math.ceil(EMAIL_CODE_DAILY_WINDOW_MS / 1000)) } } catch (error) { console.error('[EmailCode] Redis 写入失败,已回退内存存储:', error) } } export async function isEmailCodeCooldownActive(email: string): Promise { let client: Awaited> | null = null try { client = getRedisClient() if (client) { return Boolean(await client.exists(cooldownRedisKey(email))) } } catch (error) { console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error) } const entry = emailCodes.get(normalizeEmail(email)) return Boolean(entry && getServerTimestamp() - entry.createdAt < EMAIL_CODE_COOLDOWN_MS) } export async function isEmailCodeDailyLimitReached(email: string): Promise { let client: Awaited> | null = null try { client = getRedisClient() if (client) { const count = await client.get(dailyRedisKey(email)) return Number(count || 0) >= EMAIL_CODE_DAILY_LIMIT } } catch (error) { console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error) } const normalized = normalizeEmail(email) const dayKey = getDayKey() const record = dailySendCounts.get(normalized) return Boolean(record && record.dayKey === dayKey && record.count >= EMAIL_CODE_DAILY_LIMIT) } export async function verifyEmailCode(email: string, code: string): Promise { const trimmedCode = code.trim() let client try { client = getRedisClient() if (client) { const result = await client.eval(VERIFY_EMAIL_CODE_SCRIPT, { keys: [codeRedisKey(email), attemptsRedisKey(email)], arguments: [trimmedCode, String(EMAIL_CODE_MAX_ATTEMPTS)] }) if (Array.isArray(result)) { return Number(result[0]) === 1 } } } catch (error) { console.error('[EmailCode] Redis 校验失败,已回退内存校验:', error) } const normalized = normalizeEmail(email) const entry = emailCodes.get(normalized) if (!entry) return false if (getServerTimestamp() > entry.expiresAt) { emailCodes.delete(normalized) return false } if (safeCodeEqual(entry.code, trimmedCode)) { emailCodes.delete(normalized) return true } entry.attempts++ if (entry.attempts >= EMAIL_CODE_MAX_ATTEMPTS) { emailCodes.delete(normalized) } return false } export function cleanupExpiredEmailCodes(): void { const now = getServerTimestamp() for (const [email, entry] of emailCodes) { if (now > entry.expiresAt) emailCodes.delete(email) } }