File size: 4,327 Bytes
9704c6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544f664
9704c6e
 
 
544f664
 
9704c6e
 
544f664
9704c6e
 
544f664
 
 
 
9704c6e
 
544f664
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
"""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)