From b3f1b15f65be8fa226989a317d52e4d688e8de41 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 03:34:39 -0700 Subject: [PATCH] Release 0.12.0: CloudFlare Turnstile on OTC email-entry --- CHANGELOG.md | 145 +++++++++++++ SPEC.md | 60 ++++-- VERSION | 2 +- backend/.env.example | 18 ++ backend/app/main.py | 31 ++- backend/app/turnstile.py | 148 +++++++++++++ backend/tests/test_turnstile_vertical.py | 220 ++++++++++++++++++++ frontend/.env.example | 13 ++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api.js | 12 +- frontend/src/components/Login.jsx | 96 ++++++++- frontend/src/components/TurnstileWidget.jsx | 120 +++++++++++ 13 files changed, 833 insertions(+), 38 deletions(-) create mode 100644 backend/app/turnstile.py create mode 100644 backend/tests/test_turnstile_vertical.py create mode 100644 frontend/src/components/TurnstileWidget.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a7653..25cd9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,150 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from (because their `cookie_consent` row does not yet exist); their current sessions remain valid. +## 0.12.0 — 2026-05-28 + +**Minor — operator action required (new secret + new overlay).** +CloudFlare Turnstile gates the email-entry step of the OTC sign-in +flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2- +settled). Since v0.7.0 made `/auth/otc/request` the primary human- +auth path and v0.8.0 opened the request endpoint to any valid email, +the OTC dispatch became the natural target for distributed scrapers +fanning out to harvest "this email is admitted vs. this email is +not" timing/bounce signals. The per-email cooldown stops the trivial +back-to-back loop; the Turnstile challenge stops the distributed one +by costing the attacker a browser-side proof-of-humanness on every +request. The challenge runs before the bcrypt hash + SMTP send so a +failed verify spends no rate budget and produces no envelope. + +Scope: the widget renders on the email-entry step of `/login` only. +The OTC verify step (where the user pastes the six-digit code) is +already bottlenecked on email delivery and protected by the +five-minute TTL + single-use consume on the row; a second challenge +there would double the rate budget against the same abuse path +without measurably more protection. If bots adapt to defeat the +email-entry challenge specifically — pushing the abuse vector onto +the verify step — a future release adds the second widget. The +widget also renders on the passcode step's "Use a code instead" +fallback dispatch since that route also calls `/auth/otc/request`. + +Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when +the secret is absent — the dev / test path, and the pre-rollout +path while the operator is wiring the secret. Once the secret is in +GCP Secret Manager and the site key is in the overlay, the operator +**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on +the secret fails loudly (HTTP 500 "auth misconfigured") instead of +silently disabling abuse defense. + +No schema migration — Turnstile siteverify is stateless. + +### Added + +- **`backend/app/turnstile.py`** — the siteverify caller. POSTs + `secret` + `response` (+ optional `remoteip`) to + `https://challenges.cloudflare.com/turnstile/v0/siteverify` and + returns a `VerifyOutcome` (`ok` boolean + `reason` enum: + `ok` / `skipped` / `misconfigured` / `missing-token` / `failed` / + `network`). Tunables read from env at call time so tests + monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`, + `TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`. +- **`frontend/src/components/TurnstileWidget.jsx`** — the React + wrapper around the official CloudFlare Turnstile JS API. Reads the + site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders + nothing when the var is unset (the form still submits and the + backend's `TURNSTILE_REQUIRED` policy decides admission). Loads + the CloudFlare script once per page on first widget mount. Cleans + up the widget instance on unmount via `turnstile.remove()` so a + remount produces a fresh challenge rather than reusing a stale, + already-consumed token. +- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) — + five vertical scenarios: happy path (secret + valid token → + admit), siteverify rejects → 400 + no envelope, missing-token → + 400 + no envelope, missing-secret-soft (default) → admit, and + missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500 + "misconfigured". All five mock the siteverify HTTP call via + `monkeypatch.setattr(turnstile.httpx, "post", …)`; no real + CloudFlare keys are ever embedded. +- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as + the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes. + +### Changed + +- **`backend/app/main.py`** — `OtcRequestBody` grows an optional + `turnstile_token` field. The `/auth/otc/request` handler calls + `turnstile.verify_token` first, before `otc.request_code`, so a + failed challenge spends no rate budget and produces no envelope. + The handler maps `misconfigured` → HTTP 500, all other failures + (`missing-token`, `failed`, `network`) → uniform HTTP 400 so the + response does not enumerate which leg of the challenge broke. +- **`frontend/src/api.js`** — `requestOtc` accepts a second arg + `{ turnstileToken }` and threads it into the request body. The + positional signature stays backwards-compatible so calls that pass + only an email still type-check. +- **`frontend/src/components/Login.jsx`** — the email step and the + passcode step both render ``. The submit button + on the email step is disabled until the widget produces a token + (when the widget is enabled at build time); the "Use a code + instead" link on the passcode step has the same gate. A 400 from + `/auth/otc/request` clears the token and surfaces a "couldn't + verify you're human, please retry" status. The fallback-from- + passcode path bounces back to the email step on 400 so the user + gets a fresh challenge in the natural place. +- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET` + and `TURNSTILE_REQUIRED` alongside the existing OTC tunables. +- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY` + with the operator wire-up procedure (dash.cloudflare.com → + Turnstile → Add site). + +### Upgrade steps (from 0.10.0) + +The operator **MUST** create a CloudFlare Turnstile site +(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget +mode), obtain the site key (public) and secret key (private), and: + +- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET` + (paste the secret key when prompted) before the v0.12.0 deploy. + The framework reads the secret at request time; deploying v0.12.0 + without the secret leaves the gate in its default soft-fail state + (every request admitted regardless of token), which means abuse + defense is silently off. +- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY ` + so the frontend build embeds the site key and the widget renders + on `/login`. The site key is public — it travels in the bundle and + appears in every browser — so this is the overlay (non-secret) + layer per the §3 invariant 1 split. Skipping this step leaves + `/login` with no widget; even after the operator sets the secret, + the backend would refuse every request as `missing-token` once + `TURNSTILE_REQUIRED=true` flips. +- You **MUST** rebuild the frontend and restart the backend after + upgrading. `frontend/package.json#version` and `VERSION` both move + to `0.12.0`. No schema migration; Turnstile siteverify is + stateless. The site-key embed is build-time, so the rebuild after + the `flotilla overlay set` is what actually wires the widget into + the bundle the deploy serves. +- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true` + once you've confirmed a real sign-in works end-to-end with the + widget. The default (`false`) keeps the gate in soft-fail mode so + a missing-secret regression admits requests rather than 500ing + every sign-in attempt; flipping to `true` makes a future config + drift on the secret fail loudly with HTTP 500 instead of silently + disabling abuse defense. The framework's tested path is the + flipped-to-true production shape; the default `false` exists for + the dev / pre-rollout window only. +- You **MAY** customize the Turnstile widget mode (Managed / + Non-interactive / Invisible) from the dashboard at any time + without redeploying — the site key stays the same, and the widget + picks up the mode change on the next page load. The framework's + tested path is "Managed" because it gives the operator a visible + challenge surface to debug against. + +If either of the two **MUST** secret/overlay steps is skipped, the +deploy still boots and `/login` still serves; the failure mode is +that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or +every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while +the secret is unset). The driver pauses the wave at the secret/ +overlay gesture so the operator confirms both are in place before +the framework version pin moves. + ## 0.11.0 — 2026-05-28 **Minor — schema migration required; no new env vars.** This release @@ -310,6 +454,7 @@ candidate walk. The raw token is never logged. opt-in on the next sign-in, and a user who never checks the box keeps the v0.10.0 behavior verbatim. + ## 0.10.0 — 2026-05-28 **Minor — schema migration required; new auth path is additive.** diff --git a/SPEC.md b/SPEC.md index 220f32a..14449cf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2788,9 +2788,19 @@ The follow-up session will refine this. A minimal starting set: silently if the email wasn't on the `allowed_emails` list (the v0.3.0 admission gate); v0.8.0 (item #6) removed that check — admission moved to `permission_state` on the freshly-provisioned - `users` row, asserted at the contributor gate. Per §19.2's - expected next session, this endpoint is the lead-up to the - Cloudflare-Turnstile abuse-mitigation overlay. + `users` row, asserted at the contributor gate. v0.12.0 (item #10) + gates this endpoint behind a CloudFlare Turnstile siteverify call: + the body carries an optional `turnstile_token` field, the server + POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/ + v0/siteverify` before the bcrypt hash + SMTP send, and a failed + challenge refuses with HTTP 400 spending no rate budget. Two env + vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret + Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When + the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is + open (the dev / pre-rollout path); when the secret is unset and + `TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500 + "auth misconfigured" so a future config drift fails loudly + instead of silently disabling abuse defense. - `POST /auth/otc/verify` — unauthenticated. Body carries `email` and `code`. Validates the bcrypt hash against the most-recent unconsumed non-expired row for the email, marks the row consumed, provisions @@ -3997,24 +4007,32 @@ Candidates surfaced during v0.8.0 (open beta-access request flow, hard-coding — name the tunable when a deployment wants it different rather than shipping a knob that has no operator asking for it. -- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.** - *Surfaced by v0.7.0 — the endpoint is now the new abuse hot - path.* Per-email cooldown stops the trivial loop; what it - doesn't stop is a distributed scrape 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). The roadmap item-#10 candidate gates - the request endpoint behind a one-step browser-side challenge - before the bcrypt hash + SMTP send. Open questions: which - provider (Turnstile is the default since it's free and - privacy-respecting; hCaptcha and reCAPTCHA are also viable); - how the deployment configures it (`TURNSTILE_SITE_KEY` + - `TURNSTILE_SECRET_KEY` env vars, gated by `if - config.turnstile_site_key:` at the handler so existing - deployments don't break); whether the verify endpoint also - gets a challenge (probably yes for parity); and how the test - harness mocks the challenge. Earns its session as the v0.12.0 - design pass. +- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in + v0.12.0 (roadmap item #10).* The OTC request endpoint is now + gated behind a Turnstile siteverify call: the frontend renders + the official widget on the `/login` email-entry step (and on the + passcode step for the "Use a code instead" fallback dispatch), + the captured token rides in the request body as + `turnstile_token`, and the backend POSTs `secret` + `response` + to `challenges.cloudflare.com/turnstile/v0/siteverify` before + the bcrypt hash + SMTP send. The widget renders only on the + email-entry / passcode-fallback dispatch points — the OTC + verify step is bottlenecked on email delivery and protected by + the five-minute TTL + single-use row consume, so a second + challenge there would double the rate budget against the same + abuse path without measurably more protection (revisit if bots + adapt to the email-entry challenge specifically). Configured + via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and + `VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a + third var `TURNSTILE_REQUIRED` (default `false`) lets the + operator flip from "soft-fail when secret unset" (the dev / + pre-rollout shape) to "fail-closed when secret unset" (HTTP 500 + "auth misconfigured", the production-locked shape). Tests mock + the siteverify HTTP call at the `httpx.post` boundary in + `app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in + the v0.7.0 surfacing are still viable substitutes for a future + deployment that wants them but the framework's tested path is + Turnstile. Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 / roadmap item #8): diff --git a/VERSION b/VERSION index d9df1bb..ac454c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/backend/.env.example b/backend/.env.example index 779d2dc..b5bb476 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -92,3 +92,21 @@ OTC_TTL_MINUTES=10 # loud-failure shape so the abuse path is visible). Set to 0 to # disable the cooldown — useful for tests but never in production. OTC_REQUEST_COOLDOWN_SECONDS=60 + +# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) --- +# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add +# site. The site key (public) goes in `frontend/.env` as +# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is +# what the backend POSTs to /siteverify alongside the user's response +# token. Leave both unset for dev/test paths; the gate stays open when +# the secret is absent AND TURNSTILE_REQUIRED=false (the default). +CLOUDFLARE_TURNSTILE_SECRET= + +# When `true`, /auth/otc/request fails closed (HTTP 500 "auth +# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false` +# (the default), a missing secret skips verification — useful in dev +# and during the pre-rollout window when the operator hasn't wired +# the secret yet. Flip to `true` once the secret is wired so a future +# config drift surfaces as a loud 500 rather than a silent abuse- +# defense disablement. +TURNSTILE_REQUIRED=false diff --git a/backend/app/main.py b/backend/app/main.py index bdc11d6..33e6422 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,6 +27,7 @@ from . import ( otc, passcode as passcode_mod, providers as providers_mod, + turnstile, webhooks, ) from .bot import Bot @@ -39,6 +40,14 @@ log = logging.getLogger("rfc_app") class OtcRequestBody(BaseModel): email: str = Field(min_length=3, max_length=320) + # v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the + # frontend widget. Optional in the body so a deployment that has + # not yet wired the Turnstile site key (or a dev environment with + # the widget intentionally skipped) still routes through the same + # endpoint; the backend turnstile.verify_token call decides whether + # to admit the request based on `TURNSTILE_REQUIRED` + presence of + # the secret. + turnstile_token: str | None = Field(default=None, max_length=4096) class OtcVerifyBody(BaseModel): @@ -215,7 +224,27 @@ def _oauth_router(config) -> APIRouter: # --------------------------------------------------------------- @router.post("/auth/otc/request") - async def otc_request(body: OtcRequestBody): + async def otc_request(body: OtcRequestBody, request: Request): + # v0.12.0 / roadmap item #10: gate the request on a successful + # Turnstile siteverify before the bcrypt hash + SMTP send. The + # check runs first so a failed challenge spends no rate budget + # and produces no envelope. When the operator has not wired the + # secret AND TURNSTILE_REQUIRED=false (the default), the gate + # opens — see `backend/app/turnstile.py` for the full matrix. + client_ip = request.client.host if request.client else None + ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip) + if not ts.ok: + if ts.reason == "misconfigured": + # TURNSTILE_REQUIRED=true but the secret is unset. This + # is an operator/config problem, not a client problem; + # surface as 500 so the operator notices in their logs + # rather than blaming the user's browser. + raise HTTPException(500, "auth misconfigured") + # missing-token / failed / network → uniform 400 so the + # response does not enumerate which leg of the challenge + # broke. The reason is in the server logs. + raise HTTPException(400, "verification failed") + outcome = otc.request_code(body.email) if outcome.reason == "cooldown": # Loud failure per the rate-limit primitive — the abuse diff --git a/backend/app/turnstile.py b/backend/app/turnstile.py new file mode 100644 index 0000000..d8efc24 --- /dev/null +++ b/backend/app/turnstile.py @@ -0,0 +1,148 @@ +"""§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 + + +@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 + + +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" + + Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL` + + a MockTransport client) to avoid touching the real CloudFlare + endpoint. 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 = httpx.post(_siteverify_url(), data=data, timeout=10.0) + 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") diff --git a/backend/tests/test_turnstile_vertical.py b/backend/tests/test_turnstile_vertical.py new file mode 100644 index 0000000..db7316b --- /dev/null +++ b/backend/tests/test_turnstile_vertical.py @@ -0,0 +1,220 @@ +"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile +gate on `/auth/otc/request` (§6.2 / roadmap item #10). + +The release gates the OTC request endpoint behind a one-step +browser-side Turnstile challenge before the bcrypt hash + SMTP send. +The tests prove: + + * Happy path: with the secret set, a valid token admits the request + and the OTC envelope lands. + * Failure path: with the secret set, a token siteverify rejects + refuses the request with 400 and produces no envelope. + * Missing-token: with the secret set, a request without a token + refuses with 400. + * Missing-secret-soft: with the secret unset AND + `TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request + admits — this is the dev / "operator hasn't wired it yet" path. + * Missing-secret-hard: with the secret unset AND + `TURNSTILE_REQUIRED=true`, the request refuses with 500 + "auth misconfigured" — the production fail-closed path once + the operator has flipped the policy. + +The Turnstile siteverify call is mocked at the `httpx.post` boundary +inside `app.turnstile` so no real keys are needed and no real +CloudFlare call is made. The Gitea fakes from `test_propose_vertical` +remain in scope so the rest of the app boots cleanly. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + tmp_env, +) + + +def _reset_outbound(): + from app import email as email_mod + email_mod.reset_sent_envelopes() + + +def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]: + from app import email as email_mod + out = [] + for env in email_mod.sent_envelopes(): + if env.get("kind") != "otc": + continue + if to_address is not None and env["to"] != to_address: + continue + out.append(env) + return out + + +def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None): + """Replace `httpx.post` inside `app.turnstile` with a stub that + returns the requested success shape. The stub does not touch the + real CloudFlare endpoint and never sees a real secret. + """ + captured = {} + + def fake_post(url, *, data=None, timeout=None, **kwargs): + captured["url"] = url + captured["data"] = data + body = {"success": bool(success)} + if error_codes is not None: + body["error-codes"] = error_codes + return SimpleNamespace(json=lambda: body) + + from app import turnstile as turnstile_mod + monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post) + return captured + + +# --------------------------------------------------------------------------- +# Happy path: secret set, token valid → admit + OTC envelope lands +# --------------------------------------------------------------------------- + + +def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real") + monkeypatch.setenv("TURNSTILE_REQUIRED", "true") + captured = _patch_siteverify(monkeypatch, success=True) + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r = client.post( + "/auth/otc/request", + json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"}, + ) + assert r.status_code == 200, r.text + # The siteverify call was made with the secret + the token we sent. + assert captured["data"]["secret"] == "test-secret-not-real" + assert captured["data"]["response"] == "fake-token-abc" + # And the OTC dispatch ran — exactly one envelope to the address. + envs = _outbound_otc_envelopes("alice@example.com") + assert len(envs) == 1 + + +# --------------------------------------------------------------------------- +# Failure path: secret set, siteverify says success=false → 400 + no envelope +# --------------------------------------------------------------------------- + + +def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real") + monkeypatch.setenv("TURNSTILE_REQUIRED", "true") + _patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"]) + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r = client.post( + "/auth/otc/request", + json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"}, + ) + assert r.status_code == 400, r.text + # The OTC bcrypt + SMTP path did not run — no envelope was buffered. + assert _outbound_otc_envelopes("alice@example.com") == [] + + +# --------------------------------------------------------------------------- +# Missing-token: secret set, no token → 400 + no envelope +# --------------------------------------------------------------------------- + + +def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real") + monkeypatch.setenv("TURNSTILE_REQUIRED", "true") + # Even though we patch httpx.post, the missing-token check fires + # before the siteverify call — so the patch is here only as a + # safety net in case the implementation regresses to making the + # network call anyway. + _patch_siteverify(monkeypatch, success=False) + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r = client.post( + "/auth/otc/request", + json={"email": "alice@example.com"}, # no turnstile_token field at all + ) + assert r.status_code == 400, r.text + assert _outbound_otc_envelopes("alice@example.com") == [] + + +# --------------------------------------------------------------------------- +# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit +# --------------------------------------------------------------------------- + + +def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch): + """v0.12.0 default: the operator has not yet wired the Turnstile + secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays + open — this is the dev / test / pre-rollout path. Once the + operator confirms the secret is in place and flips + `TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed + (covered in test_otc_request_refuses_when_required_but_secret_unset). + """ + from fastapi.testclient import TestClient + + monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False) + monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False) + # The httpx.post inside turnstile must not be called in this path — + # patch it to a sentinel that explodes if it ever runs. + from app import turnstile as turnstile_mod + + def must_not_be_called(*a, **kw): + raise AssertionError("siteverify should not run when no secret is configured") + + monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called) + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r = client.post( + "/auth/otc/request", + json={"email": "alice@example.com"}, + ) + assert r.status_code == 200, r.text + # The OTC path ran end-to-end — one envelope to the address. + assert len(_outbound_otc_envelopes("alice@example.com")) == 1 + + +# --------------------------------------------------------------------------- +# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured" +# --------------------------------------------------------------------------- + + +def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch): + """Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock + down production, a missing secret stops being a soft-fail and + becomes a fail-closed 500. This is the regression-detection shape + the §20.4 upgrade-steps MAY block calls out — flip the flag once + the secret is wired so a future config drift fails loudly instead + of silently disabling abuse defense. + """ + from fastapi.testclient import TestClient + + monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False) + monkeypatch.setenv("TURNSTILE_REQUIRED", "true") + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r = client.post( + "/auth/otc/request", + json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"}, + ) + assert r.status_code == 500, r.text + assert _outbound_otc_envelopes("alice@example.com") == [] diff --git a/frontend/.env.example b/frontend/.env.example index 5a85901..147b48d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -49,3 +49,16 @@ VITE_PRIVACY_POLICY_URL= # Examples: # VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies VITE_COOKIES_POLICY_URL= + +# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public). +# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add +# site. The site key (this var) is embedded into the frontend bundle at +# build time and rendered by the Turnstile widget on the /login email- +# entry step. The secret key (private) lives in the backend env as +# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset +# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy +# decides what happens to a tokenless request. +# +# Examples: +# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA... +VITE_TURNSTILE_SITE_KEY= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0ecbd47..2fd0758 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 54ef2fa..69fda27 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.11.0", + "version": "0.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.js b/frontend/src/api.js index 6a48b89..2dd1e79 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -31,11 +31,19 @@ export async function getMe() { // migration — the new UI just no longer points at it primarily. These // two helpers drive the Login.jsx surface. -export async function requestOtc(email) { +export async function requestOtc(email, { turnstileToken } = {}) { + // v0.12.0 / roadmap item #10: when the Turnstile widget has produced + // a token, send it alongside the email so the backend can siteverify + // before the OTC dispatch. The backend treats a missing token as + // either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false) + // or hard-fail (verification required) — the frontend stays + // uninvolved in the policy. + const body = { email } + if (turnstileToken) body.turnstile_token = turnstileToken const res = await fetch('/auth/otc/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify(body), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index 5e0e2c6..7440f00 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -1,7 +1,17 @@ -// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0 -// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request -// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5) -// established the email + OTC scaffolding both releases extended. +// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0 +// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) / +// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0 +// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0 +// (roadmap item #5) established the email + OTC scaffolding the later +// releases extended. +// +// v0.12.0: the email-entry step renders a Turnstile widget. The token +// it produces is sent to `/auth/otc/request` alongside the email. The +// passcode step also renders a widget for the "Use a code instead" +// fallback dispatch (same backend endpoint, same gate). When the +// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders +// nothing — the form still submits and the backend's TURNSTILE_REQUIRED +// policy decides admission. // // Four-to-six-step flow (most users see three; the longest path is // pending-user with no passcode, who never sees the passcode steps): @@ -74,6 +84,7 @@ import { setPasscode as apiSetPasscode, startDeviceTrust, } from '../api' +import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget' export default function Login() { // Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after @@ -98,6 +109,18 @@ export default function Login() { const [reason, setReason] = useState('') const [status, setStatus] = useState('') const [busy, setBusy] = useState(false) + // v0.12.0: Turnstile token captured by the widget. `null` means no + // challenge solved yet (or the site key is unset, in which case the + // widget surfaces null on mount). The token is single-use; we clear + // it back to null right after we send it so a second request on the + // same form remount re-challenges. `turnstileReady` is true once the + // widget has produced a token OR the widget is not configured at + // build time (no site key) — the submit button reads from it so the + // form locks up when the operator has wired Turnstile but the user + // hasn't solved the challenge yet. + const [turnstileToken, setTurnstileToken] = useState(null) + const turnstileOn = turnstileEnabled() + const turnstileReady = !turnstileOn || !!turnstileToken const emailRef = useRef(null) const codeRef = useRef(null) const passcodeRef = useRef(null) @@ -149,13 +172,22 @@ export default function Login() { setStep('passcode') setStatus('') } else { - await requestOtc(email.trim()) + await requestOtc(email.trim(), { turnstileToken }) + // v0.12.0: the token is single-use; drop it so a re-request + // from the code step (via "Use a different email" → back to + // email) starts with a fresh challenge. + setTurnstileToken(null) setStep('code') setStatus('Check your inbox — a six-digit code is on the way.') } } catch (err) { + // Any failure consumes the token from CloudFlare's side; clear + // so the widget re-renders a fresh challenge on retry. + setTurnstileToken(null) if (err.status === 429) { setStatus('Slow down — wait a minute before requesting another code.') + } else if (err.status === 400) { + setStatus("Couldn't verify you're human. Please retry the challenge.") } else { setStatus(err.message || 'Could not start sign-in. Try again.') } @@ -186,17 +218,29 @@ export default function Login() { // fresh code in the user's inbox immediately. setPasscode('') try { - await requestOtc(email.trim()) + // v0.12.0: pass whatever token the widget on the passcode + // step has produced. If the operator has Turnstile required + // and the user hasn't solved the passcode-step widget, the + // backend refuses and we bounce them back to email-entry + // with a clear status (see catch below). + await requestOtc(email.trim(), { turnstileToken }) + setTurnstileToken(null) setStep('code') setStatus( 'Too many failed attempts. We sent a one-time code to your email — use it to sign in.', ) } catch (e2) { + setTurnstileToken(null) if (e2.status === 429) { setStep('code') setStatus( 'Too many failed attempts. Wait a minute, then request a one-time code to sign in.', ) + } else if (e2.status === 400) { + setStep('email') + setStatus( + 'Too many failed attempts. Solve the challenge below to receive a one-time code.', + ) } else { setStatus( 'Too many failed attempts. Use the "Use a code instead" link to sign in via email.', @@ -344,17 +388,27 @@ export default function Login() { async function fallbackToOtc() { // Manual "Use a code instead" from the passcode step. Same shape - // as the email-step OTC dispatch. + // as the email-step OTC dispatch. v0.12.0: pass through whatever + // Turnstile token the passcode-step widget has produced (or null + // when the widget is disabled at build time). setBusy(true) setStatus('') try { - await requestOtc(email.trim()) + await requestOtc(email.trim(), { turnstileToken }) + setTurnstileToken(null) setPasscode('') setStep('code') setStatus('Check your inbox — a six-digit code is on the way.') } catch (err) { + setTurnstileToken(null) if (err.status === 429) { setStatus('Slow down — wait a minute before requesting another code.') + } else if (err.status === 400) { + // v0.12.0: the widget rejected or no token was sent. Bounce + // the user back to the email step so they get a fresh + // challenge alongside the email input. + setStep('email') + setStatus("Couldn't verify you're human. Please retry the challenge.") } else { setStatus(err.message || 'Could not request a code. Try again.') } @@ -387,7 +441,18 @@ export default function Login() { required disabled={busy} /> - @@ -421,6 +486,17 @@ export default function Login() { /> Trust this device for 30 days + {/* + v0.12.0: a second Turnstile widget for the + "Use a code instead" fallback dispatch. The passcode + verify path does not consume a Turnstile token (it has + its own 5-attempt lockout shape from v0.10.0), but if + the user falls back to OTC the same /auth/otc/request + endpoint runs and needs a token. We render the widget + on this step too so the fallback works without bouncing + back to email-entry first. + */} +
diff --git a/frontend/src/components/TurnstileWidget.jsx b/frontend/src/components/TurnstileWidget.jsx new file mode 100644 index 0000000..64fa5e0 --- /dev/null +++ b/frontend/src/components/TurnstileWidget.jsx @@ -0,0 +1,120 @@ +// TurnstileWidget.jsx — v0.12.0 / roadmap item #10. +// +// Renders the CloudFlare Turnstile JS widget on the email-entry step of +// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY` +// (Vite convention — VITE_* prefix is build-time embedded). When the +// site key is unset/empty, this component renders nothing and reports +// a `null` token through `onToken` so the parent form can still submit. +// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a +// request that arrives without a token; the frontend is intentionally +// not in that loop. See `backend/app/turnstile.py` for the matrix. +// +// The CloudFlare script is loaded once per page on first widget mount. +// Subsequent mounts (e.g. user goes back to email-entry after a failed +// OTC request) reuse the script tag and re-render the widget on the +// fresh container `div`. Unmounting removes the widget instance via +// `turnstile.remove(widgetId)` so a remount produces a new challenge +// rather than reusing a stale, already-consumed token. +// +// Turnstile contract: +// * `data-callback` fires with the token string on a successful +// challenge; the token is single-use and expires after ~5 minutes. +// * `data-error-callback` fires on a failed challenge (network, +// blocked, etc.); we surface a `null` token so the parent shows +// a retry hint. +// * `data-expired-callback` fires when the token times out before +// submission; we also drop to `null` and re-render so the user +// gets a fresh challenge on retry. +// +// We do **not** import the CloudFlare script at build time; loading it +// dynamically here keeps the bundle clean of an external request the +// page may not need (anonymous viewers reading RFCs never see Login). + +import { useEffect, useRef } from 'react' + +const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js' +const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' + +// Promise-keyed: only one script tag, only one resolution chain. +let scriptLoadPromise = null + +function loadTurnstileScript() { + if (typeof window === 'undefined') return Promise.resolve(null) + if (window.turnstile) return Promise.resolve(window.turnstile) + if (scriptLoadPromise) return scriptLoadPromise + + scriptLoadPromise = new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`) + if (existing) { + existing.addEventListener('load', () => resolve(window.turnstile)) + existing.addEventListener('error', reject) + return + } + const script = document.createElement('script') + script.src = TURNSTILE_SCRIPT_URL + script.async = true + script.defer = true + script.addEventListener('load', () => resolve(window.turnstile)) + script.addEventListener('error', reject) + document.head.appendChild(script) + }) + return scriptLoadPromise +} + +export function turnstileEnabled() { + return !!SITE_KEY +} + +export default function TurnstileWidget({ onToken, theme = 'auto' }) { + const containerRef = useRef(null) + const widgetIdRef = useRef(null) + + useEffect(() => { + if (!SITE_KEY) { + // No site key configured → surface a null token immediately so + // the parent form's submit-disabled gate doesn't lock up + // waiting on a challenge that will never arrive. The backend + // decides whether a tokenless request is admitted. + onToken?.(null) + return undefined + } + + let cancelled = false + loadTurnstileScript() + .then(turnstile => { + if (cancelled || !turnstile || !containerRef.current) return + widgetIdRef.current = turnstile.render(containerRef.current, { + sitekey: SITE_KEY, + theme, + callback: token => onToken?.(token), + 'error-callback': () => onToken?.(null), + 'expired-callback': () => onToken?.(null), + }) + }) + .catch(() => { + // Script load failure — surface null so the parent can decide + // what to do (today: still let submit through; the backend + // policy decides admission). + if (!cancelled) onToken?.(null) + }) + + return () => { + cancelled = true + if (widgetIdRef.current && window.turnstile) { + try { + window.turnstile.remove(widgetIdRef.current) + } catch (_) { + // Already gone or never registered — nothing to clean up. + } + widgetIdRef.current = null + } + } + // We intentionally do not list `onToken` in the dependency array; + // a parent re-rendering with a fresh closure should not tear down + // and rebuild the widget (which would consume a fresh challenge). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!SITE_KEY) return null + return
+}