""" Authentication helpers: password hashing, JWT signing/verification, and the require_auth decorator used by route blueprints. Hashes are stored in a self-describing format so the algorithm can be migrated later without invalidating existing rows: pbkdf2_sha256$$$ PBKDF2-HMAC-SHA256 is used because it is available in every CPython build. `hashlib.scrypt` is NOT: builds linked against LibreSSL (notably the system Python on macOS) omit it, which made registration fail with a 500. Legacy `:` scrypt hashes are still verified when the running interpreter supports scrypt. """ import hashlib import hmac import os import datetime import jwt from flask import request, g, jsonify from functools import wraps from config import JWT_SECRET, JWT_EXPIRY_DAYS PBKDF2_ITERATIONS = 200_000 PBKDF2_PREFIX = "pbkdf2_sha256" _SALT_BYTES = 16 _DK_LEN = 64 class AuthError(Exception): def __init__(self, code, message): super().__init__(message) self.code = code self.message = message def _pbkdf2(password: str, salt: bytes, iterations: int) -> str: return hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations, dklen=_DK_LEN ).hex() def hash_password(password: str) -> str: salt = os.urandom(_SALT_BYTES) derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS) return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}" def _verify_legacy_scrypt(password: str, stored: str) -> bool: """Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported.""" if not hasattr(hashlib, "scrypt"): return False salt_hex, hash_hex = stored.split(":", 1) try: salt = bytes.fromhex(salt_hex) except ValueError: return False derived = hashlib.scrypt( password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=_DK_LEN ).hex() return hmac.compare_digest(derived, hash_hex) def verify_password(password: str, stored: str) -> bool: if not stored: return False if stored.startswith(PBKDF2_PREFIX + "$"): try: _, iterations, salt_hex, hash_hex = stored.split("$", 3) salt = bytes.fromhex(salt_hex) derived = _pbkdf2(password, salt, int(iterations)) except (ValueError, TypeError): return False return hmac.compare_digest(derived, hash_hex) if ":" in stored: return _verify_legacy_scrypt(password, stored) return False def sign_token(user_id: str) -> str: now = datetime.datetime.utcnow() payload = { "sub": user_id, "iat": now, "exp": now + datetime.timedelta(days=JWT_EXPIRY_DAYS), } return jwt.encode(payload, JWT_SECRET, algorithm="HS256") def verify_token(token: str) -> dict: payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) return {"user_id": payload["sub"]} def require_auth(f): @wraps(f) def wrapper(*args, **kwargs): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return jsonify({"error": "missing or malformed Authorization header"}), 401 token = auth[7:].strip() try: data = verify_token(token) except jwt.ExpiredSignatureError: return jsonify({"error": "token expired"}), 401 except jwt.InvalidTokenError: return jsonify({"error": "invalid token"}), 401 g.user_id = data["user_id"] return f(*args, **kwargs) return wrapper