"""auth.py — login throttling: per-username lockout + per-IP failure budget. Pure logic, no app imports, so it unit-tests without booting the app (tests/test_auth.py). The 0.5s wrong-password sleep is exactly why app.py's login handler must stay a sync def: on the event loop it would stall every stream in flight; on the threadpool it only holds that one request. Two in-memory layers (single process — same assumption as everything else): * per-username: 5 fails -> 15 min lockout for that name (was: the only layer, so spraying fails across many names bypassed it entirely) * per-IP: IP_FAILS fails inside IP_WINDOW -> that IP is refused for the rest of the window. Defaults are deliberately loose (15/300s): they exist to stop username spraying, not to punish typos. """ import os import time from . import users LOCK_FAILS = 5 LOCK_SECONDS = 900 IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "15")) IP_WINDOW = float(os.environ.get("ANIWEB_IP_WINDOW", "300")) _lockout: dict = {} # user -> {"fails": int, "until": float} _ip_fails: dict = {} # ip -> [failure ts] (newest last, capped) def client_ip(request) -> str: """Client IP. X-Forwarded-For is trusted ONLY when ANIWEB_TRUST_XFF=1 (run.sh sets it because production is always behind the local HTTPS proxy); unconditional trust would let any client forge the header and dodge the per-IP budget.""" if os.environ.get("ANIWEB_TRUST_XFF") == "1": xff = request.headers.get("x-forwarded-for", "") if xff: return xff.split(",", 1)[0].strip() return request.client.host if request.client else "?" def ip_fail(ip: str) -> None: _ip_fails[ip] = (_ip_fails.get(ip, []) + [time.time()])[-50:] def ip_locked(ip: str) -> bool: now = time.time() ts = [t for t in _ip_fails.get(ip, ()) if now - t < IP_WINDOW] if ts: _ip_fails[ip] = ts return len(ts) >= IP_FAILS def lock(user: str, pw: str, ip: str) -> str: """-> "ok" | "bad" | "locked" (per-username lockout or per-IP budget)""" if ip_locked(ip): return "locked" lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0}) if time.time() < lo["until"]: return "locked" if users.verify(user, pw): lo.update(fails=0, until=0.0) return "ok" lo["fails"] += 1 if lo["fails"] >= LOCK_FAILS: lo.update(fails=0, until=time.time() + LOCK_SECONDS) time.sleep(0.5) # slow down brute force (sync handler -> threadpool) ip_fail(ip) return "bad"