multi-user auth (CLI-created accounts), per-user history, test suite

- server/users.py replaces credentials.py: users.json (0600), PBKDF2,
  one-time migration of the legacy single password to user admin
  (history.json -> history/admin.json)
- accounts are CLI-managed only: python -m server.users add|rm|list
  (no self-signup on an internet-exposed app); /signup route removed,
  login page back to username + password
- per-user 30-day signed session cookie (payload carries the
  username), per-user lockout, GET /api/me, POST /account/delete
- per-user watch history: server/data/history/<user>.json; ANIWEB_DATA
  env override for the data dir
- header chip: logged-in user, logout, delete account
- tests/: throwaway-server suite (port 8399, temp data dir) — 9 HTTP
  tests + 2 Playwright browser tests (login UI, real video playback
  behind auth); runs via python tests/run.py
This commit is contained in:
jozamudi committed 2026-08-19 16:17:56 +00:00
1 parent 76c45a8f29
commit c697500c7d
12 files changed
+595 -122

No files matched your search

+76 -39
View File
@@ -6,46 +6,71 @@ Cloudflare workaround) and rewrites playlist URIs to go through itself.
"""
import base64
import json
import os
import time
from pathlib import Path
from urllib.parse import quote, urljoin
from fastapi import Body, FastAPI, HTTPException, Query
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner
from . import credentials, upstream
from . import upstream, users
ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web")
# ---------- auth: single user, signed cookie ----------
_SIGNER = TimestampSigner(credentials.load()["secret"]) # exits with instructions if unset
# ---------- auth: multi-user, signed cookies ----------
users.migrate_legacy()
_SIGNER = TimestampSigner(users.get_secret())
_SESSION = "aniweb_session"
_SESSION_AGE = 30 * 86400
_PUBLIC = {("/login", "GET"), ("/login", "POST")}
# ponytail: in-memory lockout, single process — 5 fails lock for 15 min
_lockout = {"fails": 0, "until": 0.0}
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
_lockout: dict = {}
def _auth_ok(request) -> bool:
def _session_user(request) -> str | None:
try:
return _SIGNER.unsign(request.cookies.get(_SESSION, ""),
max_age=_SESSION_AGE) == b"ok"
payload = _SIGNER.unsign(request.cookies.get(_SESSION, ""), max_age=_SESSION_AGE)
except BadSignature:
return False
return 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"
@app.middleware("http")
async def require_auth(request, call_next):
if (request.url.path, request.method) in _PUBLIC or _auth_ok(request):
if (request.url.path, request.method) in _PUBLIC:
return await call_next(request)
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "not authenticated"}, status_code=401)
next_ = request.url.path + (f"?{request.url.query}" if request.url.query else "")
return RedirectResponse(f"/login?next={quote(next_, safe='')}", status_code=302)
user = _session_user(request)
if not user:
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "not authenticated"}, status_code=401)
next_ = request.url.path + (f"?{request.url.query}" if request.url.query else "")
return RedirectResponse(f"/login?next={quote(next_, safe='')}", status_code=302)
request.state.user = user
return await call_next(request)
def _session_cookie(resp, user: str):
resp.set_cookie(_SESSION, _SIGNER.sign(b"user:" + user.encode()).decode(),
max_age=_SESSION_AGE, httponly=True, samesite="lax")
@app.get("/login", include_in_schema=False)
@@ -55,19 +80,30 @@ def login_page():
@app.post("/login")
async def login(payload: dict = Body(...)):
if time.time() < _lockout["until"]:
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
if credentials.check(payload.get("password", "")):
_lockout.update(fails=0, until=0.0)
resp = JSONResponse({"ok": True})
resp.set_cookie(_SESSION, _SIGNER.sign(b"ok").decode(),
max_age=_SESSION_AGE, httponly=True, samesite="lax")
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":
resp = JSONResponse({"ok": True, "user": u})
_session_cookie(resp, u)
return resp
_lockout["fails"] += 1
if _lockout["fails"] >= 5:
_lockout.update(fails=0, until=time.time() + 900)
time.sleep(0.5) # slow down brute force
return JSONResponse({"detail": "wrong password"}, status_code=401)
if state == "locked":
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
return JSONResponse({"detail": "wrong username or password"}, status_code=401)
@app.get("/api/me", include_in_schema=False)
def api_me(request: Request):
return {"user": request.state.user}
@app.post("/account/delete")
def delete_account(request: Request):
users.remove(request.state.user)
resp = JSONResponse({"ok": True})
resp.delete_cookie(_SESSION)
return resp
@app.post("/logout")
@@ -77,40 +113,41 @@ def logout():
return resp
HIST_FILE = Path(__file__).parent / "data" / "history.json"
HIST_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "history"
def _load_hist():
def _load_hist(user: str):
try:
return json.loads(HIST_FILE.read_text())
return json.loads((HIST_DIR / f"{user}.json").read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_hist(entries):
HIST_FILE.parent.mkdir(exist_ok=True)
# ponytail: unguarded read-modify-write, fine for a single-user tool
HIST_FILE.write_text(json.dumps(entries, indent=1))
def _save_hist(user: str, entries):
HIST_DIR.mkdir(exist_ok=True)
# ponytail: unguarded read-modify-write per file, fine at this scale
(HIST_DIR / f"{user}.json").write_text(json.dumps(entries, indent=1))
@app.get("/api/history")
def api_history():
return sorted(_load_hist(), key=lambda e: e.get("t", 0), reverse=True)[:5]
def api_history(request: Request):
return sorted(_load_hist(request.state.user), key=lambda e: e.get("t", 0), reverse=True)[:5]
@app.post("/api/history")
def api_history_post(entry: dict = Body(...)):
def api_history_post(entry: dict = Body(...), request: Request = None):
if "ep_id" not in entry:
raise HTTPException(422, "ep_id required")
entry["t"] = time.time()
entries = _load_hist()
u = request.state.user
entries = _load_hist(u)
for i, e in enumerate(entries):
if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"):
entries[i] = {**e, **entry}
break
else:
entries.append(entry)
_save_hist(sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
_save_hist(u, sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
return entry