- 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
56 lines
1.7 KiB
Python
56 lines
1.7 KiB
Python
"""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()
|