Release 0.12.0: CloudFlare Turnstile on OTC email-entry

This commit is contained in:
Ben Stull
2026-05-28 03:34:39 -07:00
parent de28272914
commit f7228d27f4
13 changed files with 832 additions and 38 deletions
+144
View File
@@ -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 `<TurnstileWidget>`. 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 <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.10.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**