From ddd15cae0f64ddb98741df20894a7c6293df15bb Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Tue, 2 Jun 2026 14:38:56 +0200 Subject: [PATCH] =?UTF-8?q?auth:=20add=20FastAPI=20service=20=E2=80=94=20m?= =?UTF-8?q?odels,=20deps,=20server,=20routers,=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 3–7 of the migration plan: - models.py: Pydantic request/response types - deps.py: shared state, JWT-based auth helpers, rate limiting - server.py: FastAPI app with CORS + gzip - routers/auth.py: login, logout, /api/me, reset-password, register - routers/invites.py: GET/POST /api/invites - routers/admin.py: user listing, suspend/unsuspend, delete, access flags, reset-password-code - cli.py: `bincio-auth init` (creates DB + admin + JWT secret) and `bincio-auth serve` Cookie carries a signed JWT (HS256); consumers validate locally with shared secret. --- bincio/auth/cli.py | 111 +++++++++++++++++++++++ bincio/auth/deps.py | 156 +++++++++++++++++++++++++++++++++ bincio/auth/models.py | 45 ++++++++++ bincio/auth/routers/admin.py | 118 +++++++++++++++++++++++++ bincio/auth/routers/auth.py | 155 ++++++++++++++++++++++++++++++++ bincio/auth/routers/invites.py | 39 +++++++++ bincio/auth/server.py | 23 +++++ 7 files changed, 647 insertions(+) create mode 100644 bincio/auth/cli.py create mode 100644 bincio/auth/deps.py create mode 100644 bincio/auth/models.py create mode 100644 bincio/auth/routers/admin.py create mode 100644 bincio/auth/routers/auth.py create mode 100644 bincio/auth/routers/invites.py create mode 100644 bincio/auth/server.py diff --git a/bincio/auth/cli.py b/bincio/auth/cli.py new file mode 100644 index 0000000..86c17b7 --- /dev/null +++ b/bincio/auth/cli.py @@ -0,0 +1,111 @@ +"""bincio-auth CLI entry point.""" + +from __future__ import annotations + +import secrets +from pathlib import Path + +import click +from rich.console import Console +from rich.panel import Panel + +console = Console() + + +@click.group() +def main() -> None: + """bincio-auth — central authentication service.""" + + +@main.command("init") +@click.option("--data-dir", required=True, type=click.Path(), help="Data directory to initialise") +@click.option("--handle", required=True, help="Admin user handle") +@click.option("--password", required=True, hide_input=True, confirmation_prompt=True) +@click.option("--display-name", default="", help="Admin display name (defaults to handle)") +@click.option("--max-users", default=0, type=int, help="Max users (0 = unlimited)") +def init_cmd(data_dir: str, handle: str, password: str, display_name: str, max_users: int) -> None: + """Bootstrap a fresh bincio-auth instance.""" + from bincio.auth.db import create_invite, create_user, get_user, open_db, set_setting + + dd = Path(data_dir).expanduser().resolve() + dd.mkdir(parents=True, exist_ok=True) + + console.print(f"[bold]Initialising bincio-auth[/bold] at [cyan]{dd}[/cyan]") + + db = open_db(dd) + console.print(" [green]✓[/green] instance.db ready") + + existing = get_user(db, handle) + if existing: + console.print(f" [yellow]·[/yellow] user '{handle}' already exists — skipping") + else: + create_user(db, handle, display_name or handle, password, is_admin=True) + console.print(f" [green]✓[/green] admin '{handle}' created") + + if max_users > 0: + set_setting(db, "max_users", str(max_users)) + console.print(f" [green]✓[/green] user limit set to {max_users}") + + # Generate JWT secret if not already stored + from bincio.auth.db import get_setting + if not get_setting(db, "jwt_secret"): + jwt_secret = secrets.token_hex(32) + set_setting(db, "jwt_secret", jwt_secret) + console.print(" [green]✓[/green] JWT secret generated and stored") + else: + console.print(" [yellow]·[/yellow] JWT secret already set — skipping") + + code = create_invite(db, handle) + + console.print() + console.print(Panel( + f"[bold green]Instance ready![/bold green]\n\n" + f"Admin: [cyan]{handle}[/cyan]\n" + f"Data dir: [cyan]{dd}[/cyan]\n\n" + f"First invite code:\n\n" + f" [bold yellow]{code}[/bold yellow]\n\n" + f"Share this with your first user:\n" + f" /register/?code={code}", + title="bincio-auth init", + border_style="green", + )) + + +@main.command("serve") +@click.option("--data-dir", required=True, type=click.Path(), help="Data directory (contains instance.db)") +@click.option("--host", default="127.0.0.1") +@click.option("--port", default=4040, type=int) +@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", + help="HS256 signing secret. Defaults to the value stored in the DB during `init`.") +def serve_cmd(data_dir: str, host: str, port: int, jwt_secret: str | None) -> None: + """Start the bincio-auth API server.""" + import uvicorn + + import bincio.auth.server as srv + from bincio.auth import deps + from bincio.auth.db import get_setting, open_db + + dd = Path(data_dir).expanduser().resolve() + if not (dd / "instance.db").exists(): + raise click.UsageError( + f"No instance.db in {dd}. Run `bincio-auth init --data-dir {dd}` first." + ) + + db = open_db(dd) + secret = jwt_secret or get_setting(db, "jwt_secret") or "" + db.close() + + if not secret: + raise click.UsageError( + "No JWT secret configured. Pass --jwt-secret or run `bincio-auth init` first." + ) + + deps.data_dir = dd + deps.jwt_secret = secret + + console.print("[bold]bincio-auth[/bold]") + console.print(f" Data: [cyan]{dd}[/cyan]") + console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") + console.print() + + uvicorn.run(srv.app, host=host, port=port, log_level="info") diff --git a/bincio/auth/deps.py b/bincio/auth/deps.py new file mode 100644 index 0000000..3dafa1e --- /dev/null +++ b/bincio/auth/deps.py @@ -0,0 +1,156 @@ +"""Shared state and FastAPI dependency functions for bincio-auth.""" + +from __future__ import annotations + +import os +import re +import time +from pathlib import Path + +import jwt as _jwt +from fastapi import Cookie, HTTPException, Request, Response + +from bincio.auth.db import User, get_user, open_db +from bincio.auth.tokens import create_token, decode_token + +# ── Module-level state (set by CLI before uvicorn starts) ───────────────────── + +data_dir: Path | None = None +jwt_secret: str = "" +_db = None + +# ── Constants ───────────────────────────────────────────────────────────────── + +_VALID_HANDLE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,29}$") +_SESSION_COOKIE = "bincio_session" +_COOKIE_MAX_AGE = 30 * 86400 +_COOKIE_DOMAIN = os.environ.get("SESSION_DOMAIN") or None +_JWT_TTL = 30 * 86400 + +_login_attempts: dict[str, list[float]] = {} +_register_attempts: dict[str, list[float]] = {} +_RATE_WINDOW = 900 +_LOGIN_RATE_LIMIT = 10 +_REGISTER_RATE_LIMIT = 5 + + +# ── Core helpers ────────────────────────────────────────────────────────────── + +def _get_data_dir() -> Path: + if data_dir is None: + raise HTTPException(500, "Server not configured") + return data_dir + + +def _get_db(): + global _db + if _db is None: + _db = open_db(_get_data_dir()) + return _db + + +def _issue_jwt(user: User) -> str: + return create_token( + { + "sub": user.handle, + "display_name": user.display_name, + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }, + jwt_secret, + _JWT_TTL, + ) + + +# ── Rate limiting ───────────────────────────────────────────────────────────── + +def _check_rate_limit( + ip: str, + store: dict[str, list[float]], + limit: int, + msg: str = "Too many attempts. Try again later.", +) -> None: + now = time.time() + attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW] + store[ip] = attempts + if len(attempts) >= limit: + raise HTTPException(429, msg) + attempts.append(now) + store[ip] = attempts + + +# ── Auth dependency functions ───────────────────────────────────────────────── + +def _decode_session(token: str) -> User | None: + """Decode JWT and return the live User, or None if invalid/suspended.""" + try: + payload = decode_token(token, jwt_secret) + except _jwt.PyJWTError: + return None + handle = payload.get("sub") + if not handle: + return None + user = get_user(_get_db(), handle) + if not user or user.suspended: + return None + return user + + +def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None: + if not bincio_session: + return None + return _decode_session(bincio_session) + + +def _require_user(bincio_session: str | None = Cookie(default=None)) -> User: + user = _current_user(bincio_session) + if not user: + raise HTTPException(401, "Not authenticated") + return user + + +def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User: + user = _require_user(bincio_session) + if not user.is_admin: + raise HTTPException(403, "Admin required") + return user + + +def _require_auth( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> User: + """Accept JWT cookie (web) OR Authorization: Bearer token (mobile).""" + token = bincio_session + if not token: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if not token: + raise HTTPException(401, "Not authenticated") + user = _decode_session(token) + if not user: + raise HTTPException(401, "Invalid or expired token") + return user + + +def _set_session_cookie(response: Response, token: str) -> None: + kwargs: dict = dict( + key=_SESSION_COOKIE, + value=token, + max_age=_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=False, + ) + if _COOKIE_DOMAIN: + kwargs["domain"] = _COOKIE_DOMAIN + response.set_cookie(**kwargs) + + +def _clear_session_cookie(response: Response) -> None: + kwargs: dict = dict(key=_SESSION_COOKIE) + if _COOKIE_DOMAIN: + kwargs["domain"] = _COOKIE_DOMAIN + response.delete_cookie(**kwargs) diff --git a/bincio/auth/models.py b/bincio/auth/models.py new file mode 100644 index 0000000..00a96e3 --- /dev/null +++ b/bincio/auth/models.py @@ -0,0 +1,45 @@ +"""Pydantic request/response models for bincio-auth.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + handle: str = Field(..., description="User handle") + password: str = Field(..., description="User password") + + +class LoginResponse(BaseModel): + ok: bool = True + handle: str + display_name: str + is_admin: bool + wiki_access: bool + activity_access: bool + + +class RegisterRequest(BaseModel): + code: str = Field(..., description="Invite code") + handle: str = Field(..., description="Desired username (lowercase, 1–30 chars)") + password: str = Field(..., description="Password (min 8 characters)") + display_name: str = Field(default="", description="Full name (optional)") + + +class RegisterResponse(BaseModel): + ok: bool = True + handle: str + + +class ResetPasswordRequest(BaseModel): + handle: str + code: str = Field(..., description="One-time reset code (24 h TTL)") + password: str = Field(..., description="New password (min 8 chars)") + + +class CreateInviteRequest(BaseModel): + grants_activity: bool = Field(default=False) + + +class GenericResponse(BaseModel): + ok: bool = True diff --git a/bincio/auth/routers/admin.py b/bincio/auth/routers/admin.py new file mode 100644 index 0000000..41ad40d --- /dev/null +++ b/bincio/auth/routers/admin.py @@ -0,0 +1,118 @@ +"""Admin user-management endpoints (/api/admin/*).""" + +from __future__ import annotations + +from fastapi import APIRouter, Cookie, HTTPException +from fastapi.responses import JSONResponse + +from bincio.auth import deps +from bincio.auth.db import ( + create_reset_code, + delete_user, + get_user, + list_users, + purge_expired_sessions, + set_suspended, +) + +router = APIRouter() + + +@router.get("/api/admin/users") +async def admin_list_users(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + deps._require_admin(bincio_session) + users = list_users(deps._get_db()) + return JSONResponse([{ + "handle": u.handle, + "display_name": u.display_name, + "is_admin": u.is_admin, + "wiki_access": u.wiki_access, + "activity_access": u.activity_access, + "suspended": u.suspended, + "created_at": u.created_at, + } for u in users]) + + +@router.post("/api/admin/users/{handle}/reset-password-code") +async def admin_reset_password_code( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + admin = deps._require_admin(bincio_session) + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, f"User '{handle}' not found") + code = create_reset_code(db, handle, admin.handle) + return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24}) + + +@router.post("/api/admin/users/{handle}/suspend") +async def admin_suspend( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + admin = deps._require_admin(bincio_session) + if handle == admin.handle: + raise HTTPException(400, "Cannot suspend yourself") + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + set_suspended(db, handle, True) + purge_expired_sessions(db) + return JSONResponse({"status": "suspended", "handle": handle}) + + +@router.post("/api/admin/users/{handle}/unsuspend") +async def admin_unsuspend( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + deps._require_admin(bincio_session) + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + set_suspended(db, handle, False) + return JSONResponse({"status": "unsuspended", "handle": handle}) + + +@router.delete("/api/admin/users/{handle}") +async def admin_delete_user( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + admin = deps._require_admin(bincio_session) + if handle == admin.handle: + raise HTTPException(400, "Cannot delete your own account") + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + delete_user(db, handle) + return JSONResponse({"ok": True, "handle": handle}) + + +@router.patch("/api/admin/users/{handle}/access") +async def admin_set_access( + handle: str, + body: dict, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Set wiki_access and/or activity_access flags for a user.""" + deps._require_admin(bincio_session) + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + + updates = [] + params: list = [] + for flag in ("wiki_access", "activity_access", "is_admin"): + if flag in body: + updates.append(f"{flag} = ?") + params.append(int(bool(body[flag]))) + + if not updates: + raise HTTPException(400, "No valid fields to update") + + params.append(handle) + db.execute(f"UPDATE users SET {', '.join(updates)} WHERE handle = ?", params) + db.commit() + return JSONResponse({"ok": True}) diff --git a/bincio/auth/routers/auth.py b/bincio/auth/routers/auth.py new file mode 100644 index 0000000..bbd58de --- /dev/null +++ b/bincio/auth/routers/auth.py @@ -0,0 +1,155 @@ +"""Authentication and registration endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse + +from bincio.auth import deps +from bincio.auth.db import ( + authenticate, + change_password, + count_activity_users, + count_wiki_users, + create_user, + get_invite, + get_setting, + get_user, + use_invite, + use_reset_code, +) +from bincio.auth.models import ( + GenericResponse, + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + ResetPasswordRequest, +) + +router = APIRouter() + + +@router.post("/api/auth/login", response_model=LoginResponse) +async def login(body: LoginRequest, request: Request) -> JSONResponse: + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, + "Too many login attempts. Try again later.") + + user = authenticate(deps._get_db(), body.handle.strip().lower(), body.password) + if not user: + raise HTTPException(401, "Invalid credentials") + + token = deps._issue_jwt(user) + resp = JSONResponse({ + "ok": True, + "handle": user.handle, + "display_name": user.display_name, + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }) + deps._set_session_cookie(resp, token) + return resp + + +@router.post("/api/auth/token") +async def get_token(body: LoginRequest, request: Request) -> JSONResponse: + """Mobile auth: same as login but returns the JWT in the body.""" + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, + "Too many login attempts. Try again later.") + + user = authenticate(deps._get_db(), body.handle.strip().lower(), body.password) + if not user: + raise HTTPException(401, "Invalid credentials") + + token = deps._issue_jwt(user) + return JSONResponse({ + "ok": True, + "token": token, + "handle": user.handle, + "display_name": user.display_name, + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }) + + +@router.post("/api/auth/logout", response_model=GenericResponse) +async def logout(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + resp = JSONResponse({"ok": True}) + deps._clear_session_cookie(resp) + return resp + + +@router.get("/api/me") +async def me(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + return JSONResponse({ + "handle": user.handle, + "display_name": user.display_name, + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }) + + +@router.post("/api/auth/reset-password", response_model=GenericResponse) +async def reset_password(body: ResetPasswordRequest) -> JSONResponse: + handle = body.handle.strip().lower() + code = body.code.strip().upper() + if len(body.password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + db = deps._get_db() + if not use_reset_code(db, code, handle): + raise HTTPException(400, "Invalid or expired reset code") + change_password(db, handle, body.password) + return JSONResponse({"ok": True}) + + +# ── Registration ────────────────────────────────────────────────────────────── + +@router.post("/api/register", response_model=RegisterResponse) +async def register(body: RegisterRequest, request: Request) -> JSONResponse: + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._register_attempts, deps._REGISTER_RATE_LIMIT, + "Too many registration attempts. Try again later.") + + handle = body.handle.strip().lower() + code = body.code.strip().upper() + display = body.display_name.strip() or handle + + if not deps._VALID_HANDLE.match(handle): + raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)") + if len(body.password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + + db = deps._get_db() + invite = get_invite(db, code) + if not invite or invite.used: + raise HTTPException(400, "Invalid or already-used invite code") + if get_user(db, handle): + raise HTTPException(409, "Handle already taken") + + max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users") + if max_wiki_val is not None: + limit = int(max_wiki_val) + if limit > 0 and count_wiki_users(db) >= limit: + raise HTTPException(403, f"This instance has reached its user limit ({limit})") + + if invite.grants_activity: + max_act_val = get_setting(db, "max_activity_users") + if max_act_val is not None: + limit = int(max_act_val) + if limit > 0 and count_activity_users(db) >= limit: + raise HTTPException(403, f"This instance has reached its activity user limit ({limit})") + + user = create_user(db, handle, display, body.password, + wiki_access=True, activity_access=invite.grants_activity) + use_invite(db, code, handle) + + token = deps._issue_jwt(user) + resp = JSONResponse({"ok": True, "handle": handle}) + deps._set_session_cookie(resp, token) + return resp diff --git a/bincio/auth/routers/invites.py b/bincio/auth/routers/invites.py new file mode 100644 index 0000000..f051ffb --- /dev/null +++ b/bincio/auth/routers/invites.py @@ -0,0 +1,39 @@ +"""Invite management endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Cookie, HTTPException +from fastapi.responses import JSONResponse + +from bincio.auth import deps +from bincio.auth.db import create_invite, list_invites +from bincio.auth.models import CreateInviteRequest + +router = APIRouter() + + +@router.get("/api/invites") +async def get_invites(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + invites = list_invites(deps._get_db(), user.handle) + return JSONResponse([{ + "code": i.code, + "used": i.used, + "used_by": i.used_by, + "created_at": i.created_at, + "used_at": i.used_at, + "grants_activity": i.grants_activity, + } for i in invites]) + + +@router.post("/api/invites") +async def post_invite( + body: CreateInviteRequest = CreateInviteRequest(), # noqa: B008 + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + try: + code = create_invite(deps._get_db(), user.handle, grants_activity=body.grants_activity) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity}) diff --git a/bincio/auth/server.py b/bincio/auth/server.py new file mode 100644 index 0000000..5661637 --- /dev/null +++ b/bincio/auth/server.py @@ -0,0 +1,23 @@ +"""bincio-auth FastAPI application.""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware + +from bincio.auth.routers import admin, auth, invites + +app = FastAPI(title="bincio-auth") + +app.add_middleware(GZipMiddleware, minimum_size=1024) +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org", + allow_credentials=True, + allow_methods=["GET", "POST", "DELETE", "PATCH"], + allow_headers=["Content-Type", "Authorization"], +) + +for _router in [auth.router, invites.router, admin.router]: + app.include_router(_router)