- 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
107 lines
3.5 KiB
Python
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", "")
|