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
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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", "")
|
||||
Reference in New Issue
Block a user