"""Per-IP auth failure tracking with exponential backoff. Tracks consecutive verify failures per IP in memory. After successive failures the IP is blocked for an increasing cooldown period. Thresholds (consecutive failures → cooldown): 5 → 30 seconds 10 → 5 minutes 20 → 15 minutes The counter resets on the first successful verify from that IP. Stale entries (no activity for 1 hour) are garbage-collected lazily. This is in-memory and process-local. For multi-worker deployments move the state to a shared cache (Redis). For single-worker (UVICORN_WORKERS=1) this is sufficient and has zero external dependencies. Why this matters even with Ed25519: - Prevents automated stuffing of the verify endpoint with fabricated challenge tokens and random signatures. - Makes enumeration of valid handles via timing measurably slower. - Adds a layer of defence that complements the global 20/min rate limit. """ import time from fastapi import HTTPException, status type _FailureEntry = tuple[int, float, float] type _FailureMap = dict[str, _FailureEntry] # (failure_count, first_failure_ts, last_failure_ts) _failures: _FailureMap = {} # Cooldown thresholds: (min_failures, cooldown_seconds) _THRESHOLDS: list[tuple[int, int]] = [ (20, 900), # 20+ failures → 15 min (10, 300), # 10+ failures → 5 min (5, 30), # 5+ failures → 30 sec ] _GC_AFTER_SECONDS = 3600 # drop entries inactive for 1 hour def _cooldown_for(count: int) -> int: for min_failures, seconds in _THRESHOLDS: if count >= min_failures: return seconds return 0 def _gc() -> None: """Remove entries that have been quiet for GC_AFTER_SECONDS.""" now = time.monotonic() stale = [ip for ip, (_, _, last) in _failures.items() if now - last > _GC_AFTER_SECONDS] for ip in stale: del _failures[ip] def check_failure_limit(ip: str) -> None: """Raise 429 if this IP is currently in a cooldown window. Call this at the top of the verify endpoint, before any DB work. """ entry = _failures.get(ip) if entry is None: return count, _, last_failure_ts = entry cooldown = _cooldown_for(count) if cooldown == 0: return elapsed = time.monotonic() - last_failure_ts remaining = cooldown - elapsed if remaining > 0: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"Too many failed auth attempts. Try again in {int(remaining) + 1}s.", headers={"Retry-After": str(int(remaining) + 1)}, ) def record_failure(ip: str) -> None: """Increment the failure counter for this IP after a failed verify.""" _gc() now = time.monotonic() if ip in _failures: count, first_ts, _ = _failures[ip] _failures[ip] = (count + 1, first_ts, now) else: _failures[ip] = (1, now, now) def record_success(ip: str) -> None: """Reset the failure counter for this IP after a successful verify.""" _failures.pop(ip, None)