"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes.""" import base64 import functools import hashlib import hmac import logging import os import re import secrets from pathlib import Path import psycopg2.errors from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request from db import query, query_one from validation import ApiError, json_body, require_utf8 log = logging.getLogger(__name__) bp = Blueprint("auth", __name__, url_prefix="/api/auth") ITERATIONS = 600_000 SALT_BYTES = 16 MIN_PEPPER_CHARS = 32 SESSION_COOKIE = "sid" USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}") MIN_PASSWORD, MAX_PASSWORD = 12, 1024 INVALID_LOGIN = "Invalid username or password" def init_app(app: Flask) -> None: app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper") _dummy_hash() app.register_blueprint(bp) # --- Passwords ----------------------------------------------------------------- def hash_password( password: str, pepper: bytes, *, salt: bytes | None = None, iterations: int = ITERATIONS, ) -> str: salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt derived = _derive(password, pepper, salt, iterations) return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}" def verify_password(password: str, stored: str, pepper: bytes) -> bool: _, iterations, salt, expected = stored.split("$") derived = _derive(password, pepper, base64.b64decode(salt), int(iterations)) return hmac.compare_digest(derived, base64.b64decode(expected)) def needs_rehash(stored: str) -> bool: return int(stored.split("$")[1]) < ITERATIONS def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes: peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest() return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations) def _b64(raw: bytes) -> str: return base64.b64encode(raw).decode() @functools.cache def _dummy_hash() -> str: # Verified against for unknown usernames so their response time matches a real account. return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS) def load_pepper(pepper_file: Path) -> bytes: from_env = os.environ.get("PASSWORD_PEPPER") if from_env is not None: if len(from_env) < MIN_PEPPER_CHARS: raise RuntimeError( f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. " 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' ) return from_env.encode() try: fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) except FileExistsError: pass else: with os.fdopen(fd, "w") as f: f.write(secrets.token_hex(32)) log.warning( "PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper " "from a secrets manager.", pepper_file, ) pepper = pepper_file.read_text().strip() if len(pepper) < MIN_PEPPER_CHARS: raise RuntimeError( f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete " "the file to regenerate it (existing passwords will stop verifying)." ) return pepper.encode() # --- Sessions ------------------------------------------------------------------ def login_required(view): @functools.wraps(view) def wrapper(*args, **kwargs): token = request.cookies.get(SESSION_COOKIE) row = None if token: row = query_one( """ UPDATE sessions SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours') WHERE token_hash = %s AND expires_at > now() AND created_at > now() - interval '12 hours' RETURNING user_id """, (_token_hash(token),), ) if row is None: raise ApiError(401, "Not signed in or session expired; log in again") g.user_id = row["user_id"] return view(*args, **kwargs) return wrapper def _token_hash(token: str) -> bytes: return hashlib.sha256(token.encode("utf-8")).digest() def _pepper() -> bytes: return current_app.extensions["password_pepper"] def _start_session(user: dict, status: int): token = secrets.token_urlsafe(32) query( "DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],) ) query( "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (%s, %s, now() + interval '30 minutes')", (_token_hash(token), user["id"]), ) response = jsonify(id=user["id"], username=user["username"]) response.status_code = status response.set_cookie( SESSION_COOKIE, token, httponly=True, secure=True, samesite="Lax", path="/api" ) return response # --- Routes ---------------------------------------------------------------------- def _password(body: dict) -> str: password = body.get("password") if not isinstance(password, str): raise ApiError(400, "password is required and must be a string", "password") if len(password) > MAX_PASSWORD: raise ApiError( 400, f"password must be at most {MAX_PASSWORD} characters", "password" ) require_utf8(password, "password") return password @bp.post("/register") def register(): body = json_body({"username", "password"}) username = body.get("username") if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username): raise ApiError( 400, "username must be 3-64 characters: letters, digits, '.', '_' or '-'", "username", ) password = _password(body) if len(password) < MIN_PASSWORD: raise ApiError( 400, f"password must be at least {MIN_PASSWORD} characters", "password" ) try: user = query_one( "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", (username, hash_password(password, _pepper())), ) except psycopg2.errors.UniqueViolation: raise ApiError( 409, "Username already taken; choose another", "username" ) from None return _start_session(user, 201) @bp.post("/login") def login(): body = json_body({"username", "password"}) password = _password(body) username = body.get("username") user = None if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username): user = query_one( "SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)", (username,), ) if user is None: verify_password(password, _dummy_hash(), _pepper()) raise ApiError(401, INVALID_LOGIN) if not verify_password(password, user["password_hash"], _pepper()): raise ApiError(401, INVALID_LOGIN) if needs_rehash(user["password_hash"]): query( "UPDATE users SET password_hash = %s WHERE id = %s", (hash_password(password, _pepper()), user["id"]), ) return _start_session(user, 200) @bp.post("/logout") def logout(): token = request.cookies.get(SESSION_COOKIE) if token: query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),)) response = make_response("", 204) response.delete_cookie( SESSION_COOKIE, path="/api", secure=True, httponly=True, samesite="Lax" ) return response @bp.get("/me") @login_required def me(): return jsonify( query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,)) )