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 --- packages/meshbay-hub/src/meshbay_hub/api/users.py | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py') 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() -- cgit v1.2.3