diff options
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', |