auth: add FastAPI service — models, deps, server, routers, CLI

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.
This commit is contained in:
Davide Scaini
2026-06-02 14:38:56 +02:00
parent a3a98c033d
commit ddd15cae0f
7 changed files with 647 additions and 0 deletions
+111
View File
@@ -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")
+156
View File
@@ -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)
+45
View File
@@ -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, 130 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
+118
View File
@@ -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})
+155
View File
@@ -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
+39
View File
@@ -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})
+23
View File
@@ -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)