Source-agnostic license issuance service. FastAPI app fronts a Postgres `licenses` table; the only currently-wired source is `manual` (operator mints via /internal/mint). Gumroad webhook adapter lands in PR 2. Key design points: - Signing reuses src/license/crypto.py via a COPY into the image (single source of truth — blobs minted server-side verify against the same embedded pubkey on the buyer's machine). - Source adapter Protocol (app/adapters/base.py) is the seam for Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks only SaleEvent / RefundEvent. - (source, source_order_id) UNIQUE composite gives idempotent webhook retries without double-mint. - JSONB type uses with_variant(JSON, 'sqlite') so the same models drive both Postgres prod and SQLite tests (no testcontainers dep). - Bearer-token auth on /internal/*; the IP-loopback guard was removed after the docker bridge made it fight legitimate prod traffic (nginx defense + Bearer remain). - Secrets resolved via *_FILE env vars pointing at /run/secrets/<name>, so passwords never appear in `docker inspect`. 21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres docker-compose smoke test in server/scripts/smoke.sh that builds the image, runs the alembic migration, mints a license, verifies the signature against the host dev pubkey, and checks the DB row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
66 lines
2.3 KiB
Python
66 lines
2.3 KiB
Python
"""Auth guards for ``/internal/*``.
|
|
|
|
Active layer: Bearer token, presented by the operator's CLI and
|
|
matched against the value in the secrets dir. Token rotation =
|
|
update the file, restart the container.
|
|
|
|
:func:`require_localhost` is preserved but unused by default — it
|
|
fights the Docker bridge network model (the container sees the
|
|
gateway IP, not 127.0.0.1, regardless of where traffic originated).
|
|
Re-enable it only if the API runs in ``network_mode: host``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException, Request, status
|
|
|
|
from app.config import get_settings
|
|
|
|
|
|
def require_localhost(request: Request) -> None:
|
|
"""Reject the request unless the connecting peer is loopback.
|
|
|
|
``request.client.host`` reflects the actual TCP peer (the nginx
|
|
upstream connecting from 127.0.0.1) when ``proxy_set_header`` is
|
|
used appropriately. We deliberately do NOT trust
|
|
``X-Forwarded-For`` here — we want the raw peer.
|
|
"""
|
|
peer = request.client.host if request.client else None
|
|
if peer not in {"127.0.0.1", "::1"}:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Not found",
|
|
)
|
|
|
|
|
|
def require_bearer_token(request: Request) -> None:
|
|
"""Verify ``Authorization: Bearer <admin_token>``.
|
|
|
|
Uses constant-time comparison so timing leaks don't reveal token
|
|
prefixes. The 401 deliberately doesn't echo the supplied token or
|
|
leak whether a token is configured at all — clients should treat
|
|
"no token configured" the same as "wrong token".
|
|
"""
|
|
settings = get_settings()
|
|
expected: Optional[str] = settings.resolve_admin_token()
|
|
if not expected:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Server not configured for internal access.",
|
|
)
|
|
auth = request.headers.get("Authorization", "")
|
|
if not auth.startswith("Bearer "):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Bearer token required.",
|
|
)
|
|
presented = auth.removeprefix("Bearer ").strip()
|
|
if not hmac.compare_digest(presented, expected):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token.",
|
|
)
|