type HeaderSource = | Headers | Record | undefined type AuthRequestLike = { headers?: HeaderSource } type RateState = { count: number firstAttemptAt: number lockedUntil?: number } const ipAttempts = new Map() const accountFailures = new Map() const DEFAULT_IP_WINDOW_MS = 60 * 1000 const DEFAULT_IP_MAX_ATTEMPTS = 5 const DEFAULT_ACCOUNT_MAX_FAILURES = 5 const DEFAULT_ACCOUNT_LOCK_MS = 15 * 60 * 1000 function readPositiveIntEnv(name: string, fallback: number) { const value = Number(process.env[name]) return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback } function getConfig() { return { ipWindowMs: readPositiveIntEnv('AUTH_LOGIN_IP_WINDOW_MS', DEFAULT_IP_WINDOW_MS), ipMaxAttempts: readPositiveIntEnv('AUTH_LOGIN_IP_MAX_ATTEMPTS', DEFAULT_IP_MAX_ATTEMPTS), accountMaxFailures: readPositiveIntEnv( 'AUTH_LOGIN_ACCOUNT_MAX_FAILURES', DEFAULT_ACCOUNT_MAX_FAILURES ), accountLockMs: readPositiveIntEnv('AUTH_LOGIN_ACCOUNT_LOCK_MS', DEFAULT_ACCOUNT_LOCK_MS), } } function normalizeEmail(email: string) { return email.trim().toLowerCase() } function readHeader(headers: HeaderSource, name: string) { if (!headers) return '' if (typeof (headers as Headers).get === 'function') { return (headers as Headers).get(name) || '' } const record = headers as Record const lowerName = name.toLowerCase() const value = record[name] ?? record[lowerName] if (Array.isArray(value)) return value[0] || '' return value || '' } function getClientIp(request?: AuthRequestLike) { const forwardedFor = readHeader(request?.headers, 'x-forwarded-for') const realIp = readHeader(request?.headers, 'x-real-ip') const candidate = forwardedFor.split(',')[0]?.trim() || realIp.trim() return candidate || 'unknown' } function getOrCreateWindowState(store: Map, key: string, now: number, windowMs: number) { const existing = store.get(key) if (!existing || now - existing.firstAttemptAt >= windowMs) { const fresh: RateState = { count: 0, firstAttemptAt: now } store.set(key, fresh) return fresh } return existing } export function assertCanAttemptPasswordLogin(email: string, request?: AuthRequestLike) { const now = Date.now() const config = getConfig() const ipKey = getClientIp(request) const accountKey = normalizeEmail(email) const ipState = getOrCreateWindowState(ipAttempts, ipKey, now, config.ipWindowMs) if (ipState.count >= config.ipMaxAttempts) { throw new Error('登录尝试过于频繁,请稍后再试') } ipState.count += 1 const accountState = accountFailures.get(accountKey) if (accountState?.lockedUntil && accountState.lockedUntil > now) { throw new Error('该账号登录失败次数过多,请稍后再试') } if (accountState?.lockedUntil && accountState.lockedUntil <= now) { accountFailures.delete(accountKey) } } export function recordPasswordLoginFailure(email: string) { const now = Date.now() const config = getConfig() const accountKey = normalizeEmail(email) const state = getOrCreateWindowState(accountFailures, accountKey, now, config.accountLockMs) state.count += 1 if (state.count >= config.accountMaxFailures) { state.lockedUntil = now + config.accountLockMs } } export function recordPasswordLoginSuccess(email: string) { accountFailures.delete(normalizeEmail(email)) } export function resetAuthRateLimitForTests() { ipAttempts.clear() accountFailures.clear() }