""" login_limiter.py — Two-tier brute-force protection for the login endpoint. Tier 1: 5 failures within 30 minutes → 30-minute lockout. Tier 2: Same IP gets locked out again within 24 hours → permanent lockout (requires admin action to unlock via Settings → Security). All timestamps are unix wall-clock (time.time()) so they can be shown in the UI. State is in-process memory; it resets on server restart. """ from __future__ import annotations import logging import time from typing import Any logger = logging.getLogger(__name__) # ── Config ──────────────────────────────────────────────────────────────────── MAX_ATTEMPTS = 5 # failures before tier-1 lockout ATTEMPT_WINDOW = 1800 # 30 min — window in which failures are counted LOCKOUT_DURATION = 1800 # 30 min — tier-1 lockout duration RECURRENCE_WINDOW = 86400 # 24 h — if locked again within this period → tier-2 # ── State ───────────────────────────────────────────────────────────────────── # Per-IP entry shape: # failures: [unix_ts, ...] recent failed attempts (pruned to ATTEMPT_WINDOW) # locked_until: float | None unix_ts when tier-1 lockout expires # permanent: bool tier-2: admin must unlock # lockouts_24h: [unix_ts, ...] when tier-1 lockouts were applied (pruned to 24 h) # locked_at: float | None when the current lockout started (for display) _STATE: dict[str, dict[str, Any]] = {} def _entry(ip: str) -> dict[str, Any]: if ip not in _STATE: _STATE[ip] = { "failures": [], "locked_until": None, "permanent": False, "lockouts_24h": [], "locked_at": None, } return _STATE[ip] # ── Public API ──────────────────────────────────────────────────────────────── def is_locked(ip: str) -> tuple[bool, str]: """Return (locked, kind) where kind is 'permanent', 'temporary', or ''.""" e = _entry(ip) if e["permanent"]: return True, "permanent" if e["locked_until"] and time.time() < e["locked_until"]: return True, "temporary" return False, "" def record_failure(ip: str) -> None: """Record a failed login attempt; apply lockout if threshold is reached.""" e = _entry(ip) now = time.time() e["failures"].append(now) # Prune to the counting window cutoff = now - ATTEMPT_WINDOW e["failures"] = [t for t in e["failures"] if t > cutoff] if len(e["failures"]) < MAX_ATTEMPTS: return # threshold not reached yet # Threshold reached — determine tier cutoff_24h = now - RECURRENCE_WINDOW e["lockouts_24h"] = [t for t in e["lockouts_24h"] if t > cutoff_24h] if e["lockouts_24h"]: # Already locked before in the last 24 h → permanent e["permanent"] = True e["locked_until"] = None e["locked_at"] = now logger.warning("[login_limiter] %s permanently locked (repeat offender within 24 h)", ip) else: # First offence → 30-minute lockout e["locked_until"] = now + LOCKOUT_DURATION e["lockouts_24h"].append(now) e["locked_at"] = now logger.warning("[login_limiter] %s locked for 30 minutes", ip) e["failures"] = [] # reset after triggering lockout def clear_failures(ip: str) -> None: """Called on successful login — clears the failure counter for this IP.""" if ip in _STATE: _STATE[ip]["failures"] = [] def unlock(ip: str) -> bool: """Admin action: fully reset lockout state for an IP. Returns False if unknown.""" if ip not in _STATE: return False _STATE[ip].update(permanent=False, locked_until=None, locked_at=None, failures=[], lockouts_24h=[]) logger.info("[login_limiter] %s unlocked by admin", ip) return True def unlock_all() -> int: """Admin action: unlock every locked IP. Returns count unlocked.""" count = 0 for ip, e in _STATE.items(): if e["permanent"] or (e["locked_until"] and time.time() < e["locked_until"]): e.update(permanent=False, locked_until=None, locked_at=None, failures=[], lockouts_24h=[]) count += 1 return count def list_locked() -> list[dict]: """Return info dicts for all currently locked IPs (for the admin UI).""" now = time.time() result = [] for ip, e in _STATE.items(): if e["permanent"]: result.append({ "ip": ip, "type": "permanent", "locked_at": e["locked_at"], "locked_until": None, }) elif e["locked_until"] and now < e["locked_until"]: result.append({ "ip": ip, "type": "temporary", "locked_at": e["locked_at"], "locked_until": e["locked_until"], }) return result