login: per-source-IP failure budget (auth.py) — username rotation no longer dodges the lockout; sliding window, XFF trusted only behind the proxy (ANIWEB_TRUST_XFF); lockout logic now unit-testable (tests/test_auth.py)
This commit is contained in:
1 parent
c5c4dff851
commit
3e5c7062ab
6 files changed
+149
-133
No files matched your search
+53
-52
@@ -1,68 +1,69 @@
|
||||
"""auth.py — login throttling: per-username lockout + per-IP failure budget.
|
||||
"""auth.py — login throttling: per-username lockout + per-source-IP 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.
|
||||
Lives outside app.py so it is unit-testable without booting the app
|
||||
(tests/test_auth.py). Both buckets are in-memory timestamp lists — the app
|
||||
is single-process by design, so no store is needed; every read prunes
|
||||
timestamps older than the window (sliding, not fixed).
|
||||
|
||||
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.
|
||||
- per-username: FAILS_USER timestamps inside the window -> that account is
|
||||
locked, right or wrong password, until the newest fail ages out.
|
||||
- per-source-IP: IP_FAILS fails from one source across ANY usernames ->
|
||||
every login from that IP bounces. This is what username-rotation sweeps
|
||||
cannot step around.
|
||||
|
||||
Client IP is request.client.host. X-Forwarded-For is consulted ONLY when
|
||||
ANIWEB_TRUST_XFF=1 (run.sh sets it, because production traffic arrives via
|
||||
the local HTTPS proxy); a directly-connected client can spoof the header,
|
||||
so it is never a silent trust — without the flag client.host rules even if
|
||||
XFF is present.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import users
|
||||
FAILS_USER = 5 # prod default
|
||||
WINDOW = float(os.environ.get("ANIWEB_LOCK_WINDOW", "900")) # seconds
|
||||
IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "20")) # prod default
|
||||
TRUST_XFF = os.environ.get("ANIWEB_TRUST_XFF") == "1"
|
||||
|
||||
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)
|
||||
_user_fails: dict = {} # username -> [fail timestamps, newest last]
|
||||
_ip_fails: dict = {} # client ip -> [fail timestamps, newest last]
|
||||
|
||||
|
||||
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 "?"
|
||||
"""Source of a login attempt: socket peer, or the proxy-reported client
|
||||
when ANIWEB_TRUST_XFF=1 (see module docstring — spoofable, hence gated)."""
|
||||
if TRUST_XFF:
|
||||
first = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return request.client.host if request.client is not None else "?"
|
||||
|
||||
|
||||
def ip_fail(ip: str) -> None:
|
||||
_ip_fails[ip] = (_ip_fails.get(ip, []) + [time.time()])[-50:]
|
||||
def _fresh(ts_list, now: float):
|
||||
return [t for t in (ts_list or ()) if now - t < WINDOW]
|
||||
|
||||
|
||||
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 locked(user: str, ip: str, now: float | None = None) -> bool:
|
||||
"""True when either bucket is over budget — caller must not verify."""
|
||||
now = time.time() if now is None else now
|
||||
return (len(_fresh(_user_fails.get(user), now)) >= FAILS_USER
|
||||
or len(_fresh(_ip_fails.get(ip), now)) >= 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"
|
||||
def record(user: str, ip: str, now: float | None = None) -> None:
|
||||
"""Count one failed attempt against both buckets (pruning stale fails)."""
|
||||
now = time.time() if now is None else now
|
||||
_user_fails[user] = _fresh(_user_fails.get(user), now) + [now]
|
||||
_ip_fails[ip] = _fresh(_ip_fails.get(ip), now) + [now]
|
||||
|
||||
|
||||
def clear(user: str) -> None:
|
||||
"""A correct password resets only that username's count; the IP bucket
|
||||
keeps aging on its own (one success must not un-lock a sweep)."""
|
||||
_user_fails.pop(user, None)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Test hook: drop all buckets."""
|
||||
_user_fails.clear()
|
||||
_ip_fails.clear()
|
||||
Reference in new issue
Block a user