"""Lightweight session auth + rate limiting (P0 hardening). - **Session tokens**: HMAC-SHA256-signed bearer tokens minted at enrollment. A user-scoped endpoint verifies the token's subject matches the ``user_id`` it acts on, closing the "act as any user_id" hole. - **Enforcement is opt-in** via ``AMANPAY_REQUIRE_AUTH`` (default off) so the public demo keeps working; production sets it to 1. When off, ``authorize`` is a no-op. - **Rate limiting**: a small in-memory token bucket per (client, key) to blunt biometric hill-climbing / brute force and basic abuse. Single-instance only — move to Redis when the app scales (P1). Secrets: ``AMANPAY_SESSION_SECRET`` signs tokens (a random per-process key is used if unset — fine for the demo, set it in production so tokens survive restarts). """ from __future__ import annotations import base64 import hashlib import hmac import json import os import secrets import time from typing import Dict, Optional, Tuple from fastapi import HTTPException, Request _SECRET = (os.getenv("AMANPAY_SESSION_SECRET") or secrets.token_hex(32)).encode() _TTL = int(os.getenv("AMANPAY_SESSION_TTL", "3600")) def _truthy(v: Optional[str]) -> bool: return (v or "").strip().lower() not in ("", "0", "false", "no", "off") def require_auth() -> bool: return _truthy(os.getenv("AMANPAY_REQUIRE_AUTH", "0")) def _b64u(b: bytes) -> str: return base64.urlsafe_b64encode(b).decode().rstrip("=") def _ub64u(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def mint_token(user_id: str, now: Optional[float] = None) -> str: """Issue a signed session token for ``user_id`` (exp = now + TTL).""" now = time.time() if now is None else now payload = _b64u(json.dumps({"sub": user_id, "exp": now + _TTL}).encode()) sig = _b64u(hmac.new(_SECRET, payload.encode(), hashlib.sha256).digest()) return f"{payload}.{sig}" def verify_token(token: str, now: Optional[float] = None) -> Optional[str]: """Return the token's subject if valid & unexpired, else None.""" now = time.time() if now is None else now try: payload, sig = token.split(".", 1) expected = _b64u(hmac.new(_SECRET, payload.encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, expected): return None data = json.loads(_ub64u(payload)) if float(data.get("exp", 0)) < now: return None return data.get("sub") except Exception: return None def _bearer(request: Optional[Request]) -> Optional[str]: if request is None: return None h = request.headers.get("authorization", "") return h[7:].strip() if h.lower().startswith("bearer ") else None def authorize(user_id: str, request: Optional[Request]) -> None: """Enforce that the caller is authenticated as ``user_id`` — no-op unless ``AMANPAY_REQUIRE_AUTH`` is set. Raises 401/403 otherwise.""" if not require_auth(): return tok = _bearer(request) sub = verify_token(tok) if tok else None if sub is None: raise HTTPException(status_code=401, detail="authentication required") if sub != user_id: raise HTTPException(status_code=403, detail="token subject does not match user_id") # ---- rate limiter (token bucket, KV-backed so it's shared across instances) ---- def rate_limit(request: Optional[Request], key: str, capacity: int = 10, refill_per_sec: float = 0.5, now: Optional[float] = None) -> None: """Token-bucket limiter. Raises 429 when the bucket for (client, key) is empty. Backed by the KV store (Redis when REDIS_URL is set → shared across replicas).""" if not _truthy(os.getenv("AMANPAY_RATE_LIMIT", "1")): return from amanpay.storage.kv import get_kv now = time.time() if now is None else now client = (request.client.host if request and request.client else "anon") kv = get_kv() bkey = f"rl:{key}:{client}" b = kv.get(bkey, now=now) or {"tokens": float(capacity), "last": now} tokens = min(capacity, b["tokens"] + (now - b["last"]) * refill_per_sec) if tokens < 1.0: raise HTTPException(status_code=429, detail="rate limit exceeded — slow down") kv.set(bkey, {"tokens": tokens - 1.0, "last": now}, ttl=3600, now=now)