aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
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 /packages/meshbay-hub/src/meshbay_hub/api
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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py29
2 files changed, 35 insertions, 3 deletions
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()