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:
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user