From 76c45a8f29d45b5f242097cbb0d5934cc9cbf849 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 19 Aug 2026 15:54:50 +0000 Subject: [PATCH] single-user login screen - server/credentials.py: PBKDF2 password + session secret, 0600 json, one-time setup command (server refuses to start without it) - app.py: signed 30-day cookie (itsdangerous), middleware 401s API / redirects pages to /login?next=, 5-fail/15-min lockout, /logout - static/login.html: dark-themed form, inline error display - app.js: api() 401 -> redirect to /login preserving current URL - docs: README quick start + features, SPEC auth section --- README.md | 7 ++++- SPEC.md | 12 ++++++-- requirements.txt | 1 + server/app.py | 64 +++++++++++++++++++++++++++++++++++++++++-- server/credentials.py | 55 +++++++++++++++++++++++++++++++++++++ static/app.js | 4 +++ static/login.html | 44 +++++++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 server/credentials.py create mode 100644 static/login.html diff --git a/README.md b/README.md index b07f567..c556eee 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,19 @@ workflow: search, browse, and stream anime in the browser via anidb.app. ```sh uv venv .venv uv pip install --python .venv/bin/python -r requirements.txt +.venv/bin/python -m server.credentials # set the login password (one-time) ./run.sh ``` Open . `run.sh` restarts cleanly if the server -is already running; logs go to `server.log`. +is already running; logs go to `server.log`. The server refuses to +start without a password. ## Features +- Login: single shared password, 30-day session cookie, + 5 wrong attempts → 15-min lockout. PBKDF2-SHA256 hash + session + secret in `server/data/credentials.json` (0600). - Search with poster grid, detail view with synopsis + season tabs - HLS playback (vendored hls.js), browser ABR across 1080/720/360p - Dub/sub toggle diff --git a/SPEC.md b/SPEC.md index ac7d1b3..12a830a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -44,7 +44,7 @@ Two moving parts, no database, no framework on the frontend. ## Backend `server/app.py` + `server/upstream.py`. Python 3.12, deps: -`fastapi`, `uvicorn`, `curl_cffi`. +`fastapi`, `uvicorn`, `curl_cffi`, `itsdangerous`. - `GET /api/search?q=` → `[{id, slug_id, name, poster}]` (parse step 1) - `GET /api/anime/{slug_id}` → `{id, name, synopsis, mal_id, seasons:[{slug_id,name,id}]}` @@ -58,6 +58,12 @@ Two moving parts, no database, no framework on the frontend. upstream URL. Rewrites every non-comment line of m3u8 bodies to point back at `/stream/`; proxies master, variants, and segments (bytes, no transcoding). +- `GET /login` / `POST /login` / `POST /logout` → single-user auth: + PBKDF2-SHA256 password (`server/credentials.py`; hash + session + secret in `server/data/credentials.json`, mode 0600), signed 30-day + cookie, 5-fail/15-min in-memory lockout. Everything else 401s + (API) or redirects to `/login?next=` (pages). Server refuses to + start without credentials. ## Frontend @@ -89,7 +95,9 @@ Open `http://localhost:8321` (bound to 0.0.0.0 for LAN access). ## Out of scope (v1) Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip, -ani-schedule integration, auth, multi-user, episode titles (not in +ani-schedule integration, multi-user accounts (single shared +password today — per-user history is the natural upgrade), episode +titles (not in anidb's API — needs a MyAnimeList API key). Add when someone asks. ## Risks diff --git a/requirements.txt b/requirements.txt index 87672ba..203c5de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ fastapi uvicorn curl_cffi +itsdangerous diff --git a/server/app.py b/server/app.py index c6067e9..0edbf33 100644 --- a/server/app.py +++ b/server/app.py @@ -8,17 +8,75 @@ import base64 import json import time from pathlib import Path -from urllib.parse import urljoin +from urllib.parse import quote, urljoin from fastapi import Body, FastAPI, HTTPException, Query -from fastapi.responses import Response +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles +from itsdangerous import BadSignature, TimestampSigner -from . import upstream +from . import credentials, upstream 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 +_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} + + +def _auth_ok(request) -> bool: + try: + return _SIGNER.unsign(request.cookies.get(_SESSION, ""), + max_age=_SESSION_AGE) == b"ok" + except BadSignature: + return False + + +@app.middleware("http") +async def require_auth(request, call_next): + if (request.url.path, request.method) in _PUBLIC or _auth_ok(request): + 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) + + +@app.get("/login", include_in_schema=False) +def login_page(): + return FileResponse(ROOT / "static" / "login.html") + + +@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") + 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) + + +@app.post("/logout") +def logout(): + resp = JSONResponse({"ok": True}) + resp.delete_cookie(_SESSION) + return resp + + HIST_FILE = Path(__file__).parent / "data" / "history.json" diff --git a/server/credentials.py b/server/credentials.py new file mode 100644 index 0000000..b06de3f --- /dev/null +++ b/server/credentials.py @@ -0,0 +1,55 @@ +"""credentials.py — single-user password + session secret for ani-web. + +Run once: .venv/bin/python -m server.credentials +Stores a PBKDF2 password hash and a random session-signing secret in +server/data/credentials.json (mode 0600). No env vars, no DB. +""" +import getpass +import hashlib +import json +import os +import secrets +from pathlib import Path + +FILE = Path(__file__).parent / "data" / "credentials.json" +ITER = 210_000 # OWASP 2023 minimum for PBKDF2-SHA256 + + +def set_password(): + pw = getpass.getpass("Choose a password (min 8 chars): ") + if len(pw) < 8: + raise SystemExit("password too short") + if pw != getpass.getpass("Again: "): + raise SystemExit("passwords did not match") + salt = secrets.token_bytes(16) + data = { + "v": 1, + "salt": salt.hex(), + "hash": hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, ITER).hex(), + "secret": secrets.token_hex(32), + } + FILE.parent.mkdir(exist_ok=True) + # ponytail: mode 0600 on create; won't tighten an existing file + fd = os.open(FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(data, f) + print(f"credentials saved to {FILE}") + + +def load(): + if not FILE.exists(): + raise SystemExit( + "no credentials yet — set a password first:\n" + " .venv/bin/python -m server.credentials") + return json.loads(FILE.read_text()) + + +def check(password: str) -> bool: + d = load() + h = hashlib.pbkdf2_hmac("sha256", password.encode(), + bytes.fromhex(d["salt"]), d.get("iter", ITER)) + return secrets.compare_digest(h.hex(), d["hash"]) + + +if __name__ == "__main__": + set_password() diff --git a/static/app.js b/static/app.js index e9290d8..253b6aa 100644 --- a/static/app.js +++ b/static/app.js @@ -4,6 +4,10 @@ let hls = null, detail = null, lang = "eng", seasonId = null; async function api(path) { const r = await fetch(path); + if (r.status === 401) { + location.href = "/login?next=" + encodeURIComponent(location.pathname + location.search); + throw new Error("not authenticated"); + } if (!r.ok) { let msg = r.statusText; try { msg = (await r.json()).detail || msg; } catch {} diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..1902b95 --- /dev/null +++ b/static/login.html @@ -0,0 +1,44 @@ + + + + + +ani-web — log in + + + +
+

ani-web

+ + +

+
+ + +