feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public

This commit is contained in:
2026-08-23 19:58:39 -04:00
parent fc0d9a2d5c
commit cbc263a4b2
46 changed files with 1555 additions and 691 deletions
+89
View File
@@ -0,0 +1,89 @@
"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22).
One admin (the owner), one plaintext password, one signed cookie. The
mechanism is Starlette's ``SessionMiddleware`` (itsdangerous-signed cookie
— no server-side store, no new services, no DB tables): the public API
stays stateless, the cookie is the *only* session state.
Contract:
* ``ensure_admin_configured`` — fail-loud startup gate: the app must name
the missing ``BOR_`` variable(s) instead of serving anything.
* ``check_password`` — constant-time compare; exactly one generic 401
message (no user enumeration, there is no second user).
* ``require_admin`` — FastAPI dependency; anonymous callers get 403
``{"detail": "admin only"}`` (used by ``GET /api/docs`` and the whole
``/api/steering`` router).
* ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes.
"""
from __future__ import annotations
import secrets
from collections.abc import MutableMapping
from typing import Any
from fastapi import HTTPException, Request
from app.config import Settings
#: The session key the single admin is stored under.
ADMIN_SESSION_KEY = "admin"
def ensure_admin_configured(settings: Settings) -> None:
"""Refuse to boot without admin auth (fail-loud, A6 spirit).
Raises :class:`RuntimeError` naming every missing ``BOR_`` variable so
the startup log tells the operator exactly what to set.
"""
missing = [
env_name
for env_name, value in (
("BOR_ADMIN_PASSWORD", settings.admin_password),
("BOR_SESSION_SECRET", settings.session_secret),
)
if not value.strip()
]
if missing:
raise RuntimeError(
"Brain of Reese cannot start: admin auth is not configured. "
f"Set the missing variable(s): {', '.join(missing)} "
"(see .env.example and the README 'Admin & sign-in' section)."
)
def check_password(candidate: str, expected: str) -> bool:
"""Constant-time password check (``secrets.compare_digest``).
One admin → one generic 401 on any mismatch: the response never reveals
whether the password was *close*, empty, or not (no user enumeration).
"""
return secrets.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
def require_admin(request: Request) -> None:
"""FastAPI dependency: allow the signed-in admin, else 403.
Reads the cookie-backed session installed by ``SessionMiddleware``;
a request without a valid admin session gets 403 ``admin only``.
"""
if not request.session.get(ADMIN_SESSION_KEY):
raise HTTPException(status_code=403, detail="admin only")
def sign_in(session: MutableMapping[str, Any]) -> None:
"""Mark the (cookie-backed) session as the single admin.
Writing the key marks the session modified, so the middleware emits
the signed ``bor_session`` cookie with the configured Max-Age.
"""
session[ADMIN_SESSION_KEY] = True
def sign_out(session: MutableMapping[str, Any]) -> None:
"""Clear the session state (the route additionally expires the cookie).
An emptied dict is not re-persisted by the middleware, so the API
route pairs this with ``response.delete_cookie`` to make the browser
drop the signed cookie immediately.
"""
session.clear()