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