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

+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()