Files
iistwin/server/utils/login-attempts.ts
Ильяс Султанов 5750a3106f security(auth): anti-enumeration, returnTo-валидация, лимит попыток логина
Шаг 2.1 плана production-готовности:
- единый 401 при любом отказе логина + constant-time bcrypt
- forgot-password: идентичный ответ независимо от существования email
- sanitizeReturnTo (open redirect fix в /api/documents/generate-link)
- 10 неудачных попыток → блок 15 мин (in-memory, аудит)
- DATABASE_SSL_REJECT_UNAUTHORIZED env (дефолт true)
- доделка 0.7: статичные тексты в llm-providers/rag/finance-di2
- 8 новых тестов (104/104)
2026-09-08 00:46:57 +03:00

93 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, AttemptEntry>();
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();
}