diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:57:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:57:55 +0200 |
| commit | 1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (patch) | |
| tree | 04a23f81d3eea49de7414bf973600d33913795f9 /packages/meshbay-hub/src/meshbay_hub/api/users.py | |
| parent | 4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (diff) | |
| download | meshbay-1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc.tar.gz | |
feat(hub): Phase 8 — Hub v2 security hardening + production readiness
8.1 Config-based admin authz (require_admin on all admin endpoints)
8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key)
8.3 Refresh token rotation with family-based reuse detection
8.4 Federation persistence (HubPeer model replaces in-memory dict)
8.5 Federation token verification now async (DB-backed)
8.6 CSAM hash check wired into swarm registration flow
8.7 Rate limiting on auth endpoints (5/10/20 per minute)
8.8 Healthcheck endpoint (GET /v1/health, no auth)
8.9 IP log cleanup background task (365-day retention)
8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login)
Deployed to meshbay.org — schema migrated, existing emails encrypted.
117 tests pass (29 hub, 88 common+node).
Resolves security review items S1, S2, S5.
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 | 71 |
1 files changed, 60 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 5a2c7be..f91b381 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1,5 +1,6 @@ """User endpoints — /v1/users/*""" +import uuid from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -8,14 +9,18 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import ( + current_pw_version, decode_access_token, + encrypt_email, generate_refresh_token, hash_password, hash_refresh_token, hub_public_key_pem, issue_access_token, + pw_needs_rehash, verify_password, ) +from meshbay_hub.api.middleware import limiter from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User @@ -76,6 +81,7 @@ class RefreshRequest(BaseModel): # ── Endpoints ───────────────────────────────────────────────────────────────── @router.post("/register", status_code=201) +@limiter.limit("5/minute") async def register( body: RegisterRequest, request: Request, @@ -90,9 +96,10 @@ async def register( hub_id = _cfg.identity.id if _cfg else "meshbay.org" user = User( username=body.username, - email=body.email, + email=encrypt_email(body.email), pw_hash=pw_hash, pw_salt=pw_salt, + pw_version=current_pw_version(), pk_ed25519=body.pk_user_ed25519, pk_x25519=body.pk_user_x25519, hub_id=hub_id, @@ -118,6 +125,7 @@ async def register( @router.post("/login") +@limiter.limit("10/minute") async def login( body: LoginRequest, request: Request, @@ -128,7 +136,9 @@ async def login( user = result.scalar_one_or_none() ip = _client_ip(request) - if not user or not verify_password(body.password, user.pw_hash, user.pw_salt): + if not user or not verify_password( + body.password, user.pw_hash, user.pw_salt, version=user.pw_version + ): db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) await db.commit() raise HTTPException(status_code=401, detail="Invalid credentials") @@ -136,15 +146,25 @@ async def login( if user.status != "active": raise HTTPException(status_code=403, detail=f"Account {user.status}") + if pw_needs_rehash(user.pw_version): + new_hash, new_salt = hash_password(body.password) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = current_pw_version() + memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token( user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() + family_id = str(uuid.uuid4()) expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) - db.add(RefreshToken(user_id=user.id, token_hash=rt_hash, expires_at=expires_at)) + db.add(RefreshToken( + user_id=user.id, token_hash=rt_hash, + family_id=family_id, expires_at=expires_at, + )) db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) await db.commit() @@ -160,31 +180,60 @@ async def login( @router.post("/token/refresh") +@limiter.limit("20/minute") async def token_refresh( body: RefreshRequest, + request: Request, db: AsyncSession = Depends(get_db), ): rt_hash = hash_refresh_token(body.refresh_token) result = await db.execute( - select(RefreshToken).where( - RefreshToken.token_hash == rt_hash, - RefreshToken.revoked == False, # noqa: E712 - )) + select(RefreshToken).where(RefreshToken.token_hash == rt_hash)) rt = result.scalar_one_or_none() - if not rt or rt.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): - raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + if not rt: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + if rt.revoked: + # Reuse detected — revoke entire token family + await db.execute( + RefreshToken.__table__.update() + .where(RefreshToken.family_id == rt.family_id) + .values(revoked=True)) + await db.commit() + raise HTTPException(status_code=401, detail="Token reuse detected — family revoked") + + if rt.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): + raise HTTPException(status_code=401, detail="Expired refresh token") user = await db.get(User, rt.user_id) if not user or user.status != "active": raise HTTPException(status_code=401, detail="User not found or suspended") + # Revoke old token + rt.revoked = True + + # Issue new refresh token in the same family + new_raw_rt, new_rt_hash = generate_refresh_token() + expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + db.add(RefreshToken( + user_id=user.id, token_hash=new_rt_hash, + family_id=rt.family_id, expires_at=expires_at, + )) + memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] - new_token = issue_access_token( + new_access = issue_access_token( user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids) - return {"access_token": new_token, "token_type": "bearer", "expires_in": _ttl()} + await db.commit() + + return { + "access_token": new_access, + "refresh_token": new_raw_rt, + "token_type": "bearer", + "expires_in": _ttl(), + } @router.get("/{username}/pubkeys") |