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 | 104 |
1 files changed, 44 insertions, 60 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 53238de..af9141c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +22,7 @@ from meshbay_hub.auth import ( verify_password, ) from meshbay_hub.api.middleware import limiter +from meshbay_hub.api.netutil import client_ip from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User @@ -49,8 +50,6 @@ class RegisterRequest(BaseModel): email: 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 @field_validator("username") @classmethod @@ -62,6 +61,24 @@ class RegisterRequest(BaseModel): raise ValueError("username: only letters, digits, -, _, .") return v + @field_validator("email") + @classmethod + def email_valid(cls, v: str) -> str: + """ + Sanity-check the address (L6): the field was plain `str`, so any junk was + accepted and stored encrypted forever. Deliberately not RFC 5322 — full + validation would pull in the email-validator dependency for little gain, + and the address is only ever used for recovery and legal contact. + """ + v = v.strip() + local, sep, domain = v.partition("@") + if (not sep or not local or not domain + or "." not in domain + or len(v) > 254 + or any(c.isspace() or ord(c) < 32 for c in v)): + raise ValueError("invalid email address") + return v + class LoginRequest(BaseModel): username: str @@ -101,26 +118,27 @@ async def register( pw_hash=pw_hash, pw_salt=pw_salt, pw_version=pw_ver, - pk_ed25519=body.pk_user_ed25519, - pk_x25519=body.pk_user_x25519, hub_id=hub_id, ) db.add(user) + # flush assigns user.id so the log row can be attributed directly. + # + # Finding M6: this used to insert the row with a NULL user_id and then run + # UPDATE ip_logs SET user_id = <new user> WHERE user_id IS NULL + # which claimed *every* unattributed row in the table — failed logins for other + # usernames, other registrations racing this one — and stamped them with the + # account just created. For logs retained a year to answer legal requests, that + # attributed other people's connections to the wrong person. + await db.flush() db.add(IPLog( + user_id=user.id, event="account_create", - ip_address=_client_ip(request), + ip_address=client_ip(request), detail=body.username, )) await db.commit() await db.refresh(user) - # Set user_id in IPLog after commit - await db.execute( - IPLog.__table__.update() - .where(IPLog.user_id == None) # noqa: E711 - .values(user_id=user.id)) - await db.commit() - return {"user_id": user.id} @@ -135,7 +153,7 @@ async def login( select(User).where(User.username == body.username)) user = result.scalar_one_or_none() - ip = _client_ip(request) + ip = client_ip(request) if not body.auth_key and not body.password: raise HTTPException(status_code=401, detail="No credentials provided") @@ -189,8 +207,7 @@ async def login( 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) + access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() family_id = str(uuid.uuid4()) @@ -255,8 +272,7 @@ async def token_refresh( memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] - new_access = issue_access_token( - user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids) + new_access = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) await db.commit() return { @@ -302,39 +318,10 @@ async def register_node_key( 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, - } +# Key rotation used to live here (`PUT /me/keys`). Identity keys are per node +# now, so rotating means `meshbay-node member unpin <user>` and pairing again with +# a fresh code — an operator decision on the machine that pinned it, not a hub +# call that silently changes what every node believes about someone. @router.get("/{username}/pubkeys") @@ -347,19 +334,16 @@ async def get_user_pubkeys( target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") + # Account lookup, not a key directory. `user_id` is how a username is resolved + # for an invitation, and `pk_node_ed25519` is a node's own linking key. The + # user identity keys this used to return were H3: whoever asked wrapped the + # group key for whatever came back. resp = { - "user_id": target.id, - "username": target.username, - "pk_ed25519": target.pk_ed25519, - "pk_x25519": target.pk_x25519, + "user_id": target.id, + "username": target.username, } if target.pk_node_ed25519: resp["pk_node_ed25519"] = target.pk_node_ed25519 return resp -def _client_ip(request: Request) -> str: - forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - return forwarded.split(",")[0].strip() - return request.client.host if request.client else "unknown" |