aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 11:06:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 11:06:47 +0200
commitb6c15f35d570d4f54901b811654991847502ca82 (patch)
treea3d0a42c3b48aa7ac618094cedc59b5b3f329376
parent1c8eb6577e36e1e4a150afd0cdda8d283172385e (diff)
downloadmeshbay-b6c15f35d570d4f54901b811654991847502ca82.tar.gz
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 <noreply@anthropic.com>
-rw-r--r--docs/captcha.md490
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/captcha.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js111
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
17 files changed, 688 insertions, 20 deletions
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 `<script>` line in `webapp.py`'s
+`_HTML` template: `window.__MB_CAPTCHA_KEY = "{captcha_key}";`. Advantage: the
+key is available synchronously, before any fetch. Disadvantage: `_HTML` is built
+once at import time, so the config must be available then — currently it is, via
+`set_config` in `app.py`.
+
+Recommendation: **option A**. It follows the existing pattern, avoids touching
+the HTML shell, and the tiny latency of waiting for the `/v1/hub/info` response
+is irrelevant — the user has not reached the form yet.
+
+---
+
+## 4. Server-side verification
+
+### `captcha.py` (new module, `meshbay_hub/captcha.py`)
+
+```python
+import httpx
+import logging
+
+log = logging.getLogger(__name__)
+
+VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
+
+async def verify_captcha(secret_key: str, token: str, remote_ip: str | None = None) -> bool:
+ payload = {"secret": secret_key, "response": token}
+ if remote_ip:
+ payload["remoteip"] = remote_ip
+ try:
+ async with httpx.AsyncClient(timeout=5) as client:
+ resp = await client.post(VERIFY_URL, data=payload)
+ resp.raise_for_status()
+ result = resp.json()
+ if not result.get("success"):
+ log.info("captcha rejected: %s", result.get("error-codes", []))
+ return result.get("success", False)
+ except Exception:
+ log.exception("captcha verification request failed")
+ return False
+```
+
+Notes:
+- `httpx` is already a dev dependency and is light. Add it to `[project.dependencies]`
+ in `packages/meshbay-hub/pyproject.toml`.
+- 5-second timeout — a Google outage should not hang registration indefinitely.
+- On network failure the function returns `False` (fail-closed). If this is too
+ aggressive for availability, a retry or a fallback to allowing registration
+ can be discussed — but for a bot-prevention gate, fail-closed is correct.
+- `remote_ip` is optional — Google uses it for risk analysis, not as a hard check.
+
+### `users.py` — gate the registration endpoint
+
+Add `captcha_token: str | None = None` to `RegisterRequest`:
+
+```python
+class RegisterRequest(BaseModel):
+ username: str
+ email: str
+ password: str | None = None
+ auth_key: str | None = None
+ captcha_token: str | None = None
+```
+
+In `register()`, **before** any database work:
+
+```python
+@router.post("/register", status_code=201)
+@limiter.limit("5/minute")
+async def register(body: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
+ # ── Captcha gate ────────────────────────────────────────────────
+ if _cfg and _cfg.captcha.enabled:
+ if not body.captcha_token:
+ raise HTTPException(400, "captcha_required")
+ from meshbay_hub.captcha import verify_captcha
+ ok = await verify_captcha(
+ _cfg.captcha.secret_key,
+ body.captcha_token,
+ request.client.host if request.client else None,
+ )
+ if not ok:
+ raise HTTPException(400, "captcha_failed")
+
+ # ── Existing registration logic (unchanged) ────────────────────
+ eh = hash_email_blind(body.email)
+ ...
+```
+
+The check runs **before** the email blind hash, the username lookup, and the
+Argon2id hash — none of which should execute for a bot. This also means a
+failed captcha does not increment the rate limiter's cost beyond the existing
+`5/minute` on the endpoint itself.
+
+### `users.py` — gate the password reset request
+
+Same principle: `POST /v1/users/password/reset-request` sends an email with a
+6-digit code. The captcha must be verified before the email is sent.
+
+Add `captcha_token: str | None = None` to `ResetRequestRequest`:
+
+```python
+class ResetRequestRequest(BaseModel):
+ username: str
+ email: str
+ captcha_token: str | None = None
+```
+
+In `password_reset_request()`, **before** the user lookup:
+
+```python
+if _cfg and _cfg.captcha.enabled:
+ if not body.captcha_token:
+ raise HTTPException(400, "captcha_required")
+ from meshbay_hub.captcha import verify_captcha
+ ok = await verify_captcha(
+ _cfg.captcha.secret_key,
+ body.captcha_token,
+ request.client.host if request.client else None,
+ )
+ if not ok:
+ raise HTTPException(400, "captcha_failed")
+```
+
+No `auth_key` exemption here: the reset form is web-only (it requires
+`window.MeshBayKeys`), and the captcha gate applies to everyone on this path.
+
+---
+
+## 5. Client-side implementation
+
+### Loading the reCAPTCHA script (`auth-page.js`)
+
+Load the script **lazily** when the registration form mounts, not in the HTML
+shell — it is useless on every other page, and loading it globally adds ~150 KB
+of Google JS to every visit.
+
+```javascript
+function loadRecaptchaScript(siteKey) {
+ if (document.getElementById('recaptcha-script')) return;
+ const s = document.createElement('script');
+ s.id = 'recaptcha-script';
+ s.src = `https://www.google.com/recaptcha/api.js?render=explicit`;
+ s.async = true;
+ s.defer = true;
+ document.head.appendChild(s);
+}
+```
+
+### Rendering the widget
+
+In `RegisterPage`, after the component mounts:
+
+```javascript
+export function RegisterPage() {
+ const [captchaToken, setCaptchaToken] = useState(null);
+ const [captchaSiteKey, setCaptchaSiteKey] = useState('');
+ const captchaRef = useRef(null);
+ const widgetId = useRef(null);
+
+ useEffect(() => {
+ // Fetch the site key from /v1/hub/info (already cached by the SPA)
+ hubFetch('/v1/hub/info').then(info => {
+ if (info.captcha_site_key) {
+ setCaptchaSiteKey(info.captcha_site_key);
+ loadRecaptchaScript();
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!captchaSiteKey || !captchaRef.current) return;
+ const interval = setInterval(() => {
+ if (window.grecaptcha && window.grecaptcha.render) {
+ clearInterval(interval);
+ widgetId.current = window.grecaptcha.render(captchaRef.current, {
+ sitekey: captchaSiteKey,
+ callback: (token) => setCaptchaToken(token),
+ 'expired-callback': () => setCaptchaToken(null),
+ theme: document.documentElement.getAttribute('data-theme') === 'dark'
+ ? 'dark' : 'light',
+ });
+ }
+ }, 100);
+ return () => clearInterval(interval);
+ }, [captchaSiteKey]);
+ ...
+}
+```
+
+The widget div goes in the form, **above** the submit button:
+
+```javascript
+${captchaSiteKey && html`
+ <div ref=${captchaRef} style="display:flex;justify-content:center;margin:12px 0"></div>
+`}
+```
+
+### Sending the token
+
+In `onSubmit`, include `captcha_token` in the request body. Both paths
+(native `MeshBayKeys.registerUser` and the web fallback) must send it:
+
+```javascript
+// Web fallback path
+await hubFetch('/v1/users/register', {
+ method: 'POST',
+ body: { username, email, password, captcha_token: captchaToken },
+});
+```
+
+For the `MeshBayKeys.registerUser` path, `registerUser()` in `keyderive.js`
+must accept and forward the token:
+
+```javascript
+async registerUser(username, email, password, captchaToken) {
+ const authKey = await deriveAuthKey(password, username);
+ await hubFetch('/v1/users/register', {
+ method: 'POST',
+ body: { username, email, auth_key: authKey, captcha_token: captchaToken },
+ });
+}
+```
+
+### Resend flow
+
+`onResend` re-POSTs to `/register`. On a resend, the captcha token has already
+been consumed by Google (one-time use). Two options:
+
+1. **Reset the widget** after each submission (`grecaptcha.reset(widgetId)`) so
+ the user solves it again before resending. Safe but mildly annoying.
+2. **Skip the captcha on resend** server-side — the account already exists in
+ `pending` state, proving it passed the captcha once. The server already
+ detects this case (`found.status == "pending" and found.email_hash == eh`,
+ `users.py:126`). Move the captcha gate to run only when no pending account
+ matches.
+
+Recommendation: **option 2** — skip the captcha when resending to an existing
+pending account. The bot-prevention goal is met by the first check; a resend
+is a human who lost the email.
+
+Server-side, restructure the check order:
+
+```python
+# Check for existing pending account first (resend case)
+existing = await db.execute(select(User).where(User.username == body.username))
+found = existing.scalar_one_or_none()
+if found and found.status == "pending" and found.email_hash == eh:
+ # Resend — captcha already passed on initial registration
+ await _create_and_send_verification(db, found, body.email, eh)
+ await db.commit()
+ return {"user_id": str(found.id), "email_verification_required": True}
+
+# New registration — require captcha
+if _cfg and _cfg.captcha.enabled:
+ if not body.captcha_token:
+ raise HTTPException(400, "captcha_required")
+ ...
+```
+
+### Password reset page (`ResetPasswordPage`)
+
+The same captcha widget is rendered in the `request` phase of
+`ResetPasswordPage` — the form where the user enters username + email before a
+reset code is sent. The implementation is identical: lazy-load the script, render
+the widget, send `captcha_token` in the body of
+`POST /v1/users/password/reset-request`.
+
+The `form` phase (entering the code + new passphrase) does **not** need a
+captcha — the code itself is the proof the user controls the email.
+
+### Shared captcha helper
+
+Both `RegisterPage` and `ResetPasswordPage` need the same logic: load the
+script, render the widget, track the token. Extract a reusable `useCaptcha()`
+hook to avoid duplicating the setup code across both components.
+
+### Error handling
+
+The SPA must handle two new error codes from the server:
+
+- `captcha_required` — the server expects a captcha token but none was sent.
+ Display a message asking the user to complete the captcha. Should not happen
+ in normal flow unless JS failed to load.
+- `captcha_failed` — the token was rejected. Reset the widget and ask the user
+ to try again.
+
+Add i18n keys:
+```
+captcha.required: "Please complete the captcha"
+captcha.failed: "Captcha verification failed — please try again"
+```
+
+---
+
+## 6. Desktop client (Electron)
+
+The Electron client loads its UI from the package (`app://meshbay`), not from
+the hub. Its CSP forbids external scripts by design — the renderer executes no
+code that did not ship in the package. Loading Google's reCAPTCHA script
+(`https://www.google.com/recaptcha/api.js`) would be a **new category of
+trust**: third-party executable code in a renderer that currently runs none.
+
+Two approaches:
+
+- **Open the CSP to Google's reCAPTCHA domains.** Add
+ `https://www.google.com/recaptcha/` and `https://www.gstatic.com/recaptcha/`
+ to `script-src`, `frame-src` and `connect-src`. Functional, but undermines
+ the principle that the renderer runs only packaged code — the reCAPTCHA
+ script is fetched live and changes without the operator's knowledge.
+- **Skip the captcha for native clients** (recommended). The Electron client
+ already raises the bar against automated account creation: it requires
+ installation, generates a device Ed25519 key pair stored in `safeStorage`,
+ and authenticates to the hub via `POST /v1/users/auth` with a signed
+ challenge. A bot automating that path must install and drive a full Electron
+ app, which is a harder problem than filling a web form — and the reCAPTCHA
+ exists to solve the web-form problem.
+
+**How to skip server-side.** The web SPA sends `password` (legacy) in the
+registration body; the Electron client sends `auth_key` (PBKDF2-derived via
+`window.MeshBayKeys.registerUser`). The server requires `captcha_token` only
+when `auth_key` is absent — i.e. the web path. This is not a security
+boundary: a bot that derives `auth_key` itself bypasses the check, but it also
+proves it can run the PBKDF2 derivation, which is the same cost as solving the
+captcha. The real gate for native-path abuse is the rate limiter (`5/minute`)
+and the email verification step.
+
+```python
+# In register(), captcha gate adjusted:
+if _cfg and _cfg.captcha.enabled and not body.auth_key:
+ if not body.captcha_token:
+ raise HTTPException(400, "captcha_required")
+ ...
+```
+
+On the Electron side: `registerUser()` in `keyderive.js` does not send
+`captcha_token`, and the server does not ask for one. No CSP change, no
+Google script loaded, no new trust boundary.
+
+---
+
+## 7. Dependencies
+
+Add `httpx` to `packages/meshbay-hub/pyproject.toml` runtime dependencies
+(it is already in `[project.optional-dependencies] dev`):
+
+```toml
+dependencies = [
+ ...
+ "httpx>=0.28",
+]
+```
+
+No other new dependency. The reCAPTCHA client-side is a single `<script>` tag
+from Google — no npm package.
+
+---
+
+## 8. Files changed
+
+| File | Change |
+|---|---|
+| `packages/meshbay-hub/src/meshbay_hub/config.py` | Add `CaptchaConfig` dataclass, `captcha` field on `HubConfig`, parse `[captcha]` section + env vars |
+| `packages/meshbay-hub/src/meshbay_hub/captcha.py` | New module — `verify_captcha()` |
+| `packages/meshbay-hub/src/meshbay_hub/api/users.py` | Add `captcha_token` to `RegisterRequest` and `ResetRequestRequest`, gate before email send on both endpoints |
+| `packages/meshbay-hub/src/meshbay_hub/api/hub.py` | Add `captcha_site_key` to `/v1/hub/info` response |
+| `packages/meshbay-hub/src/meshbay_hub/app.py` | Pass `cfg` to `hub.py` (for site key access) — may already be sufficient via `_cfg` |
+| `packages/meshbay-hub/src/meshbay_hub/static/auth-page.js` | Load reCAPTCHA script, render widget in `RegisterPage` and `ResetPasswordPage`, send token, handle errors |
+| `packages/meshbay-hub/src/meshbay_hub/static/locales/en.js` | Add captcha error i18n keys |
+| `packages/meshbay-hub/src/meshbay_hub/static/locales/*.js` | Same keys in each locale |
+| `packages/meshbay-hub/pyproject.toml` | `httpx` to runtime deps |
+
+---
+
+## 9. Testing
+
+- **Unit test (`test_captcha.py`):** mock `httpx.AsyncClient.post` to return
+ `{"success": true}` / `{"success": false}`, verify `verify_captcha()` returns
+ the right bool. Test timeout and network-error handling (returns `False`).
+- **Integration test (`test_register_captcha.py`):** with captcha enabled in
+ config, POST to `/v1/users/register` without `captcha_token` → 400
+ `captcha_required`. With a mocked passing token → 201. With a mocked failing
+ token → 400 `captcha_failed`. Resend to an existing pending account without
+ token → 201 (skip). Same tests for `/v1/users/password/reset-request`.
+- **SPA source test:** verify `auth-page.js` sends `captcha_token` in the
+ registration body (add to `test_transport_contracts.py` or equivalent).
+- **Manual test:** deploy to a local hub with real Google keys, register from a
+ browser, confirm the widget appears and the email is only sent after solving it.
+
+---
+
+## 10. Deployment steps
+
+1. Obtain reCAPTCHA v2 keys from Google (register `meshbay.org` + `localhost`).
+2. Add to `/etc/meshbay/hub.toml` on the production server:
+ ```toml
+ [captcha]
+ site_key = "6Le..."
+ secret_key = "6Le..."
+ ```
+3. Deploy the new hub code (`deploy-hub.sh` — runs `alembic upgrade head` +
+ restart; no migration needed for this change).
+4. Verify registration: open `https://meshbay.org/#/register`, confirm the
+ checkbox appears. Complete registration, confirm email arrives only after
+ solving the captcha.
+5. Verify reset: open `https://meshbay.org/#/reset`, confirm the checkbox
+ appears. Request a reset code, confirm email arrives only after solving it.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 8692995..8223400 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -6,10 +6,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_common import MNP_VERSION, MHP_VERSION
from meshbay_hub import __version__, hub_settings
from meshbay_hub.auth import hub_public_key_pem
+from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db, get_engine
router = APIRouter(prefix="/v1/hub", tags=["hub"])
+_cfg: HubConfig | None = None
+
+
+def set_config(cfg: HubConfig) -> None:
+ global _cfg
+ _cfg = cfg
+
@router.get("/info")
async def hub_info(db: AsyncSession = Depends(get_db)):
@@ -24,6 +32,7 @@ async def hub_info(db: AsyncSession = Depends(get_db)):
# reachable before the group list loads. The hub enforces it regardless
# of what any client does with this flag.
"allow_public_groups": await hub_settings.public_groups_allowed(db),
+ "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "",
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index fa74368..559cfa6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -60,6 +60,19 @@ def _generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
+async def _verify_captcha_or_raise(token: str | None, request: Request) -> None:
+ if not token:
+ raise HTTPException(status_code=400, detail="captcha_required")
+ from meshbay_hub.captcha import verify_captcha
+ ok = await verify_captcha(
+ _cfg.captcha.secret_key, # type: ignore[union-attr]
+ token,
+ request.client.host if request.client else None,
+ )
+ if not ok:
+ raise HTTPException(status_code=400, detail="captcha_failed")
+
+
# ── Models ────────────────────────────────────────────────────────────────────
class RegisterRequest(BaseModel):
@@ -71,6 +84,7 @@ class RegisterRequest(BaseModel):
# pass-through: appended to the verification e-mail so the user's mailbox
# backs it up, then dropped. Never written to any table, never logged.
recovery_key: str | None = None
+ captcha_token: str | None = None
@field_validator("username")
@classmethod
@@ -128,13 +142,18 @@ async def register(
if found:
if found.status == "pending" and found.email_hash == eh:
- # Same person retrying before validation — resend a code
+ # Same person retrying before validation — resend a code.
+ # No captcha: the initial registration already passed it.
await _create_and_send_verification(
db, found, body.email, eh, body.recovery_key)
await db.commit()
return {"user_id": found.id, "email_verification_required": True}
raise HTTPException(status_code=409, detail="Username already taken")
+ # Captcha gate — web path only (native clients send auth_key)
+ if _cfg and _cfg.captcha.enabled and not body.auth_key:
+ await _verify_captcha_or_raise(body.captcha_token, request)
+
# Email uniqueness (only active or pending accounts)
dup = await db.execute(
select(User).where(User.email_hash == eh, User.status.in_(["active", "pending"])))
@@ -801,8 +820,9 @@ PASSWORD_RESET_TTL = 3600 # 1 hour — shorter than sign-up verification
class ResetRequestRequest(BaseModel):
- username: str
- email: str # must match the address on file for `username`
+ username: str
+ email: str # must match the address on file for `username`
+ captcha_token: str | None = None
@field_validator("email")
@classmethod
@@ -830,6 +850,9 @@ async def password_reset_request(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ if _cfg and _cfg.captcha.enabled:
+ await _verify_captcha_or_raise(body.captcha_token, request)
+
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 1726ff4..76d7ec0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -21,7 +21,7 @@ from meshbay_hub import __version__
from meshbay_hub.auth import generate_hub_keypair, load_hub_keypair
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import close_db, init_db
-from meshbay_hub.api.hub import router as hub_router
+from meshbay_hub.api.hub import router as hub_router, set_config as hub_set_config
from meshbay_hub.api.users import router as users_router, set_config as users_set_config
from meshbay_hub.api.deps import set_admin_usernames
from meshbay_hub.api.nodes import router as nodes_router
@@ -102,6 +102,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
generate_hub_keypair(kp)
load_hub_keypair(kp, cfg.identity.id)
users_set_config(cfg)
+ hub_set_config(cfg)
set_admin_usernames(cfg.identity.admin_usernames)
await _backfill_email_hashes()
diff --git a/packages/meshbay-hub/src/meshbay_hub/captcha.py b/packages/meshbay-hub/src/meshbay_hub/captcha.py
new file mode 100644
index 0000000..faf7907
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/captcha.py
@@ -0,0 +1,28 @@
+"""reCAPTCHA v2 server-side verification."""
+
+import logging
+
+import httpx
+
+log = logging.getLogger(__name__)
+
+VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
+
+
+async def verify_captcha(
+ secret_key: str, token: str, remote_ip: str | None = None,
+) -> bool:
+ payload: dict[str, str] = {"secret": secret_key, "response": token}
+ if remote_ip:
+ payload["remoteip"] = remote_ip
+ try:
+ async with httpx.AsyncClient(timeout=5) as client:
+ resp = await client.post(VERIFY_URL, data=payload)
+ resp.raise_for_status()
+ result = resp.json()
+ if not result.get("success"):
+ log.info("captcha rejected: %s", result.get("error-codes", []))
+ return result.get("success", False)
+ except Exception:
+ log.exception("captcha verification request failed")
+ return False
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index b508e45..48d5a6e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -62,11 +62,22 @@ class JWTConfig:
@dataclass
+class CaptchaConfig:
+ site_key: str = ""
+ secret_key: str = ""
+
+ @property
+ def enabled(self) -> bool:
+ return bool(self.site_key and self.secret_key)
+
+
+@dataclass
class HubConfig:
db: DatabaseConfig = field(default_factory=DatabaseConfig)
server: ServerConfig = field(default_factory=ServerConfig)
identity: HubIdentityConfig = field(default_factory=HubIdentityConfig)
jwt: JWTConfig = field(default_factory=JWTConfig)
+ captcha: CaptchaConfig = field(default_factory=CaptchaConfig)
def load_config(path: Path | None = None) -> HubConfig:
@@ -92,6 +103,9 @@ def load_config(path: Path | None = None) -> HubConfig:
if jwt := raw.get("jwt", {}):
cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl)
cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl)
+ 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)
break
# Env var overrides
@@ -107,5 +121,9 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.identity.private_key_path = Path(kp).expanduser()
if admin_users := os.environ.get("MESHBAY_ADMIN_USERS"):
cfg.identity.admin_usernames = [u.strip() for u in admin_users.split(",") if u.strip()]
+ if captcha_site := os.environ.get("MESHBAY_CAPTCHA_SITE_KEY"):
+ cfg.captcha.site_key = captcha_site
+ if captcha_secret := os.environ.get("MESHBAY_CAPTCHA_SECRET_KEY"):
+ cfg.captcha.secret_key = captcha_secret
return cfg
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 7c92d52..df08bc4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -1,5 +1,5 @@
import {
- html, useState,
+ html, useState, useEffect, useRef, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import {
@@ -10,6 +10,75 @@ import * as platform from './platform.js';
const PASSWORD_MIN_BITS = 60;
const PASSWORD_MIN_LEN = 12;
+// ── reCAPTCHA v2 helper ──────────────────────────────────────────────────────
+
+let _captchaSiteKey = null;
+let _captchaKeyFetched = false;
+
+async function fetchCaptchaSiteKey() {
+ if (_captchaKeyFetched) return _captchaSiteKey;
+ try {
+ const info = await hubFetch('/v1/hub/info');
+ _captchaSiteKey = info.captcha_site_key || null;
+ } catch { _captchaSiteKey = null; }
+ _captchaKeyFetched = true;
+ return _captchaSiteKey;
+}
+
+function loadRecaptchaScript() {
+ if (document.getElementById('recaptcha-script')) return;
+ const s = document.createElement('script');
+ s.id = 'recaptcha-script';
+ s.src = 'https://www.google.com/recaptcha/api.js?render=explicit';
+ s.async = true;
+ s.defer = true;
+ document.head.appendChild(s);
+}
+
+function useCaptcha() {
+ const [siteKey, setSiteKey] = useState(_captchaSiteKey);
+ const [token, setToken] = useState(null);
+ const containerRef = useRef(null);
+ const widgetId = useRef(null);
+
+ useEffect(() => {
+ fetchCaptchaSiteKey().then(k => {
+ if (k) { setSiteKey(k); loadRecaptchaScript(); }
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!siteKey || !containerRef.current) return;
+ const poll = setInterval(() => {
+ if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) {
+ clearInterval(poll);
+ widgetId.current = window.grecaptcha.render(containerRef.current, {
+ sitekey: siteKey,
+ callback: (tk) => setToken(tk),
+ 'expired-callback': () => setToken(null),
+ theme: document.documentElement.getAttribute('data-theme') === 'dark'
+ ? 'dark' : 'light',
+ });
+ }
+ }, 100);
+ return () => clearInterval(poll);
+ }, [siteKey]);
+
+ const reset = useCallback(() => {
+ if (widgetId.current !== null && window.grecaptcha) {
+ window.grecaptcha.reset(widgetId.current);
+ setToken(null);
+ }
+ }, []);
+
+ const widget = siteKey
+ ? html`<div ref=${containerRef}
+ style="display:flex;justify-content:center;margin:12px 0"></div>`
+ : null;
+
+ return { token, widget, reset, enabled: !!siteKey };
+}
+
function passwordBits(pw) {
if (!pw) return 0;
let pool = 0;
@@ -142,6 +211,7 @@ export function RegisterPage() {
const [recoverySaved, setRecoverySaved] = useState(false);
const [recoveryCopied, setRecoveryCopied] = useState(false);
const [emailRecovery, setEmailRecovery] = useState(true);
+ const captcha = useCaptcha();
const onSubmit = async (e) => {
e.preventDefault();
@@ -177,7 +247,11 @@ export function RegisterPage() {
} else {
await hubFetch('/v1/users/register', {
method: 'POST',
- body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ body: {
+ username: name, email, password,
+ pk_user_ed25519: '', pk_user_x25519: '',
+ captcha_token: captcha.token,
+ },
});
setPhase('verify');
}
@@ -322,18 +396,16 @@ export function RegisterPage() {
<input type="password" placeholder="${t('register.password')}" value=${password}
onInput=${e => setPassword(e.target.value)}
autocomplete="new-password" required minlength="8" />
- ${password && html`
- <div style="margin:-4px 0 10px">
- <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
- <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%;
- background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)'
- : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div>
- </div>
- <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px">
- ${t('register.strength', { bits: passwordBits(password) })}
- </p>
+ <div style=${`margin:-4px 0 10px;${password ? '' : 'visibility:hidden;height:0;margin:0;overflow:hidden'}`}>
+ <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
+ <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%;
+ background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)'
+ : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div>
</div>
- `}
+ <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px">
+ ${t('register.strength', { bits: passwordBits(password) })}
+ </p>
+ </div>
<input type="password" placeholder="${t('register.confirm')}" value=${confirm}
onInput=${e => setConfirm(e.target.value)}
autocomplete="new-password" required />
@@ -343,8 +415,9 @@ export function RegisterPage() {
onChange=${e => setEmailRecovery(e.target.checked)} />
<span>${t('register.recovery_email_opt')}</span>
</label>
+ ${captcha.widget}
${error && html`<div class="error-msg">${error}</div>`}
- <button type="submit" disabled=${loading}>
+ <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}>
${loading ? t('register.loading') : t('register.submit')}
</button>
</form>
@@ -375,6 +448,7 @@ export function ResetPasswordPage({ onLogin }) {
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(null);
const [result, setResult] = useState(null);
+ const captcha = useCaptcha();
const requestCode = async (e) => {
e.preventDefault();
@@ -384,7 +458,10 @@ export function ResetPasswordPage({ onLogin }) {
try {
await hubFetch('/v1/users/password/reset-request', {
method: 'POST',
- body: { username: username.trim(), email: email.trim() },
+ body: {
+ username: username.trim(), email: email.trim(),
+ captcha_token: captcha.token,
+ },
});
setPhase('form');
} catch (err) {
@@ -493,8 +570,10 @@ export function ResetPasswordPage({ onLogin }) {
<input type="email" placeholder="${t('register.email')}" value=${email}
onInput=${e => setEmail(e.target.value)}
autocomplete="email" required />
+ ${captcha.widget}
${error && html`<div class="error-msg">${error}</div>`}
- <button type="submit" disabled=${busy}>${t('reset.send_code')}</button>
+ <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}>
+ ${t('reset.send_code')}</button>
</form>`}
${phase === 'form' && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 363df86..fcd138d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -92,6 +92,8 @@ export default {
'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer '
+ 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, '
+ 'sie gut zu wählen.',
+ 'captcha.required': 'Bitte das Captcha ausfüllen.',
+ 'captcha.failed': 'Captcha-Überprüfung fehlgeschlagen — bitte erneut versuchen.',
// Home
'home.welcome': 'Willkommen bei MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 14a42a9..a265527 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -93,6 +93,8 @@ export default {
+ 'your keys where they are stored — a few unrelated words work well.',
'register.strength': 'Strength: about {bits} bits. This protects the copy of '
+ 'your keys kept on the nodes you join, so it is worth getting right.',
+ 'captcha.required': 'Please complete the captcha.',
+ 'captcha.failed': 'Captcha verification failed — please try again.',
// Home
'home.welcome': 'Welcome to MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index ab1829a..1505ffe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -90,6 +90,8 @@ export default {
+ 'relación entre sí funcionan muy bien.',
'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que '
+ 'se guarda en los nodes a los que se une, así que conviene acertar.',
+ 'captcha.required': 'Por favor, complete el captcha.',
+ 'captcha.failed': 'Verificación del captcha fallida — inténtelo de nuevo.',
// Home
'home.welcome': 'Bienvenido a MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index bfa707c..95729e5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -91,6 +91,8 @@ export default {
'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de '
+ 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine '
+ 'de la soigner.',
+ 'captcha.required': 'Veuillez compléter le captcha.',
+ 'captcha.failed': 'Vérification du captcha échouée — veuillez réessayer.',
// Home
'home.welcome': 'Bienvenue sur MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 0f92692..faf0592 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -91,6 +91,8 @@ export default {
+ 'legame tra loro funziona bene.',
'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue '
+ 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.',
+ 'captcha.required': 'Completa il captcha.',
+ 'captcha.failed': 'Verifica captcha fallita — riprova.',
// Home
'home.welcome': 'Benvenuto in MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 1e5a9f5..3a354de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -89,6 +89,8 @@ export default {
+ 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。',
'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の'
+ 'コピーを守るものですので、しっかり決めておく価値があります。',
+ 'captcha.required': 'キャプチャを完了してください。',
+ 'captcha.failed': 'キャプチャの検証に失敗しました — もう一度お試しください。',
// Home
'home.welcome': 'MeshBay へようこそ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 3cd34c3..d66fafd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -92,6 +92,8 @@ export default {
'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw '
+ 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er '
+ 'even bij stil te staan.',
+ 'captcha.required': 'Vul de captcha in.',
+ 'captcha.failed': 'Captchaverificatie mislukt — probeer het opnieuw.',
// Home
'home.welcome': 'Welkom bij MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 10fe306..74a4dac 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -96,6 +96,8 @@ export default {
+ 'bardzo dobrze.',
'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na '
+ 'nodes, do których się dołącza, więc warto ją dobrać starannie.',
+ 'captcha.required': 'Proszę wypełnić captcha.',
+ 'captcha.failed': 'Weryfikacja captcha nie powiodła się — spróbuj ponownie.',
// Home
'home.welcome': 'Witamy w MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9d69b85..e478133 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -92,6 +92,8 @@ export default {
+ 'entre si funcionam muito bem.',
'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas '
+ 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.',
+ 'captcha.required': 'Por favor, complete o captcha.',
+ 'captcha.failed': 'Verificação do captcha falhou — tente novamente.',
// Home
'home.welcome': 'Bem-vindo ao MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index fc141c2..1d537b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -89,6 +89,8 @@ export default {
+ '几个互不相关的词就很好用。',
'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,'
+ '因此值得认真设置。',
+ 'captcha.required': '请完成验证码。',
+ 'captcha.failed': '验证码验证失败——请重试。',
// Home
'home.welcome': '欢迎使用 MeshBay',