flnxi-Wu/lib/auth-rate-limit.ts
2026-06-02 10:16:28 +08:00

118 lines
3.5 KiB
TypeScript

type HeaderSource =
| Headers
| Record<string, string | string[] | undefined>
| undefined
type AuthRequestLike = {
headers?: HeaderSource
}
type RateState = {
count: number
firstAttemptAt: number
lockedUntil?: number
}
const ipAttempts = new Map<string, RateState>()
const accountFailures = new Map<string, RateState>()
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<string, string | string[] | undefined>
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<string, RateState>, 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()
}