aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
commitfb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch)
treeeb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/src/meshbay_hub/api/users.py
parent77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff)
downloadmeshbay-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.py199
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"