From 91b8e24d6b9017f2d2c715e17d0d75f839b997c3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 05:56:44 +0200 Subject: [PATCH] feat: hCaptcha replaces Turnstile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One verifier, backend/app/services/captcha.py, and one widget, components/Captcha.jsx. There were two copies of each and they had drifted: the register widget loaded the script itself while the landing one relied on a page-level effect elsewhere in its file, and on the backend auth failed *open* on an unreachable Turnstile while contact failed *shut*. Both failure modes were kept rather than one quietly chosen, as an explicit `fail_open` argument with the reason written down: an outage that stops people creating accounts costs the site its users, while an outage that bounces a contact message costs the sender one retry. An unconfigured secret still skips verification entirely, as before, so a site with no keys keeps working. The keys in .env are empty. The Cloudflare ones there were live and are now dead, so **there is no captcha on register or contact until hCaptcha keys are issued** — this is not a state to leave a public site in. Also corrected on the way: docs/frontend.md still documented `login(email, password, turnstileToken)`, whose third argument had already gone from AuthContext. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- ADMIN.md | 2 +- README.md | 79 ++++++----- backend/.env.example | 5 + backend/tests/test_captcha.py | 148 ++++++++++++++++++++ backend/tests/test_login_without_captcha.py | 35 +++-- docs/TODO.md | 3 +- docs/api-reference.md | 13 +- docs/architecture.md | 6 +- docs/deployment.md | 4 +- docs/frontend.md | 14 +- docs/quiz-revamp-progress.md | 12 ++ frontend/docker-entrypoint.sh | 2 +- frontend/nginx.conf | 2 +- frontend/src/components/Captcha.css | 3 + frontend/src/components/Captcha.jsx | 83 +++++++++++ frontend/src/components/Captcha.test.jsx | 71 ++++++++++ frontend/src/pages/LandingPage.jsx | 53 ++----- frontend/src/pages/LoginCaptcha.test.jsx | 33 +++-- frontend/src/pages/RegisterPage.jsx | 37 +---- 19 files changed, 448 insertions(+), 157 deletions(-) create mode 100644 backend/tests/test_captcha.py mode change 100644 => 100755 frontend/docker-entrypoint.sh create mode 100644 frontend/src/components/Captcha.css create mode 100644 frontend/src/components/Captcha.jsx create mode 100644 frontend/src/components/Captcha.test.jsx diff --git a/ADMIN.md b/ADMIN.md index cf671b7..548e73f 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -166,7 +166,7 @@ Key settings in `backend/.env`: | `MAIL_FROM` | Sender email for verification/reset emails | | `LITELLM_API_BASE` | LiteLLM proxy URL for AI features | | `LITELLM_API_KEY` | API key for LiteLLM proxy | -| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot protection | +| `HCAPTCHA_SITE_KEY` / `HCAPTCHA_SECRET_KEY` | hCaptcha bot protection | | `BBB_SERVER_URL` / `BBB_SECRET` | BigBlueButton integration for live sessions | | `OIDC_PROVIDER_URL` | OIDC discovery URL (see SSO section below) | | `OIDC_CLIENT_ID` | OAuth client ID from your identity provider | diff --git a/README.md b/README.md index 08f2513..a324898 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall - **Concurrent Quiz Protection**: Redis session locks prevent the same quiz from being resumed on multiple devices simultaneously - **Landing Page**: Integrated with the app — Sign In / Register open as modal overlays, shared Navbar - **PWA**: Installable on mobile/desktop (no caching — avoids stale JS issues) -- **Bot Protection**: Cloudflare Turnstile on registration and contact forms +- **Bot Protection**: hCaptcha on registration and contact forms - **Email Verification**: Required before first login; password reset via email - **Role System**: Admin / Moderator / User with optional rate-limit exemption (unthrottle) - **Admin User Management**: Delete users, change roles, toggle unthrottle — all from the admin dashboard @@ -39,7 +39,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall | TTS | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS | | Queue | Celery + Redis (4 fork workers) | | Email | SMTP (smtp2go or any SMTP server) | -| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) | +| Bot protection | hCaptcha (runtime-configurable, no rebuild needed) | For detailed architecture documentation, see [docs/architecture.md](docs/architecture.md). @@ -101,8 +101,8 @@ MAIL_PASSWORD= MAIL_FROM=noreply@yourdomain.com MAIL_STARTTLS=true -# Bot protection — Cloudflare Turnstile (backend secret) -TURNSTILE_SECRET_KEY= +# Bot protection — hCaptcha (backend secret) +HCAPTCHA_SECRET_KEY= # Contact form admin notifications ADMIN_EMAIL=admin@yourdomain.com @@ -121,8 +121,8 @@ DEFAULT_ADMIN_PASSWORD= ### Frontend (`frontend/.env`) ```env -# Bot protection — Cloudflare Turnstile (public site key) -TURNSTILE_SITE_KEY= +# Bot protection — hCaptcha (public site key) +HCAPTCHA_SITE_KEY= ``` The frontend env file is **not** baked into the Docker image at build time. Instead, `docker-entrypoint.sh` generates a `/config.js` file from the env vars when the container **starts**. This means: @@ -131,29 +131,31 @@ The frontend env file is **not** baked into the Docker image at build time. Inst - Switch captcha providers by updating the entrypoint script and the widget component - Remove bot protection by clearing the key (empty = disabled) -## Cloudflare Turnstile (Bot Protection) +## hCaptcha (Bot Protection) ### How it works -Turnstile protects the **registration** and **contact** forms from bot submissions. It does NOT require Cloudflare DNS/proxy — it works standalone on any domain. +hCaptcha protects the **registration** and **contact** forms from bot submissions. One shared component, `frontend/src/components/Captcha.jsx`, renders every widget; one backend service, `backend/app/services/captcha.py`, verifies every token. **Flow:** ``` -1. Page loads → Turnstile JS loads from challenges.cloudflare.com -2. Widget renders (invisible or interactive depending on risk score) +1. Page loads → Captcha component loads js.hcaptcha.com/1/api.js (once) +2. Widget renders where the component sits on the form 3. User completes challenge → widget calls onVerify(token) 4. Frontend stores token in state → Sign Up button becomes enabled -5. User submits form → token sent as `turnstile_token` in POST body -6. Backend receives token → POSTs to Cloudflare's siteverify API: - POST https://challenges.cloudflare.com/turnstile/v0/siteverify - Body: { secret: TURNSTILE_SECRET_KEY, response: turnstile_token } -7. Cloudflare returns { success: true/false } +5. User submits form → token sent as `captcha_token` in POST body +6. Backend receives token → POSTs to hCaptcha's siteverify API: + POST https://api.hcaptcha.com/siteverify + Body: { secret: HCAPTCHA_SECRET_KEY, response: captcha_token } +7. hCaptcha returns { success: true/false } 8. If false → 400 "Bot verification failed" 9. If true → registration proceeds normally ``` -**Where Turnstile is active:** +A solve expires after a couple of minutes; the widget says so, the component clears the stored token, and the submit button goes back to disabled rather than sending something that will be refused. + +**Where hCaptcha is active:** - Register form (modal overlay on landing page) - Register form (standalone `/register` page) - Contact form (landing page) @@ -161,31 +163,34 @@ Turnstile protects the **registration** and **contact** forms from bot submissio **Where it is NOT active (by design):** - Login — protected by IP-based rate limiting (10 attempts / 15 min) instead +**When hCaptcha itself is unreachable**, registration is let through and the contact form is not: an outage that blocks sign-ups costs the site its users, while a bounced message costs the sender one retry. + ### Setup -1. Go to https://dash.cloudflare.com → Turnstile → Add widget -2. Add your domain(s), choose "Managed" widget type -3. Copy the Site Key and Secret Key +1. Go to https://dashboard.hcaptcha.com → Sites → New Site +2. Add your domain(s) +3. Copy the Site Key, and the account's Secret Key from Settings ```bash # frontend/.env -TURNSTILE_SITE_KEY=0x4AAAAAAA... +HCAPTCHA_SITE_KEY=10000000-ffff-... # backend/.env -TURNSTILE_SECRET_KEY=0x4AAAAAAA... +HCAPTCHA_SECRET_KEY=ES_... # Restart (no rebuild needed) docker compose restart frontend backend ``` +The CSP in `frontend/nginx.conf` already allows `hcaptcha.com` and its subdomains for scripts, frames, images and XHR; a provider change means editing that header too. + ### Testing -Cloudflare provides test keys for development: +hCaptcha publishes a key pair that always passes: | Purpose | Site Key | Secret Key | |---|---|---| -| Always passes | `1x00000000000000000000AA` | `1x0000000000000000000000000000000AA` | -| Always blocks | `2x00000000000000000000AB` | `2x0000000000000000000000000000000AB` | +| Always passes | `10000000-ffff-ffff-ffff-000000000001` | `0x0000000000000000000000000000000000000000` | ### Disabling @@ -198,14 +203,14 @@ docker-compose.yml └─ frontend service: env_file: ./frontend/.env Container startup (docker-entrypoint.sh): - └─ Reads $TURNSTILE_SITE_KEY from environment + └─ Reads $HCAPTCHA_SITE_KEY from environment └─ Writes /usr/share/nginx/html/config.js: - window.__APP_CONFIG__ = { TURNSTILE_SITE_KEY: "0x4AAA..." }; + window.__APP_CONFIG__ = { HCAPTCHA_SITE_KEY: "10000000-ffff-..." }; Browser loads index.html: └─