Release 0.12.0: CloudFlare Turnstile on OTC email-entry

This commit is contained in:
Ben Stull
2026-05-28 03:34:39 -07:00
parent 6fb68a95c7
commit b3f1b15f65
13 changed files with 833 additions and 38 deletions
+30 -1
View File
@@ -27,6 +27,7 @@ from . import (
otc,
passcode as passcode_mod,
providers as providers_mod,
turnstile,
webhooks,
)
from .bot import Bot
@@ -39,6 +40,14 @@ log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
# frontend widget. Optional in the body so a deployment that has
# not yet wired the Turnstile site key (or a dev environment with
# the widget intentionally skipped) still routes through the same
# endpoint; the backend turnstile.verify_token call decides whether
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
# the secret.
turnstile_token: str | None = Field(default=None, max_length=4096)
class OtcVerifyBody(BaseModel):
@@ -215,7 +224,27 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody):
async def otc_request(body: OtcRequestBody, request: Request):
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
# and produces no envelope. When the operator has not wired the
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
# is an operator/config problem, not a client problem;
# surface as 500 so the operator notices in their logs
# rather than blaming the user's browser.
raise HTTPException(500, "auth misconfigured")
# missing-token / failed / network → uniform 400 so the
# response does not enumerate which leg of the challenge
# broke. The reason is in the server logs.
raise HTTPException(400, "verification failed")
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse