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