"""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", "")