From b6c15f35d570d4f54901b811654991847502ca82 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 11:06:47 +0200 Subject: feat(hub): reCAPTCHA v2 on Register and Password Reset pages Server-side verification module, CaptchaConfig in hub.toml, captcha_site_key exposed via /v1/hub/info, useCaptcha() hook in the SPA with stable DOM rendering (strength bar always present to avoid Preact re-ordering the captcha widget). Native clients (auth_key path) skip captcha. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 --- docs/captcha.md | 490 +++++++++++++++++++++ packages/meshbay-hub/src/meshbay_hub/api/hub.py | 9 + packages/meshbay-hub/src/meshbay_hub/api/users.py | 29 +- packages/meshbay-hub/src/meshbay_hub/app.py | 3 +- packages/meshbay-hub/src/meshbay_hub/captcha.py | 28 ++ packages/meshbay-hub/src/meshbay_hub/config.py | 18 + .../src/meshbay_hub/static/auth-page.js | 111 ++++- .../src/meshbay_hub/static/locales/de.js | 2 + .../src/meshbay_hub/static/locales/en.js | 2 + .../src/meshbay_hub/static/locales/es.js | 2 + .../src/meshbay_hub/static/locales/fr.js | 2 + .../src/meshbay_hub/static/locales/it.js | 2 + .../src/meshbay_hub/static/locales/ja.js | 2 + .../src/meshbay_hub/static/locales/nl.js | 2 + .../src/meshbay_hub/static/locales/pl.js | 2 + .../src/meshbay_hub/static/locales/pt-BR.js | 2 + .../src/meshbay_hub/static/locales/zh-CN.js | 2 + 17 files changed, 688 insertions(+), 20 deletions(-) create mode 100644 docs/captcha.md create mode 100644 packages/meshbay-hub/src/meshbay_hub/captcha.py diff --git a/docs/captcha.md b/docs/captcha.md new file mode 100644 index 0000000..c908a5c --- /dev/null +++ b/docs/captcha.md @@ -0,0 +1,490 @@ +# reCAPTCHA on Registration and Password Reset + +> Goal: verify the user is not a bot **before** sending any email — registration +> verification code or password reset code. The captcha gate sits between form +> submission and the email-sending call, so a failed check never triggers an email. + +--- + +## 1. reCAPTCHA v2 (checkbox) + +reCAPTCHA v2 with the "I'm not a robot" checkbox. Reasons: + +- Binary pass/fail — no score threshold to tune or monitor. +- The user is already filling a form; one checkbox is negligible friction. +- Works in both the web SPA and the Electron client (both run Chromium). +- v3 (invisible, score-based) is an option later if the checkbox proves annoying; + the server-side verification call is identical, only the client widget differs. + +**Google Console setup:** create a reCAPTCHA v2 key pair at +`https://www.google.com/recaptcha/admin`. Register the hub's domain(s) — +`meshbay.org` and `localhost` for development. This produces a **site key** +(public, embedded in HTML) and a **secret key** (server-only, in `hub.toml`). + +--- + +## 2. Configuration + +### `hub.toml` + +```toml +[captcha] +site_key = "6Le..." # public — served to the frontend +secret_key = "6Le..." # private — never leaves the server +``` + +When the `[captcha]` section is absent or both keys are empty, the captcha is +**disabled** — the registration endpoint accepts requests without a token. This +keeps development, tests and self-hosted instances that do not need it +frictionless. + +### `config.py` — new dataclass + +```python +@dataclass +class CaptchaConfig: + site_key: str = "" + secret_key: str = "" + + @property + def enabled(self) -> bool: + return bool(self.site_key and self.secret_key) +``` + +Add `captcha: CaptchaConfig` to `HubConfig` (default: disabled). Parse the +`[captcha]` section in `load_config` on the same pattern as `[jwt]`: + +```python +if cap := raw.get("captcha", {}): + cfg.captcha.site_key = cap.get("site_key", cfg.captcha.site_key) + cfg.captcha.secret_key = cap.get("secret_key", cfg.captcha.secret_key) +``` + +Environment variable overrides: `MESHBAY_CAPTCHA_SITE_KEY`, +`MESHBAY_CAPTCHA_SECRET_KEY`. + +--- + +## 3. Serving the site key to the frontend + +The site key is public and the SPA needs it before the user reaches the +registration form. Two options: + +**Option A — extend `/v1/hub/info`** (recommended). Add `captcha_site_key` to +the response (empty string when disabled). The SPA already calls this endpoint +at startup for `allow_public_groups`; no new request. The endpoint is +unauthenticated, which is correct — the site key is public by design. + +```python +# hub.py — hub_info() +return { + ... + "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", +} +``` + +**Option B — inject in the HTML shell.** Add a `