diff options
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") |