118 lines
3.5 KiB
TypeScript
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()
|
|
}
|