64 lines
2.5 KiB
Python
64 lines
2.5 KiB
Python
"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22).
|
|
|
|
* ``POST /api/login`` — 204 + signed session cookie on success; 401
|
|
``invalid password`` on any mismatch (constant-time, one generic
|
|
message, no session set).
|
|
* ``POST /api/logout`` — 204; clears the session and expires the cookie
|
|
(idempotent for anonymous callers).
|
|
* ``GET /api/whoami`` — ``{"authenticated": bool, "role":
|
|
"admin"|"anonymous"}``; the single source of truth for all UI gating.
|
|
|
|
The public API otherwise stays stateless (A10): chat, the document
|
|
content endpoint (soft rule — anonymous may open any document by direct
|
|
URL), suggestions, and health never require the cookie.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Response
|
|
|
|
from app.config import get_settings
|
|
from app.core.auth import ADMIN_SESSION_KEY, check_password, sign_in, sign_out
|
|
from app.schemas import LoginRequest, WhoamiResponse
|
|
|
|
router = APIRouter(tags=["auth"])
|
|
|
|
|
|
@router.post("/login", status_code=204)
|
|
def login(payload: LoginRequest, request: Request) -> Response:
|
|
"""Sign in the single admin.
|
|
|
|
Success: 204 + the signed ``bor_session`` cookie (``same_site=lax``,
|
|
12 h default lifetime). Failure: one generic 401 — the admin count is
|
|
one, so there is nothing else to leak, and a wrong password must not
|
|
set any session state.
|
|
"""
|
|
settings = get_settings()
|
|
if check_password(payload.password, settings.admin_password):
|
|
sign_in(request.session)
|
|
return Response(status_code=204)
|
|
raise HTTPException(status_code=401, detail="invalid password")
|
|
|
|
|
|
@router.post("/logout", status_code=204)
|
|
def logout(request: Request, response: Response) -> Response:
|
|
"""Sign out: clear the session AND expire the browser cookie.
|
|
|
|
``sign_out`` empties the session dict (which the middleware does not
|
|
re-persist — an empty session has nothing to sign), so this route also
|
|
sends ``delete_cookie`` to make the browser drop the signed cookie
|
|
right now. Idempotent: an anonymous logout is still a 204.
|
|
"""
|
|
sign_out(request.session)
|
|
response.delete_cookie(get_settings().session_cookie, path="/")
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/whoami", response_model=WhoamiResponse)
|
|
def whoami(request: Request) -> WhoamiResponse:
|
|
"""Who is the caller? Drives every UI gating decision (phase 16)."""
|
|
authenticated = bool(request.session.get(ADMIN_SESSION_KEY))
|
|
return WhoamiResponse(
|
|
authenticated=authenticated,
|
|
role="admin" if authenticated else "anonymous",
|
|
)
|