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
This commit is contained in:
jozamudi committed 2026-08-19 15:54:50 +00:00
1 parent 2c203990bc
commit 76c45a8f29
7 files changed
+181 -6

No files matched your search

+6 -1
View File
@@ -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 <http://localhost:8321>. `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
+10 -2
View File
@@ -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
+1
View File
@@ -1,3 +1,4 @@
fastapi
uvicorn
curl_cffi
itsdangerous
+61 -3
View File
@@ -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"
+55
View File
@@ -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()
+4
View File
@@ -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 {}
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-web — log in</title>
<style>
:root { --bg:#0e0f13; --card:#171821; --bd:#262833; --fg:#e8e9f0; --acc:#f97316; }
body { margin:0; height:100vh; display:grid; place-items:center; background:var(--bg);
color:var(--fg); font:15px/1.5 system-ui,sans-serif; }
form { background:var(--card); border:1px solid var(--bd); border-radius:10px;
padding:2rem; width:280px; }
h1 { font-size:1.1rem; margin:0 0 1rem; color:var(--acc); }
input { width:100%; box-sizing:border-box; padding:.55rem .8rem; border-radius:8px;
border:1px solid var(--bd); background:var(--bg); color:var(--fg); }
button { width:100%; margin-top:.8rem; padding:.55rem; border:0; border-radius:8px;
background:var(--acc); color:#000; font-weight:600; cursor:pointer; }
.err { color:#f87171; font-size:.85rem; margin:.6rem 0 0; min-height:1.2em; }
</style>
</head>
<body>
<form onsubmit="return false">
<h1>ani-web</h1>
<input type="password" placeholder="password" autofocus autocomplete="current-password">
<button>log in</button>
<p class="err"></p>
</form>
<script>
const err = document.querySelector(".err");
document.querySelector("button").onclick = async () => {
err.textContent = "";
const r = await fetch("/login", {method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({password: document.querySelector("input").value})});
if (r.ok) {
const next = new URLSearchParams(location.search).get("next");
location.href = (next && next.startsWith("/") && !next.startsWith("//")) ? next : "/";
} else {
err.textContent = (await r.json().catch(() => ({}))).detail || "login failed";
}
};
</script>
</body>
</html>