Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7228d27f4 |
+144
@@ -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.**
|
||||
|
||||
@@ -2741,9 +2741,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
|
||||
@@ -3871,24 +3881,32 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
||||
— v0.10.0's passcode-change and passcode-clear gestures are the
|
||||
v1 instances — or only survives explicit logout. Earns its
|
||||
session as the v0.11.0 design pass.
|
||||
- **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):
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-1
@@ -26,6 +26,7 @@ from . import (
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
from .bot import Bot
|
||||
@@ -38,6 +39,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):
|
||||
@@ -164,7 +173,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
|
||||
|
||||
@@ -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:
|
||||
# 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=
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"version": "0.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+10
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
@@ -73,6 +83,7 @@ import {
|
||||
verifyPasscode,
|
||||
setPasscode as apiSetPasscode,
|
||||
} from '../api'
|
||||
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||
|
||||
export default function Login() {
|
||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||
@@ -90,6 +101,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)
|
||||
@@ -119,13 +142,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.')
|
||||
}
|
||||
@@ -156,17 +188,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.',
|
||||
@@ -314,17 +358,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.')
|
||||
}
|
||||
@@ -357,7 +411,18 @@ export default function Login() {
|
||||
required
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
@@ -378,6 +443,17 @@ export default function Login() {
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
{/*
|
||||
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">
|
||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
@@ -386,7 +462,7 @@ export default function Login() {
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={fallbackToOtc}
|
||||
disabled={busy}
|
||||
disabled={busy || !turnstileReady}
|
||||
>
|
||||
Use a code instead
|
||||
</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