// In-memory счётчик неудачных попыток входа по email (anti brute-force // per-account). После MAX_FAILED_ATTEMPTS подряд — временная блокировка // попыток на LOCK_DURATION_MS. При успешном входе счётчик сбрасывается. // Блокировка по email (а не IP), чтобы коллеги за одним офисным NAT // не блокировали друг друга; перечисление аккаунтов не раскрывается, // т.к. лимит применяется к любому email независимо от его существования. // ОГОВОРКА: при multi-instance деплое счётчики не общие между процессами // (как и остальные in-memory кэши) — для общего лимита нужен Redis/БД. export const MAX_FAILED_ATTEMPTS = 10; export const LOCK_DURATION_MS = 15 * 60 * 1000; const MAX_ENTRIES = 10_000; interface AttemptEntry { count: number; lockedUntil: number; /** Момент, после которого запись можно удалить (для очистки Map). */ expiresAt: number; } const attempts = new Map(); function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function getFreshEntry(email: string): AttemptEntry | undefined { const entry = attempts.get(email); if (!entry) return undefined; const now = Date.now(); if (now > entry.expiresAt) { attempts.delete(email); return undefined; } return entry; } /** Оставшееся время блокировки в мс (0 — не заблокирован). */ export function getLoginLockRemainingMs(email: string): number { const entry = getFreshEntry(normalizeEmail(email)); if (!entry) return 0; return Math.max(0, entry.lockedUntil - Date.now()); } export interface FailedAttemptResult { /** Аккаунт заблокирован (либо уже был, либо блокировка сработала на этой попытке). */ locked: boolean; /** Блокировка сработала именно на этой попытке — повод записать аудит. */ justLocked: boolean; remainingMs: number; } export function recordFailedLoginAttempt(email: string): FailedAttemptResult { const key = normalizeEmail(email); const now = Date.now(); const existing = getFreshEntry(key); if (existing && existing.lockedUntil > now) { // Продлеваем жизнь записи, чтобы блокировка не «протухла» раньше времени. existing.expiresAt = existing.lockedUntil; return { locked: true, justLocked: false, remainingMs: existing.lockedUntil - now }; } const count = (existing?.count ?? 0) + 1; const locked = count >= MAX_FAILED_ATTEMPTS; const entry: AttemptEntry = { count: locked ? 0 : count, lockedUntil: locked ? now + LOCK_DURATION_MS : 0, expiresAt: now + LOCK_DURATION_MS, }; if (!attempts.has(key) && attempts.size >= MAX_ENTRIES) { // Вытесняем самую старую запись (Map хранит порядок вставки). const oldestKey = attempts.keys().next().value; if (oldestKey !== undefined) attempts.delete(oldestKey); } attempts.delete(key); attempts.set(key, entry); return { locked, justLocked: locked, remainingMs: locked ? LOCK_DURATION_MS : 0 }; } /** Сброс счётчика при успешном входе. */ export function resetLoginAttempts(email: string): void { attempts.delete(normalizeEmail(email)); } /** Полная очистка (для тестов). */ export function clearLoginAttempts(): void { attempts.clear(); }