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 | 140 |
1 files changed, 116 insertions, 24 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 2c2eede..53238de 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 base64 import uuid from datetime import datetime, timezone, timedelta @@ -24,7 +25,7 @@ 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 -from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.deps import get_current_user, require_user_scope router = APIRouter(prefix="/v1/users", tags=["users"]) @@ -46,10 +47,10 @@ def _refresh_ttl() -> int: class RegisterRequest(BaseModel): username: str email: str - password: str + password: str | None = None # deprecated — legacy native clients + auth_key: str | None = None # PBKDF2-derived, new clients pk_user_ed25519: str # base64 raw 32B pk_user_x25519: str # base64 raw 32B - keypair_bundle: str | None = None # AES-GCM encrypted bundle (web clients) @field_validator("username") @classmethod @@ -61,17 +62,11 @@ class RegisterRequest(BaseModel): raise ValueError("username: only letters, digits, -, _, .") return v - @field_validator("password") - @classmethod - def password_strength(cls, v: str) -> str: - if len(v) < 8: - raise ValueError("password must be at least 8 characters") - return v - class LoginRequest(BaseModel): username: str - password: str + password: str | None = None # legacy (raw password) for migration + auth_key: str | None = None # PBKDF2-derived auth key (new scheme) class RefreshRequest(BaseModel): @@ -92,18 +87,23 @@ async def register( if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Username already taken") - pw_hash, pw_salt = hash_password(body.password) + credential = body.auth_key or body.password + if not credential: + raise HTTPException(status_code=400, detail="auth_key or password required") + + pw_hash, pw_salt = hash_password(credential) + # auth_key → pw_version 3 (password split); raw password → pw_version 2 (legacy) + pw_ver = current_pw_version() if body.auth_key else 2 hub_id = _cfg.identity.id if _cfg else "meshbay.org" user = User( username=body.username, email=encrypt_email(body.email), pw_hash=pw_hash, pw_salt=pw_salt, - pw_version=current_pw_version(), + pw_version=pw_ver, pk_ed25519=body.pk_user_ed25519, pk_x25519=body.pk_user_x25519, hub_id=hub_id, - keypair_bundle=body.keypair_bundle, ) db.add(user) db.add(IPLog( @@ -136,18 +136,52 @@ 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, version=user.pw_version - ): + + if not body.auth_key and not body.password: + raise HTTPException(status_code=401, detail="No credentials provided") + + if not user: db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) await db.commit() raise HTTPException(status_code=401, detail="Invalid credentials") + if user.pw_version >= 3: + # New scheme: verify auth_key + if not body.auth_key or not verify_password( + body.auth_key, 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") + else: + # Legacy scheme: need raw password + if not body.password: + raise HTTPException(status_code=401, detail="auth_upgrade_required") + if 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") + # Migrate to new scheme if auth_key provided alongside password + if body.auth_key: + new_hash, new_salt = hash_password(body.auth_key) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = current_pw_version() + elif user.pw_version < 2: + # Legacy rehash: upgrade Argon2 params within the password scheme (v1 -> v2) + new_hash, new_salt = hash_password(body.password) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = 2 + 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) + # Rehash within the auth_key scheme if Argon2 params upgraded beyond v3 + if user.pw_version >= 3 and pw_needs_rehash(user.pw_version): + new_hash, new_salt = hash_password(body.auth_key) user.pw_hash = new_hash user.pw_salt = new_salt user.pw_version = current_pw_version() @@ -168,15 +202,12 @@ async def login( db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) await db.commit() - resp = { + return { "access_token": access_token, "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), } - if user.keypair_bundle: - resp["keypair_bundle"] = user.keypair_bundle # encrypted, for web clients - return resp @router.post("/token/refresh") @@ -248,6 +279,64 @@ async def get_current_user_info( } +class NodeKeyRequest(BaseModel): + pk_node_ed25519: str # base64 raw 32B Ed25519 public key + + +@router.put("/me/node_key") +async def register_node_key( + body: NodeKeyRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Link a node daemon's Ed25519 public key to the operator's account.""" + try: + raw = base64.b64decode(body.pk_node_ed25519) + if len(raw) != 32: + raise ValueError + except Exception: + raise HTTPException(status_code=400, detail="Invalid Ed25519 public key (need 32 bytes base64)") + + current_user.pk_node_ed25519 = body.pk_node_ed25519 + await db.commit() + return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} + + +class RotateKeysRequest(BaseModel): + pk_user_ed25519: str # base64 raw 32B + pk_user_x25519: str # base64 raw 32B + + +@router.put("/me/keys") +async def rotate_browser_keys( + body: RotateKeysRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + for field, label in [ + (body.pk_user_ed25519, "Ed25519"), + (body.pk_user_x25519, "X25519"), + ]: + try: + raw = base64.b64decode(field) + if len(raw) != 32: + raise ValueError + except Exception: + raise HTTPException( + status_code=400, + detail=f"Invalid {label} public key (need 32 bytes base64)", + ) + + current_user.pk_ed25519 = body.pk_user_ed25519 + current_user.pk_x25519 = body.pk_user_x25519 + await db.commit() + return { + "status": "updated", + "pk_ed25519": body.pk_user_ed25519, + "pk_x25519": body.pk_user_x25519, + } + + @router.get("/{username}/pubkeys") async def get_user_pubkeys( username: str, @@ -258,12 +347,15 @@ async def get_user_pubkeys( target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") - return { + resp = { "user_id": target.id, "username": target.username, "pk_ed25519": target.pk_ed25519, "pk_x25519": target.pk_x25519, } + if target.pk_node_ed25519: + resp["pk_node_ed25519"] = target.pk_node_ed25519 + return resp def _client_ip(request: Request) -> str: |