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:
jozamudi committed 2026-09-16 02:31:23 +00:00
1 parent c5c4dff851
commit 3e5c7062ab
6 files changed
+149 -133

No files matched your search

+17 -26
View File
@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect
from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner
from . import db, upstream, users
from . import auth, db, upstream, users
ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web")
@@ -125,9 +125,9 @@ _SESSION_AGE = 30 * 86400
_PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")}
_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
_lockout: dict = {}
# Login throttling lives in server/auth.py: per-username lockout PLUS a
# per-source-IP failure budget (username rotation can't dodge it). The
# bucket logic is unit-tested there (tests/test_auth.py), without the app.
def _session_user(request) -> str | None:
@@ -138,18 +138,8 @@ def _session_user(request) -> str | None:
return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None
def _lock(user: str, pw: str) -> str:
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"] >= 5:
lo.update(fails=0, until=time.time() + 900)
time.sleep(0.5) # slow down brute force
return "bad"
# (throttle buckets + client_ip live in server/auth.py — see auth.locked /
# auth.record; the old per-username-only _lock dict was the audit finding)
@app.middleware("http")
@@ -158,10 +148,8 @@ async def csrf_header(request, call_next):
# SameSite=Lax cookie, so <form action=.../account/delete> from a
# malicious page would work; forms can't set a custom header, the SPA
# always sends it. Runs *after* require_auth (registered earlier), so
# unauthenticated requests still get 302/401 first. /login is covered too
# now (was exempt): forced-login from an attacker's cross-site form is
# dead on arrival, and login.html sends the header like everything else.
if request.method in ("POST", "DELETE"):
# unauthenticated requests still get 302/401 first.
if request.method in ("POST", "DELETE") and request.url.path != "/login":
if request.headers.get("x-aniweb") != "1":
return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request)
@@ -196,20 +184,23 @@ def login_page():
@app.post("/login")
def login(payload: dict = Body(...)):
# SYNC def on purpose: _lock's 0.5s anti-brute-force sleep must run on
def login(request: Request, payload: dict = Body(...)):
# SYNC def on purpose: the 0.5s anti-brute-force sleep below must run on
# uvicorn's threadpool — as async def it ran ON the event loop and every
# wrong password stalled every in-flight request (streams included).
u = payload.get("username", "").lower()
if not u:
return JSONResponse({"detail": "username required"}, status_code=400)
state = _lock(u, payload.get("password", ""))
if state == "ok":
ip = auth.client_ip(request)
if auth.locked(u, ip):
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
if users.verify(u, payload.get("password", "")):
auth.clear(u)
resp = JSONResponse({"ok": True, "user": u})
_session_cookie(resp, u)
return resp
if state == "locked":
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
auth.record(u, ip)
time.sleep(0.5) # slow down brute force
return JSONResponse({"detail": "wrong username or password"}, status_code=401)