Captures backend work already running on godstrument.pro but never committed: - auth.py: invite-only accounts + per-user presets (SQLite, scrypt, signed stateless sessions), mounted by hub.py at /api/* - hub.py: /api static+API handler, readonly public mode, full route spec in hello - socio-economic / market / fire / debt / crypto workers + normalize/transform - .gitignore: never commit godstrument_users.db* or auth_secret - test_fixes.py: framework-free self-check for the review fixes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
469 lines
19 KiB
Python
469 lines
19 KiB
Python
"""
|
|
auth.py — invite-only accounts + per-user presets for the Godstrument.
|
|
|
|
The planet is shared; the *instrument* is personal. This stores each user's
|
|
saved patches (routes, tweaks, godtime, synth) so a logged-in browser can load
|
|
its own instrument off the shared feed. Pure stdlib: SQLite + scrypt hashing +
|
|
signed stateless session cookies. No frameworks, no new services.
|
|
|
|
python auth.py --init # create the db
|
|
python auth.py --mint-invite [N] # print N new invite codes (default 1)
|
|
python auth.py --users # list accounts
|
|
python auth.py --selftest # assert the whole flow works
|
|
|
|
The hub mounts handle_api() at /api/* (see hub.py). Secret comes from
|
|
GODSTRUMENT_SECRET or an auto-generated auth_secret file next to the db.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DEFAULT_DB = os.path.join(HERE, "godstrument_users.db")
|
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$")
|
|
MIN_PW = 8
|
|
SESSION_DAYS = 30
|
|
# whoever signs up with this email is an admin (set in the systemd unit)
|
|
ADMIN_EMAIL = (os.environ.get("GODSTRUMENT_ADMIN") or "").strip().lower()
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
|
|
pw_hash TEXT NOT NULL, salt TEXT NOT NULL, created REAL);
|
|
CREATE TABLE IF NOT EXISTS invites (
|
|
code TEXT PRIMARY KEY, created REAL, used_by INTEGER);
|
|
CREATE TABLE IF NOT EXISTS presets (
|
|
user_id INTEGER, name TEXT, data TEXT, updated REAL,
|
|
PRIMARY KEY (user_id, name));
|
|
CREATE TABLE IF NOT EXISTS feedback (
|
|
id INTEGER PRIMARY KEY, user_id INTEGER, username TEXT, text TEXT, created REAL);
|
|
"""
|
|
|
|
# ---- basic in-memory login throttle (friends-scale; resets on restart) ------
|
|
_ATTEMPTS: dict[str, list[float]] = {}
|
|
_ATT_LOCK = threading.Lock()
|
|
_MAX_TRIES, _WINDOW = 8, 300.0 # 8 failures / 5 min per email
|
|
|
|
|
|
def _throttled(email: str) -> bool:
|
|
now = time.time()
|
|
with _ATT_LOCK:
|
|
q = [t for t in _ATTEMPTS.get(email, []) if now - t < _WINDOW]
|
|
_ATTEMPTS[email] = q
|
|
return len(q) >= _MAX_TRIES
|
|
|
|
|
|
def _note_fail(email: str):
|
|
with _ATT_LOCK:
|
|
_ATTEMPTS.setdefault(email, []).append(time.time())
|
|
|
|
|
|
# ---- db ---------------------------------------------------------------------
|
|
def _con(db: str) -> sqlite3.Connection:
|
|
con = sqlite3.connect(db)
|
|
con.execute("PRAGMA journal_mode=WAL")
|
|
con.executescript(SCHEMA)
|
|
try: # migrate: add is_admin to older dbs
|
|
con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
|
|
con.commit()
|
|
except sqlite3.OperationalError:
|
|
pass # column already exists
|
|
return con
|
|
|
|
|
|
def init_db(db: str = DEFAULT_DB):
|
|
_con(db).close()
|
|
|
|
|
|
# ---- secret / password hashing ----------------------------------------------
|
|
def _secret(db: str = DEFAULT_DB) -> bytes:
|
|
env = os.environ.get("GODSTRUMENT_SECRET")
|
|
if env:
|
|
return env.encode()
|
|
path = os.path.join(os.path.dirname(os.path.abspath(db)), "auth_secret")
|
|
if not os.path.exists(path):
|
|
with open(os.open(path, os.O_CREAT | os.O_WRONLY, 0o600), "w") as f:
|
|
f.write(secrets.token_hex(32))
|
|
with open(path) as f:
|
|
return f.read().strip().encode()
|
|
|
|
|
|
def hash_pw(pw: str, salt: str | None = None) -> tuple[str, str]:
|
|
salt = salt or secrets.token_hex(16)
|
|
h = hashlib.scrypt(pw.encode(), salt=bytes.fromhex(salt),
|
|
n=16384, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024)
|
|
return salt, h.hex()
|
|
|
|
|
|
def verify_pw(pw: str, salt: str, expected: str) -> bool:
|
|
_, h = hash_pw(pw, salt)
|
|
return hmac.compare_digest(h, expected)
|
|
|
|
|
|
# ---- stateless signed session token -----------------------------------------
|
|
def make_token(user_id: int, db: str = DEFAULT_DB, days: int = SESSION_DAYS) -> str:
|
|
exp = int(time.time()) + days * 86400
|
|
payload = f"{user_id}:{exp}"
|
|
sig = hmac.new(_secret(db), payload.encode(), hashlib.sha256).hexdigest()
|
|
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
|
|
|
|
|
|
def read_token(token: str, db: str = DEFAULT_DB) -> int | None:
|
|
try:
|
|
raw = base64.urlsafe_b64decode(token.encode()).decode()
|
|
uid, exp, sig = raw.split(":")
|
|
good = hmac.new(_secret(db), f"{uid}:{exp}".encode(), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(sig, good):
|
|
return None
|
|
if int(exp) < time.time():
|
|
return None
|
|
return int(uid)
|
|
except (ValueError, TypeError, AttributeError):
|
|
return None
|
|
|
|
|
|
# ---- account operations -----------------------------------------------------
|
|
def mint_invite(db: str = DEFAULT_DB) -> str:
|
|
code = secrets.token_urlsafe(9)
|
|
con = _con(db)
|
|
con.execute("INSERT INTO invites VALUES (?,?,?)", (code, time.time(), None))
|
|
con.commit(); con.close()
|
|
return code
|
|
|
|
|
|
def signup(username: str, email: str, pw: str, invite: str,
|
|
db: str = DEFAULT_DB) -> tuple[bool, str, int | None]:
|
|
username = (username or "").strip()
|
|
email = (email or "").strip().lower()
|
|
if not USERNAME_RE.match(username):
|
|
return False, "username must be 2-24 letters, numbers, . _ or -", None
|
|
if not EMAIL_RE.match(email):
|
|
return False, "invalid email", None
|
|
if len(pw or "") < MIN_PW:
|
|
return False, f"password must be at least {MIN_PW} characters", None
|
|
con = _con(db)
|
|
try:
|
|
row = con.execute("SELECT used_by FROM invites WHERE code=?", (invite or "",)).fetchone()
|
|
if row is None:
|
|
return False, "unknown invite code", None
|
|
if row[0] is not None:
|
|
return False, "invite code already used", None
|
|
if con.execute("SELECT 1 FROM users WHERE email=?", (email,)).fetchone():
|
|
return False, "email already registered", None
|
|
if con.execute("SELECT 1 FROM users WHERE username=? COLLATE NOCASE", (username,)).fetchone():
|
|
return False, "username already taken", None
|
|
salt, ph = hash_pw(pw)
|
|
cur = con.execute("INSERT INTO users (username,email,pw_hash,salt,created) VALUES (?,?,?,?,?)",
|
|
(username, email, ph, salt, time.time()))
|
|
uid = cur.lastrowid
|
|
con.execute("UPDATE invites SET used_by=? WHERE code=?", (uid, invite))
|
|
con.commit()
|
|
return True, "ok", uid
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def login(identifier: str, pw: str, db: str = DEFAULT_DB) -> int | None:
|
|
identifier = (identifier or "").strip()
|
|
if _throttled(identifier.lower()):
|
|
return None
|
|
con = _con(db)
|
|
try: # log in with email OR username
|
|
row = con.execute("SELECT id,salt,pw_hash FROM users WHERE email=? OR username=? COLLATE NOCASE",
|
|
(identifier.lower(), identifier)).fetchone()
|
|
finally:
|
|
con.close()
|
|
if row and verify_pw(pw or "", row[1], row[2]):
|
|
return row[0]
|
|
_note_fail(identifier.lower())
|
|
return None
|
|
|
|
|
|
def user_info(user_id: int, db: str = DEFAULT_DB) -> dict | None:
|
|
con = _con(db)
|
|
try:
|
|
row = con.execute("SELECT username,email,is_admin FROM users WHERE id=?", (user_id,)).fetchone()
|
|
finally:
|
|
con.close()
|
|
if not row:
|
|
return None
|
|
admin = bool(row[2] or (ADMIN_EMAIL and row[1] == ADMIN_EMAIL))
|
|
return {"username": row[0], "email": row[1], "admin": admin}
|
|
|
|
|
|
def _is_admin(user_id: int, db: str) -> bool:
|
|
info = user_info(user_id, db)
|
|
return bool(info and info.get("admin"))
|
|
|
|
|
|
def make_admin(email: str, db: str = DEFAULT_DB) -> bool:
|
|
con = _con(db)
|
|
cur = con.execute("UPDATE users SET is_admin=1 WHERE email=?", ((email or "").strip().lower(),))
|
|
con.commit(); con.close()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
# ---- feedback (from friends testing) + admin stats --------------------------
|
|
def submit_feedback(user_id: int, username: str, text: str, db: str = DEFAULT_DB) -> bool:
|
|
text = (text or "").strip()[:4000]
|
|
if not text:
|
|
return False
|
|
con = _con(db)
|
|
con.execute("INSERT INTO feedback (user_id,username,text,created) VALUES (?,?,?,?)",
|
|
(user_id, username, text, time.time()))
|
|
con.commit(); con.close()
|
|
return True
|
|
|
|
|
|
def admin_stats(db: str = DEFAULT_DB) -> dict:
|
|
con = _con(db)
|
|
try:
|
|
users = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
unused = con.execute("SELECT COUNT(*) FROM invites WHERE used_by IS NULL").fetchone()[0]
|
|
presets = con.execute("SELECT COUNT(*) FROM presets").fetchone()[0]
|
|
accounts = [{"username": u, "email": e,
|
|
"joined": time.strftime("%Y-%m-%d", time.localtime(c))}
|
|
for u, e, c in con.execute("SELECT username,email,created FROM users ORDER BY created DESC")]
|
|
fb = [{"username": u, "text": t,
|
|
"when": time.strftime("%Y-%m-%d %H:%M", time.localtime(c))}
|
|
for u, t, c in con.execute("SELECT username,text,created FROM feedback ORDER BY created DESC LIMIT 100")]
|
|
finally:
|
|
con.close()
|
|
return {"users": users, "unused_codes": unused, "presets": presets,
|
|
"accounts": accounts, "feedback": fb}
|
|
|
|
|
|
# ---- presets (a full personal patch, stored as one JSON blob) ---------------
|
|
def save_preset(user_id: int, name: str, data, db: str = DEFAULT_DB) -> bool:
|
|
name = (name or "").strip()[:60]
|
|
if not name:
|
|
return False
|
|
blob = json.dumps(data)
|
|
if len(blob) > 256 * 1024: # sanity cap; a patch is small
|
|
return False
|
|
con = _con(db)
|
|
con.execute("INSERT INTO presets VALUES (?,?,?,?) "
|
|
"ON CONFLICT(user_id,name) DO UPDATE SET data=excluded.data, updated=excluded.updated",
|
|
(user_id, name, blob, time.time()))
|
|
con.commit(); con.close()
|
|
return True
|
|
|
|
|
|
def list_presets(user_id: int, db: str = DEFAULT_DB) -> list[dict]:
|
|
con = _con(db)
|
|
try:
|
|
rows = con.execute("SELECT name,updated FROM presets WHERE user_id=? ORDER BY updated DESC",
|
|
(user_id,)).fetchall()
|
|
finally:
|
|
con.close()
|
|
return [{"name": n, "updated": u} for n, u in rows]
|
|
|
|
|
|
def load_preset(user_id: int, name: str, db: str = DEFAULT_DB):
|
|
con = _con(db)
|
|
try:
|
|
row = con.execute("SELECT data FROM presets WHERE user_id=? AND name=?",
|
|
(user_id, name)).fetchone()
|
|
finally:
|
|
con.close()
|
|
return json.loads(row[0]) if row else None
|
|
|
|
|
|
def delete_preset(user_id: int, name: str, db: str = DEFAULT_DB) -> bool:
|
|
con = _con(db)
|
|
cur = con.execute("DELETE FROM presets WHERE user_id=? AND name=?", (user_id, name))
|
|
con.commit(); con.close()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
# ---- HTTP dispatch (called by hub.py's request handler) ---------------------
|
|
COOKIE = "gs_session"
|
|
|
|
|
|
def _cookie(token: str, clear: bool = False) -> str:
|
|
if clear:
|
|
return f"{COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
|
age = SESSION_DAYS * 86400
|
|
return f"{COOKIE}={token}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax"
|
|
|
|
|
|
def _uid_from_cookies(cookie_header: str, db: str) -> int | None:
|
|
for part in (cookie_header or "").split(";"):
|
|
if "=" in part:
|
|
k, v = part.strip().split("=", 1)
|
|
if k == COOKIE:
|
|
return read_token(v, db)
|
|
return None
|
|
|
|
|
|
def handle_api(method: str, path: str, body: bytes, cookie_header: str,
|
|
db: str = DEFAULT_DB):
|
|
"""Returns (status, response_dict, set_cookie_or_None). Pure enough to test."""
|
|
try:
|
|
payload = json.loads(body or b"{}") if body else {}
|
|
except (ValueError, TypeError):
|
|
payload = {}
|
|
uid = _uid_from_cookies(cookie_header, db)
|
|
|
|
if path == "/api/me":
|
|
info = user_info(uid, db) if uid else None
|
|
return (200, info, None) if info else (401, {"error": "not logged in"}, None)
|
|
|
|
if path == "/api/signup" and method == "POST":
|
|
ok, msg, new_uid = signup(payload.get("username"), payload.get("email"),
|
|
payload.get("password"), payload.get("invite"), db)
|
|
if ok:
|
|
return 200, user_info(new_uid, db), _cookie(make_token(new_uid, db))
|
|
return 400, {"error": msg}, None
|
|
|
|
if path == "/api/login" and method == "POST":
|
|
u = login(payload.get("identifier") or payload.get("email"), payload.get("password"), db)
|
|
if u:
|
|
return 200, user_info(u, db), _cookie(make_token(u, db))
|
|
return 401, {"error": "wrong login or password"}, None
|
|
|
|
if path == "/api/logout" and method == "POST":
|
|
return 200, {"ok": True}, _cookie("", clear=True)
|
|
|
|
# everything below requires a session
|
|
if not uid:
|
|
return 401, {"error": "not logged in"}, None
|
|
|
|
if path == "/api/feedback" and method == "POST":
|
|
info = user_info(uid, db) or {}
|
|
ok = submit_feedback(uid, info.get("username"), payload.get("text"), db)
|
|
return (200, {"ok": True}, None) if ok else (400, {"error": "empty feedback"}, None)
|
|
|
|
if path.startswith("/api/admin/"):
|
|
if not _is_admin(uid, db):
|
|
return 403, {"error": "admins only"}, None
|
|
if path == "/api/admin/stats" and method == "GET":
|
|
return 200, admin_stats(db), None
|
|
if path == "/api/admin/invite" and method == "POST":
|
|
return 200, {"code": mint_invite(db)}, None
|
|
return 404, {"error": "not found"}, None
|
|
|
|
if path == "/api/presets" and method == "GET":
|
|
return 200, {"presets": list_presets(uid, db)}, None
|
|
|
|
if path.startswith("/api/presets/"):
|
|
name = _unquote(path[len("/api/presets/"):])
|
|
if method == "GET":
|
|
data = load_preset(uid, name, db)
|
|
return (200, {"name": name, "data": data}, None) if data is not None else (404, {"error": "no such preset"}, None)
|
|
if method in ("PUT", "POST"):
|
|
return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None)
|
|
if method == "DELETE":
|
|
return (200, {"ok": True}, None) if delete_preset(uid, name, db) else (404, {"error": "no such preset"}, None)
|
|
|
|
return 404, {"error": "not found"}, None
|
|
|
|
|
|
def _unquote(s: str) -> str:
|
|
from urllib.parse import unquote
|
|
return unquote(s)
|
|
|
|
|
|
# ---- CLI + self-test --------------------------------------------------------
|
|
def _selftest():
|
|
import tempfile
|
|
db = os.path.join(tempfile.mkdtemp(), "t.db")
|
|
os.environ["GODSTRUMENT_SECRET"] = "test-secret-not-real"
|
|
init_db(db)
|
|
code = mint_invite(db)
|
|
# signup gates
|
|
assert signup("bo", "bad", "password123", code, db)[0] is False, "bad email rejected"
|
|
assert signup("bo", "a@b.co", "short", code, db)[0] is False, "short password rejected"
|
|
assert signup("no spaces!", "a@b.co", "password123", code, db)[0] is False, "bad username rejected"
|
|
ok, _, uid = signup("Nova", "Friend@Example.com", "hunter2pass", code, db)
|
|
assert ok and uid, "signup should succeed with a valid invite"
|
|
assert signup("Other", "other@x.co", "hunter2pass", code, db)[0] is False, "invite is single-use"
|
|
assert signup("Nova2", "friend@example.com", "hunter2pass", mint_invite(db), db)[0] is False, "email unique"
|
|
assert signup("nova", "new@x.co", "hunter2pass", mint_invite(db), db)[0] is False, "username unique (case-insensitive)"
|
|
# login by email OR username
|
|
assert login("friend@example.com", "hunter2pass", db) == uid
|
|
assert login("Nova", "hunter2pass", db) == uid, "login by username works"
|
|
assert login("friend@example.com", "wrong", db) is None
|
|
# session round-trip
|
|
tok = make_token(uid, db)
|
|
assert read_token(tok, db) == uid
|
|
assert read_token(tok + "x", db) is None, "tampered token rejected"
|
|
assert read_token("garbage", db) is None
|
|
# presets
|
|
assert save_preset(uid, "my patch", {"routes": [1, 2, 3], "bpm": 120}, db)
|
|
assert load_preset(uid, "my patch", db)["bpm"] == 120
|
|
assert save_preset(uid, "my patch", {"bpm": 96}, db) # overwrite
|
|
assert load_preset(uid, "my patch", db)["bpm"] == 96
|
|
assert [p["name"] for p in list_presets(uid, db)] == ["my patch"]
|
|
assert load_preset(999, "my patch", db) is None, "presets are per-user"
|
|
assert delete_preset(uid, "my patch", db) and not list_presets(uid, db)
|
|
# http layer: unauth blocked, signup+login cookie flow works
|
|
st, _, _ = handle_api("GET", "/api/presets", b"", "", db)
|
|
assert st == 401, "presets require a session"
|
|
st, resp, ck = handle_api("POST", "/api/signup",
|
|
json.dumps({"username": "Mika", "email": "mika@x.co",
|
|
"password": "orbit12345", "invite": mint_invite(db)}).encode(), "", db)
|
|
assert st == 200 and resp["username"] == "Mika" and ck and "HttpOnly" in ck and "Secure" in ck
|
|
st, resp, ck = handle_api("POST", "/api/login",
|
|
json.dumps({"identifier": "Nova", "password": "hunter2pass"}).encode(), "", db)
|
|
assert st == 200 and resp["email"] == "friend@example.com"
|
|
cookie = ck.split(";")[0]
|
|
st, resp, _ = handle_api("GET", "/api/me", b"", cookie, db)
|
|
assert st == 200 and resp["username"] == "Nova"
|
|
# feedback + admin gating
|
|
assert handle_api("POST", "/api/feedback", json.dumps({"text": "love it, one bug tho"}).encode(), cookie, db)[0] == 200
|
|
assert handle_api("GET", "/api/admin/stats", b"", cookie, db)[0] == 403, "non-admin blocked from admin"
|
|
assert make_admin("friend@example.com", db), "grant admin"
|
|
st, stats, _ = handle_api("GET", "/api/admin/stats", b"", cookie, db)
|
|
assert st == 200 and stats["users"] >= 1, "admin can read stats"
|
|
assert any("one bug tho" in f["text"] for f in stats["feedback"]), "feedback shows in stats"
|
|
assert handle_api("POST", "/api/admin/invite", b"", cookie, db)[1].get("code"), "admin can mint codes"
|
|
print("auth self-test: all checks passed.")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--db", default=DEFAULT_DB)
|
|
ap.add_argument("--init", action="store_true")
|
|
ap.add_argument("--mint-invite", nargs="?", type=int, const=1, metavar="N")
|
|
ap.add_argument("--users", action="store_true")
|
|
ap.add_argument("--make-admin", metavar="EMAIL")
|
|
ap.add_argument("--feedback", action="store_true", help="print recent feedback")
|
|
ap.add_argument("--selftest", action="store_true")
|
|
args = ap.parse_args()
|
|
if args.selftest:
|
|
_selftest(); return
|
|
if args.make_admin:
|
|
print("granted admin" if make_admin(args.make_admin, args.db) else "no such user"); return
|
|
if args.feedback:
|
|
for f in admin_stats(args.db)["feedback"]:
|
|
print(f" [{f['when']}] @{f['username']}: {f['text']}")
|
|
return
|
|
if args.init:
|
|
init_db(args.db); print(f"initialized {args.db}"); return
|
|
if args.mint_invite:
|
|
for _ in range(args.mint_invite):
|
|
print("invite:", mint_invite(args.db))
|
|
return
|
|
if args.users:
|
|
con = _con(args.db)
|
|
for row in con.execute("SELECT id,username,email,created FROM users ORDER BY id"):
|
|
print(f" #{row[0]:3} {row[1]:16} {row[2]:30} joined {time.strftime('%Y-%m-%d', time.localtime(row[3]))}")
|
|
con.close(); return
|
|
ap.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|