diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 11:06:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 11:06:47 +0200 |
| commit | b6c15f35d570d4f54901b811654991847502ca82 (patch) | |
| tree | a3d0a42c3b48aa7ac618094cedc59b5b3f329376 /packages/meshbay-hub/src/meshbay_hub/api/users.py | |
| parent | 1c8eb6577e36e1e4a150afd0cdda8d283172385e (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 29 |
1 files changed, 26 insertions, 3 deletions
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() |