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:
1 parent
76c45a8f29
commit
c697500c7d
12 files changed
+595
-122
No files matched your search
+76
-39
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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()
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
"""users.py — multi-user accounts for ani-web (PBKDF2, single json file).
|
||||
|
||||
CLI: .venv/bin/python -m server.users add <username> (password via prompt)
|
||||
.venv/bin/python -m server.users rm <username>
|
||||
.venv/bin/python -m server.users list
|
||||
"""
|
||||
import getpass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data"))
|
||||
USERS = DATA / "users.json"
|
||||
SECRET = DATA / "secret.key"
|
||||
ITER = 210_000 # OWASP 2023 minimum for PBKDF2-SHA256
|
||||
NAME_RE = re.compile(r"^[a-z0-9_-]{3,32}$")
|
||||
|
||||
|
||||
def get_secret() -> bytes:
|
||||
if SECRET.exists():
|
||||
return SECRET.read_bytes()
|
||||
s = secrets.token_bytes(32)
|
||||
DATA.mkdir(exist_ok=True)
|
||||
fd = os.open(SECRET, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(s)
|
||||
return s
|
||||
|
||||
|
||||
def migrate_legacy():
|
||||
"""one-time: single-password era -> users.json + per-user history files."""
|
||||
cred = DATA / "credentials.json"
|
||||
if not USERS.exists() and cred.exists():
|
||||
d = json.loads(cred.read_text())
|
||||
save({"admin": {"salt": d["salt"], "hash": d["hash"], "created": time.time()}})
|
||||
cred.unlink()
|
||||
old_hist = DATA / "history.json"
|
||||
if not (DATA / "history" / "admin.json").exists() and old_hist.exists():
|
||||
(DATA / "history").mkdir(exist_ok=True)
|
||||
old_hist.rename(DATA / "history" / "admin.json")
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
try:
|
||||
return json.loads(USERS.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def save(users: dict):
|
||||
DATA.mkdir(exist_ok=True)
|
||||
# ponytail: mode 0600 on create; won't tighten an existing file
|
||||
fd = os.open(USERS, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(users, f, indent=1)
|
||||
|
||||
|
||||
def add(username: str, password: str):
|
||||
u = username.lower()
|
||||
if not NAME_RE.match(u):
|
||||
raise ValueError("username: 3-32 chars, only a-z 0-9 _ -")
|
||||
if len(password) < 8:
|
||||
raise ValueError("password: min 8 chars")
|
||||
users = load()
|
||||
if u in users:
|
||||
raise ValueError("username already taken")
|
||||
salt = secrets.token_bytes(16)
|
||||
users[u] = {"salt": salt.hex(),
|
||||
"hash": hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITER).hex(),
|
||||
"created": time.time()}
|
||||
save(users)
|
||||
|
||||
|
||||
def verify(username: str, password: str) -> bool:
|
||||
d = load().get(username.lower())
|
||||
if not d:
|
||||
return False
|
||||
h = hashlib.pbkdf2_hmac("sha256", password.encode(),
|
||||
bytes.fromhex(d["salt"]), d.get("iter", ITER))
|
||||
return secrets.compare_digest(h.hex(), d["hash"])
|
||||
|
||||
|
||||
def remove(username: str):
|
||||
u = username.lower()
|
||||
users = load()
|
||||
users.pop(u, None)
|
||||
save(users)
|
||||
(DATA / "history" / f"{u}.json").unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd == "add" and len(sys.argv) > 2:
|
||||
name = sys.argv[2]
|
||||
pw = getpass.getpass(f"Password for {name} (min 8 chars): ")
|
||||
if pw != getpass.getpass("Again: "):
|
||||
raise SystemExit("passwords did not match")
|
||||
try:
|
||||
add(name, pw)
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e))
|
||||
print(f"added user {name.lower()}")
|
||||
elif cmd == "rm" and len(sys.argv) > 2:
|
||||
remove(sys.argv[2])
|
||||
print(f"removed {sys.argv[2].lower()} (incl. history)")
|
||||
elif cmd == "list":
|
||||
for u in sorted(load()):
|
||||
print(u)
|
||||
else:
|
||||
print(__doc__)
|
||||
raise SystemExit(2)
|
||||
Reference in new issue
Block a user