summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-10 03:57:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-10 03:57:55 +0200
commit1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (patch)
tree04a23f81d3eea49de7414bf973600d33913795f9 /packages/meshbay-hub/src/meshbay_hub/api/users.py
parent4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (diff)
downloadmeshbay-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.py71
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")