"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify. The OTC request endpoint (`/auth/otc/request`) is the abuse hot path of the auth surface since v0.7.0 — the per-email cooldown stops the trivial back-to-back loop, but it does not stop a distributed scraper that fans out across a large invitee list to harvest the "this email is admitted vs. this email is not" signal indirectly (timing differences, SMTP bounce-rate observation). v0.12.0 gates the request endpoint behind a one-step browser-side Turnstile challenge before the bcrypt hash + SMTP send. Stateless: no DB writes, no schema change. The siteverify call to CloudFlare lives entirely in this module; the endpoint handler in `main.py` thin-wraps `verify_token`. Tunables (read at call time so tests can monkeypatch): * `CLOUDFLARE_TURNSTILE_SECRET` — the operator-provisioned secret key from the Turnstile dashboard. Lives in GCP Secret Manager in production; absent in tests (which monkeypatch the siteverify transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`: - `TURNSTILE_REQUIRED=true` → fail closed (`misconfigured`). - `TURNSTILE_REQUIRED=false` (default) → skip verification entirely and admit the request. This is the dev/test path and the "operator hasn't wired the secret yet" path; production deployments **should** set `TURNSTILE_REQUIRED=true` once the secret is in place so a regression in the secret wiring fails loudly instead of silently disabling abuse defense. * `TURNSTILE_REQUIRED` — `true` / `false` (default `false`). When `false` and the secret is absent, the gate is open. When `true` and the secret is absent, the endpoint refuses with a misconfigured-auth shape rather than silently letting requests through. * `TURNSTILE_SITEVERIFY_URL` — points at the real CloudFlare endpoint by default. Override in tests to redirect at a mock URL when `httpx.MockTransport` isn't ergonomic for the case. The siteverify contract is documented at https://developers.cloudflare.com/turnstile/get-started/server-side-validation/. We POST `secret` + `response` (and optionally `remoteip`) as form fields and read back `{"success": true|false, ...}`. Any network / parse failure on the siteverify call is treated as a verification failure (`network`) — the abuse path is to skip the challenge, so "can't reach CloudFlare" defaults to "refuse the request" when `TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`. """ from __future__ import annotations import logging import os from dataclasses import dataclass import httpx log = logging.getLogger(__name__) SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" def _secret() -> str: return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip() def _required() -> bool: raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower() return raw in ("1", "true", "yes", "on") def _siteverify_url() -> str: return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL async def _siteverify_post(url: str, data: dict) -> httpx.Response: """Perform the siteverify POST on an `httpx.AsyncClient`. Isolated as a narrow seam (I4, security-audit-0026): the call is awaited so a slow CloudFlare response can't block the event loop, and tests patch *this function* rather than the shared `httpx.AsyncClient` (which other modules — gitea, docs — also construct, so a global patch would break app boot). """ async with httpx.AsyncClient(timeout=10.0) as client: return await client.post(url, data=data) @dataclass class VerifyOutcome: """Result of a Turnstile siteverify call. `ok`: the request **may proceed**. True both for "siteverify said success" and for "no secret configured AND not required" (the dev/test soft-fail path). `reason`: one of * 'ok' — siteverify returned success. * 'skipped' — no secret configured, TURNSTILE_REQUIRED=false. The gate is open; the endpoint admits the request. * 'misconfigured' — TURNSTILE_REQUIRED=true but no secret in env. The endpoint fails closed with 500. * 'missing-token' — the client did not send a token at all and verification is required. * 'failed' — siteverify returned success=false. The endpoint refuses with 400. * 'network' — siteverify call raised. Treated as a failure under TURNSTILE_REQUIRED=true. """ ok: bool reason: str async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome: """Validate a Turnstile token against CloudFlare's siteverify endpoint. Returns a VerifyOutcome describing whether the calling endpoint should proceed. The endpoint maps `ok=False` to an HTTP status per the `reason`: * 'misconfigured' → 500 "auth misconfigured" * 'missing-token' / 'failed' / 'network' → 400 "verification failed" Async (I4, security-audit-0026): the siteverify call is awaited on an `httpx.AsyncClient` so a slow CloudFlare response can't block the event loop (the prior synchronous `httpx.post` stalled the single worker for up to the 10s timeout). Callers must `await` it. Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid touching the real CloudFlare endpoint and to keep the patch off the shared `httpx.AsyncClient`. No real keys are ever embedded in tests. """ secret = _secret() required = _required() if not secret: if required: log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed") return VerifyOutcome(ok=False, reason="misconfigured") # Dev/test/soft-fail path: no secret, not required → gate is open. return VerifyOutcome(ok=True, reason="skipped") if not token or not token.strip(): # Secret is set, so verification is in force. A missing token # is a hard refuse — the frontend should have rendered the # widget and collected one. return VerifyOutcome(ok=False, reason="missing-token") data = {"secret": secret, "response": token.strip()} if client_ip: data["remoteip"] = client_ip try: response = await _siteverify_post(_siteverify_url(), data) payload = response.json() except Exception as exc: # network, JSON parse, etc. log.warning("Turnstile siteverify call failed: %s", exc) return VerifyOutcome(ok=False, reason="network") if payload.get("success") is True: return VerifyOutcome(ok=True, reason="ok") # `error-codes` is a list of strings on failure; we log the codes # for the operator without surfacing them to the client. log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes")) return VerifyOutcome(ok=False, reason="failed")