diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
| commit | fb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch) | |
| tree | eb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/src/meshbay_hub/api/users.py | |
| parent | 77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff) | |
| download | meshbay-fb91c4545c757711e1b5fd354ca4b311c89fd2c0.tar.gz | |
feat(hub): add production hub — config, auth, API routers, tests
config.py: TOML + env var priority. auth.py: Argon2id passwords,
JWT EdDSA with jti, refresh token hashed (blake3). Routers:
hub (info/pubkey), users (register/login/refresh/pubkeys),
nodes (announce/get), groups (create/gek-bundle/gek-retrieve).
Rate limiting via slowapi. app.py factory with lifespan.
All 40 tests pass (SQLite in-memory, no PostgreSQL required).
Fix: remove tests/__init__.py to resolve namespace conflicts.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <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 | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py new file mode 100644 index 0000000..0b615a4 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -0,0 +1,199 @@ +"""User endpoints — /v1/users/*""" + +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, EmailStr, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub.auth import ( + decode_access_token, + generate_refresh_token, + hash_password, + hash_refresh_token, + hub_public_key_pem, + issue_access_token, + verify_password, +) +from meshbay_hub.config import HubConfig +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import IPLog, RefreshToken, User +from meshbay_hub.api.deps import get_current_user + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +_cfg: HubConfig | None = None + +def set_config(cfg: HubConfig) -> None: + global _cfg + _cfg = cfg + +def _ttl() -> int: + return _cfg.jwt.access_token_ttl if _cfg else 3600 + +def _refresh_ttl() -> int: + return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30 + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class RegisterRequest(BaseModel): + username: str + email: str + password: str + pk_user_ed25519: str # base64 raw 32B + pk_user_x25519: str # base64 raw 32B + + @field_validator("username") + @classmethod + def username_valid(cls, v: str) -> str: + v = v.strip() + if len(v) < 3 or len(v) > 64: + raise ValueError("username must be 3-64 chars") + if not v.replace("_", "").replace("-", "").replace(".", "").isalnum(): + 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 + + +class RefreshRequest(BaseModel): + refresh_token: str + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.post("/register", status_code=201) +async def register( + body: RegisterRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + existing = await db.execute( + select(User).where(User.username == body.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Username already taken") + + pw_hash, pw_salt = hash_password(body.password) + hub_id = _cfg.identity.id if _cfg else "meshbay.org" + user = User( + username=body.username, + email=body.email, + pw_hash=pw_hash, + pw_salt=pw_salt, + pk_ed25519=body.pk_user_ed25519, + pk_x25519=body.pk_user_x25519, + hub_id=hub_id, + ) + db.add(user) + db.add(IPLog( + event="account_create", + 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} + + +@router.post("/login") +async def login( + body: LoginRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(User).where(User.username == body.username)) + 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): + 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.status != "active": + raise HTTPException(status_code=403, detail=f"Account {user.status}") + + access_token = issue_access_token(user.id, user.pk_ed25519, ttl=_ttl()) + raw_rt, rt_hash = generate_refresh_token() + + 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(IPLog(user_id=user.id, event="login", ip_address=ip)) + await db.commit() + + return { + "access_token": access_token, + "refresh_token": raw_rt, + "token_type": "bearer", + "expires_in": _ttl(), + } + + +@router.post("/token/refresh") +async def token_refresh( + body: RefreshRequest, + 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 + )) + 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") + + 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") + + new_token = issue_access_token(user.id, user.pk_ed25519, ttl=_ttl()) + return {"access_token": new_token, "token_type": "bearer", "expires_in": _ttl()} + + +@router.get("/{username}/pubkeys") +async def get_user_pubkeys( + username: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.username == username)) + target = result.scalar_one_or_none() + if not target: + raise HTTPException(status_code=404, detail="User not found") + return { + "user_id": target.id, + "username": target.username, + "pk_ed25519": target.pk_ed25519, + "pk_x25519": target.pk_x25519, + } + + +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" |