amanpay / api /auth.py
MHamdan's picture
CI deploy b14b288
544f664 verified
Raw
History Blame Contribute Delete
4.33 kB
"""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)