Files
Claude 5f3b2b2874 Add accounts, presets, live sheet panel and equipment tiles
- Login with registration code, sessions, rate limiting, per-user characters
- Share characters with other users (read-only), admin CLI
- Rules presets with a Presets tab, applied/pending changes with diff in a side sheet
- Equipment as tiles with amounts, starting kit removed
- Rename nav tab to "Character", add "New character" button
- Stop tracking _to_delete/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-20 20:37:09 +00:00

107 lines
3.5 KiB
Python

"""Accounts, sessions and brute-force protection (standard library only)."""
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
SESSION_COOKIE = "aw_session"
SESSION_DAYS = 30
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
MIN_PASSWORD = 8
MAX_PASSWORD = 200
# scrypt parameters (about 16 MB of memory per hash)
_N, _R, _P = 2 ** 14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
h = hashlib.scrypt(password.encode(), salt=salt, n=_N, r=_R, p=_P, dklen=32)
return f"scrypt${_N}${_R}${_P}${salt.hex()}${h.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, n, r, p, salt, h = stored.split("$")
calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(calc.hex(), h)
except Exception:
return False
_DUMMY = hash_password("dummy-password-for-timing")
def init_tables(con: sqlite3.Connection) -> None:
con.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
pw_hash TEXT NOT NULL, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires REAL NOT NULL)""")
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def create_session(con: sqlite3.Connection, user_id: int) -> str:
token = secrets.token_urlsafe(32)
con.execute("DELETE FROM sessions WHERE expires < ?", (time.time(),))
con.execute("INSERT INTO sessions VALUES (?,?,?)", (_hash_token(token), user_id, time.time() + SESSION_DAYS * 86400))
return token
def user_for_token(con: sqlite3.Connection, token: str | None):
if not token:
return None
row = con.execute(
"SELECT u.id, u.username, s.expires FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?",
(_hash_token(token),)).fetchone()
if not row or row["expires"] < time.time():
return None
return {"id": row["id"], "username": row["username"]}
def end_session(con: sqlite3.Connection, token: str | None) -> None:
if token:
con.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),))
def timing_dummy(password: str) -> None:
"""Burn the same time as a real check when the user does not exist."""
verify_password(password, _DUMMY)
class Limiter:
"""In-memory failure counter: too many failures within the window block further attempts."""
def __init__(self):
self.lock = threading.Lock()
self.hits: dict[str, list[float]] = {}
def blocked(self, key: str, limit: int, window: int) -> int:
"""Returns seconds to wait (0 = allowed)."""
now = time.time()
with self.lock:
xs = [t for t in self.hits.get(key, []) if now - t < window]
self.hits[key] = xs
return int(window - (now - xs[0])) + 1 if len(xs) >= limit else 0
def fail(self, key: str) -> None:
with self.lock:
self.hits.setdefault(key, []).append(time.time())
if len(self.hits) > 20000: # keep memory bounded
self.hits.clear()
def reset(self, key: str) -> None:
with self.lock:
self.hits.pop(key, None)
def registration_code() -> str:
return os.environ.get("AW_REGISTRATION_CODE", "")