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:
1 parent
2c203990bc
commit
76c45a8f29
7 files changed
+181
-6
No files matched your search
@@ -8,14 +8,19 @@ workflow: search, browse, and stream anime in the browser via anidb.app.
|
|||||||
```sh
|
```sh
|
||||||
uv venv .venv
|
uv venv .venv
|
||||||
uv pip install --python .venv/bin/python -r requirements.txt
|
uv pip install --python .venv/bin/python -r requirements.txt
|
||||||
|
.venv/bin/python -m server.credentials # set the login password (one-time)
|
||||||
./run.sh
|
./run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:8321>. `run.sh` restarts cleanly if the server
|
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
|
## 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
|
- Search with poster grid, detail view with synopsis + season tabs
|
||||||
- HLS playback (vendored hls.js), browser ABR across 1080/720/360p
|
- HLS playback (vendored hls.js), browser ABR across 1080/720/360p
|
||||||
- Dub/sub toggle
|
- Dub/sub toggle
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Two moving parts, no database, no framework on the frontend.
|
|||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
`server/app.py` + `server/upstream.py`. Python 3.12, deps:
|
`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/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}]}`
|
- `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
|
upstream URL. Rewrites every non-comment line of m3u8 bodies to
|
||||||
point back at `/stream/`; proxies master, variants, and segments
|
point back at `/stream/`; proxies master, variants, and segments
|
||||||
(bytes, no transcoding).
|
(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
|
## Frontend
|
||||||
|
|
||||||
@@ -89,7 +95,9 @@ Open `http://localhost:8321` (bound to 0.0.0.0 for LAN access).
|
|||||||
## Out of scope (v1)
|
## Out of scope (v1)
|
||||||
|
|
||||||
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
|
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.
|
anidb's API — needs a MyAnimeList API key). Add when someone asks.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
curl_cffi
|
curl_cffi
|
||||||
|
itsdangerous
|
||||||
+61
-3
@@ -8,17 +8,75 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import quote, urljoin
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, HTTPException, Query
|
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 fastapi.staticfiles import StaticFiles
|
||||||
|
from itsdangerous import BadSignature, TimestampSigner
|
||||||
|
|
||||||
from . import upstream
|
from . import credentials, upstream
|
||||||
|
|
||||||
ROOT = Path(__file__).parent.parent
|
ROOT = Path(__file__).parent.parent
|
||||||
app = FastAPI(title="ani-web")
|
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"
|
HIST_FILE = Path(__file__).parent / "data" / "history.json"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +4,10 @@ let hls = null, detail = null, lang = "eng", seasonId = null;
|
|||||||
|
|
||||||
async function api(path) {
|
async function api(path) {
|
||||||
const r = await fetch(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) {
|
if (!r.ok) {
|
||||||
let msg = r.statusText;
|
let msg = r.statusText;
|
||||||
try { msg = (await r.json()).detail || msg; } catch {}
|
try { msg = (await r.json()).detail || msg; } catch {}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user