90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""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()
|