| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| const WINDOW_MS = 15 * 60 * 1000;
|
| const LOCKOUT_MS = 15 * 60 * 1000;
|
| const FAILURE_THRESHOLD = 5;
|
|
|
| interface AttemptState {
|
| count: number;
|
| firstAttemptAt: number;
|
| lockedUntil: number | null;
|
| }
|
|
|
| const attempts: Map<string, AttemptState> = new Map();
|
|
|
| export interface GuardDecision {
|
| allowed: boolean;
|
| retryAfterSeconds?: number;
|
| }
|
|
|
| function nowMs(): number {
|
| return Date.now();
|
| }
|
|
|
| function clientKey(rawIp: string | null | undefined): string {
|
| const ip = (rawIp || "").trim();
|
| return ip || "__unknown__";
|
| }
|
|
|
| export function checkLoginGuard(
|
| rawIp: string | null | undefined,
|
| options: { enabled: boolean }
|
| ): GuardDecision {
|
| if (!options.enabled) return { allowed: true };
|
| const state = attempts.get(clientKey(rawIp));
|
| if (!state) return { allowed: true };
|
| const now = nowMs();
|
| if (state.lockedUntil && state.lockedUntil > now) {
|
| return {
|
| allowed: false,
|
| retryAfterSeconds: Math.ceil((state.lockedUntil - now) / 1000),
|
| };
|
| }
|
| return { allowed: true };
|
| }
|
|
|
| export function recordLoginFailure(
|
| rawIp: string | null | undefined,
|
| options: { enabled: boolean }
|
| ): GuardDecision {
|
| if (!options.enabled) return { allowed: true };
|
| const key = clientKey(rawIp);
|
| const now = nowMs();
|
| const existing = attempts.get(key);
|
|
|
| if (!existing || now - existing.firstAttemptAt > WINDOW_MS) {
|
| attempts.set(key, { count: 1, firstAttemptAt: now, lockedUntil: null });
|
| return { allowed: true };
|
| }
|
|
|
| const nextCount = existing.count + 1;
|
| if (nextCount >= FAILURE_THRESHOLD) {
|
| const lockedUntil = now + LOCKOUT_MS;
|
| attempts.set(key, {
|
| count: nextCount,
|
| firstAttemptAt: existing.firstAttemptAt,
|
| lockedUntil,
|
| });
|
| return { allowed: false, retryAfterSeconds: Math.ceil(LOCKOUT_MS / 1000) };
|
| }
|
|
|
| attempts.set(key, {
|
| count: nextCount,
|
| firstAttemptAt: existing.firstAttemptAt,
|
| lockedUntil: null,
|
| });
|
| return { allowed: true };
|
| }
|
|
|
| export function clearLoginAttempts(rawIp: string | null | undefined): void {
|
| attempts.delete(clientKey(rawIp));
|
| }
|
|
|
| export function resetLoginGuardForTests(): void {
|
| attempts.clear();
|
| }
|
|
|
| export const LOGIN_GUARD_TUNABLES = Object.freeze({
|
| WINDOW_MS,
|
| LOCKOUT_MS,
|
| FAILURE_THRESHOLD,
|
| });
|
|
|