Release 0.12.0: CloudFlare Turnstile on OTC email-entry
This commit is contained in:
+145
@@ -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
|
(because their `cookie_consent` row does not yet exist); their
|
||||||
current sessions remain valid.
|
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.11.0 — 2026-05-28
|
## 0.11.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; no new env vars.** This release
|
**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
|
opt-in on the next sign-in, and a user who never checks the box
|
||||||
keeps the v0.10.0 behavior verbatim.
|
keeps the v0.10.0 behavior verbatim.
|
||||||
|
|
||||||
|
|
||||||
## 0.10.0 — 2026-05-28
|
## 0.10.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; new auth path is additive.**
|
**Minor — schema migration required; new auth path is additive.**
|
||||||
|
|||||||
@@ -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
|
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 —
|
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
|
||||||
admission moved to `permission_state` on the freshly-provisioned
|
admission moved to `permission_state` on the freshly-provisioned
|
||||||
`users` row, asserted at the contributor gate. Per §19.2's
|
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
|
||||||
expected next session, this endpoint is the lead-up to the
|
gates this endpoint behind a CloudFlare Turnstile siteverify call:
|
||||||
Cloudflare-Turnstile abuse-mitigation overlay.
|
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
|
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
|
||||||
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
||||||
non-expired row for the email, marks the row consumed, provisions
|
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
|
hard-coding — name the tunable when a deployment wants it
|
||||||
different rather than shipping a knob that has no operator
|
different rather than shipping a knob that has no operator
|
||||||
asking for it.
|
asking for it.
|
||||||
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
|
||||||
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
v0.12.0 (roadmap item #10).* The OTC request endpoint is now
|
||||||
path.* Per-email cooldown stops the trivial loop; what it
|
gated behind a Turnstile siteverify call: the frontend renders
|
||||||
doesn't stop is a distributed scrape that fans out across a
|
the official widget on the `/login` email-entry step (and on the
|
||||||
large invitee list to harvest the "this email is admitted vs.
|
passcode step for the "Use a code instead" fallback dispatch),
|
||||||
this email is not" signal indirectly (timing differences, SMTP
|
the captured token rides in the request body as
|
||||||
bounce-rate observation). The roadmap item-#10 candidate gates
|
`turnstile_token`, and the backend POSTs `secret` + `response`
|
||||||
the request endpoint behind a one-step browser-side challenge
|
to `challenges.cloudflare.com/turnstile/v0/siteverify` before
|
||||||
before the bcrypt hash + SMTP send. Open questions: which
|
the bcrypt hash + SMTP send. The widget renders only on the
|
||||||
provider (Turnstile is the default since it's free and
|
email-entry / passcode-fallback dispatch points — the OTC
|
||||||
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
|
verify step is bottlenecked on email delivery and protected by
|
||||||
how the deployment configures it (`TURNSTILE_SITE_KEY` +
|
the five-minute TTL + single-use row consume, so a second
|
||||||
`TURNSTILE_SECRET_KEY` env vars, gated by `if
|
challenge there would double the rate budget against the same
|
||||||
config.turnstile_site_key:` at the handler so existing
|
abuse path without measurably more protection (revisit if bots
|
||||||
deployments don't break); whether the verify endpoint also
|
adapt to the email-entry challenge specifically). Configured
|
||||||
gets a challenge (probably yes for parity); and how the test
|
via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
|
||||||
harness mocks the challenge. Earns its session as the v0.12.0
|
`VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
|
||||||
design pass.
|
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 /
|
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
|
||||||
roadmap item #8):
|
roadmap item #8):
|
||||||
|
|||||||
@@ -92,3 +92,21 @@ OTC_TTL_MINUTES=10
|
|||||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||||
# disable the cooldown — useful for tests but never in production.
|
# disable the cooldown — useful for tests but never in production.
|
||||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
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
|
||||||
|
|||||||
+30
-1
@@ -27,6 +27,7 @@ from . import (
|
|||||||
otc,
|
otc,
|
||||||
passcode as passcode_mod,
|
passcode as passcode_mod,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
|
turnstile,
|
||||||
webhooks,
|
webhooks,
|
||||||
)
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
@@ -39,6 +40,14 @@ log = logging.getLogger("rfc_app")
|
|||||||
|
|
||||||
class OtcRequestBody(BaseModel):
|
class OtcRequestBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
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):
|
class OtcVerifyBody(BaseModel):
|
||||||
@@ -215,7 +224,27 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/auth/otc/request")
|
@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)
|
outcome = otc.request_code(body.email)
|
||||||
if outcome.reason == "cooldown":
|
if outcome.reason == "cooldown":
|
||||||
# Loud failure per the rate-limit primitive — the abuse
|
# Loud failure per the rate-limit primitive — the abuse
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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") == []
|
||||||
@@ -49,3 +49,16 @@ VITE_PRIVACY_POLICY_URL=
|
|||||||
# Examples:
|
# Examples:
|
||||||
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
||||||
VITE_COOKIES_POLICY_URL=
|
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=
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+10
-2
@@ -31,11 +31,19 @@ export async function getMe() {
|
|||||||
// migration — the new UI just no longer points at it primarily. These
|
// migration — the new UI just no longer points at it primarily. These
|
||||||
// two helpers drive the Login.jsx surface.
|
// 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', {
|
const res = await fetch('/auth/otc/request', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0
|
// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0
|
||||||
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
|
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
|
||||||
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
|
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
|
||||||
// established the email + OTC scaffolding both releases extended.
|
// (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
|
// Four-to-six-step flow (most users see three; the longest path is
|
||||||
// pending-user with no passcode, who never sees the passcode steps):
|
// pending-user with no passcode, who never sees the passcode steps):
|
||||||
@@ -74,6 +84,7 @@ import {
|
|||||||
setPasscode as apiSetPasscode,
|
setPasscode as apiSetPasscode,
|
||||||
startDeviceTrust,
|
startDeviceTrust,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||||
@@ -98,6 +109,18 @@ export default function Login() {
|
|||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
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 emailRef = useRef(null)
|
||||||
const codeRef = useRef(null)
|
const codeRef = useRef(null)
|
||||||
const passcodeRef = useRef(null)
|
const passcodeRef = useRef(null)
|
||||||
@@ -149,13 +172,22 @@ export default function Login() {
|
|||||||
setStep('passcode')
|
setStep('passcode')
|
||||||
setStatus('')
|
setStatus('')
|
||||||
} else {
|
} 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')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
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 {
|
} else {
|
||||||
setStatus(err.message || 'Could not start sign-in. Try again.')
|
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.
|
// fresh code in the user's inbox immediately.
|
||||||
setPasscode('')
|
setPasscode('')
|
||||||
try {
|
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')
|
setStep('code')
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||||
)
|
)
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
|
setTurnstileToken(null)
|
||||||
if (e2.status === 429) {
|
if (e2.status === 429) {
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
'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 {
|
} else {
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
'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() {
|
async function fallbackToOtc() {
|
||||||
// Manual "Use a code instead" from the passcode step. Same shape
|
// 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)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await requestOtc(email.trim())
|
await requestOtc(email.trim(), { turnstileToken })
|
||||||
|
setTurnstileToken(null)
|
||||||
setPasscode('')
|
setPasscode('')
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setTurnstileToken(null)
|
||||||
if (err.status === 429) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
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 {
|
} else {
|
||||||
setStatus(err.message || 'Could not request a code. Try again.')
|
setStatus(err.message || 'Could not request a code. Try again.')
|
||||||
}
|
}
|
||||||
@@ -387,7 +441,18 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={busy || !email.trim()}>
|
{/*
|
||||||
|
v0.12.0: CloudFlare Turnstile widget. Renders nothing
|
||||||
|
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
|
||||||
|
defaults to true in that case so the submit gate doesn't
|
||||||
|
lock up). On every challenge the widget calls onToken
|
||||||
|
with the fresh token; we feed it to /auth/otc/request.
|
||||||
|
*/}
|
||||||
|
<TurnstileWidget onToken={setTurnstileToken} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !email.trim() || !turnstileReady}
|
||||||
|
>
|
||||||
{busy ? 'Checking…' : 'Continue'}
|
{busy ? 'Checking…' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -421,6 +486,17 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
<span>Trust this device for 30 days</span>
|
<span>Trust this device for 30 days</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/*
|
||||||
|
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.
|
||||||
|
*/}
|
||||||
|
<TurnstileWidget onToken={setTurnstileToken} />
|
||||||
<div className="otc-actions">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
@@ -429,7 +505,7 @@ export default function Login() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-link-quiet"
|
className="btn-link-quiet"
|
||||||
onClick={fallbackToOtc}
|
onClick={fallbackToOtc}
|
||||||
disabled={busy}
|
disabled={busy || !turnstileReady}
|
||||||
>
|
>
|
||||||
Use a code instead
|
Use a code instead
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 <div ref={containerRef} className="turnstile-widget" />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user