"""Auth API — admin sign-in (phase 16) + token login (phase 79). * ``POST /api/login`` — 204 + signed session cookie on success; 401 ``invalid password`` on any mismatch (constant-time, one generic message, no session set); while the per-IP failure window is exhausted → 429 + ``Retry-After``, one generic detail — audit SEC-03. * ``POST /api/token-auth`` — 204 + signed session cookie for a valid, unrevoked API token (PUBLIC — it is the token holders' login route); every failure shape (malformed / unknown / revoked / empty) is ONE generic 401 ``invalid token`` (no enumeration, the phase-16 pattern); while the per-IP failure window is exhausted → 429 + ``Retry-After``, one generic detail — audit SEC-03 (the 429 counter is SHARED with ``/api/login``: any auth failure from an IP counts, a success on either route resets it). * ``POST /api/logout`` — 204; clears the session and expires the cookie (idempotent for anonymous callers — one logout wipes BOTH roles, the session is one dict). * ``GET /api/whoami`` — ``{"authenticated": bool, "role": "admin"|"user"|"anonymous"}``; the single source of truth for all UI gating (phase 79: the third role; the UI's admin-only surfaces key off ``role === "admin"`` specifically). The rest of the public surface is the phase-79 contract: health, config, whoami, login, token-auth, and the shared chats stay anonymous — chat, suggestions, and the document viewer content are user-gated (``require_user``). """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session from app.config import get_settings from app.core import rate_limit from app.core import tokens as token_service from app.core.auth import ( ADMIN_SESSION_KEY, USER_SESSION_KEY, USER_TOKEN_ID_KEY, check_password, sign_in, sign_out, ) from app.db import get_db from app.schemas import LoginRequest, TokenAuthRequest, WhoamiResponse #: One generic 429 detail shared by BOTH throttled login routes (audit #: SEC-03): no enumeration between a login failure and a token failure. TOO_MANY_DETAIL = "too many failed sign-in attempts — try again later" 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. Rate limiting (audit SEC-03): while the per-IP failure window is exhausted (10 misses in 15 min, shared with ``/api/token-auth``), the pre-check fires BEFORE the password is compared — 429 + ``Retry-After``, one generic detail; even a correct password stays 429 until the window slides. A success on a CLEAN counter resets it; a failure is recorded on the 401 path. """ ip = request.client.host if request.client is not None else "unknown" if (wait := rate_limit.remaining_wait(ip)) > 0: raise HTTPException( status_code=429, detail=TOO_MANY_DETAIL, headers={"Retry-After": str(wait)}, ) settings = get_settings() if check_password(payload.password, settings.admin_password): sign_in(request.session) rate_limit.reset(ip) return Response(status_code=204) rate_limit.record_failure(ip) raise HTTPException(status_code=401, detail="invalid password") @router.post("/token-auth", status_code=204) def token_auth( payload: TokenAuthRequest, request: Request, db: Session = Depends(get_db), # noqa: B008 ) -> Response: """Sign a token holder in (phase 79 — PUBLIC: this IS the login). A valid, unrevoked token → 204 + the signed ``bor_session`` cookie (the same SessionMiddleware mechanism as ``/api/login``): the session carries the ``user`` key plus the token's row id (``user_token_id``), and ``require_user``'s live row check enforces revocation from the holder's very next request. The token's ``last_used_at`` is stamped here (the admin's Tokens view shows the login as the last use). EVERY failure shape — malformed, unknown, revoked, empty — is ONE generic 401 ``invalid token``: the lookup is by hash (the service returns ``None`` for anything that is not an active row), so there is no enumeration surface (the phase-16 pattern). Rate limiting (audit SEC-03): identical bookkeeping to ``/api/login`` on the SAME per-IP counter — pre-check → 429 + ``Retry-After`` while the window is exhausted; failures recorded, a success resets. """ ip = request.client.host if request.client is not None else "unknown" if (wait := rate_limit.remaining_wait(ip)) > 0: raise HTTPException( status_code=429, detail=TOO_MANY_DETAIL, headers={"Retry-After": str(wait)}, ) token = payload.token.strip() row = token_service.find_active_by_token(db, token) if token else None if row is None: rate_limit.record_failure(ip) raise HTTPException(status_code=401, detail="invalid token") token_service.mark_used(row) db.commit() request.session[USER_SESSION_KEY] = True request.session[USER_TOKEN_ID_KEY] = str(row.id) rate_limit.reset(ip) return Response(status_code=204) @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. One logout wipes BOTH roles (admin and token — the session is one dict). 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. Three roles (phase 79): ``admin`` (the signed-in admin — wins when the browser holds BOTH an admin and a token session), ``user`` (a token holder), ``anonymous``. ``authenticated`` is true for admin AND user; the UI's admin-only surfaces key off ``role === "admin"`` specifically, not off ``authenticated``. This endpoint only reads the session keys — it does NOT live-check the token row. A token session whose row was just revoked still reports ``user`` here until the next gated request: ``require_user`` then pops the dead keys, after which this endpoint reports anonymous (the phase-79 live-enforcement contract). """ if request.session.get(ADMIN_SESSION_KEY): role = "admin" elif request.session.get(USER_SESSION_KEY): role = "user" else: role = "anonymous" return WhoamiResponse(authenticated=role != "anonymous", role=role)