"""Auth route handlers — login, logout, me (TASK-03-05, D-041, D-056, D-057). APIRouter(prefix="/api/operator") with: POST /login — rate-limited 5/min (TASK-03-03), NOT auth-gated. POST /logout — auth-gated (Depends(current_operator)). GET /me — auth-gated (React route guard — D-057). Stateless cookies (D-056): logout clears the server-side session; the client also clears its cookie. No sessions table. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel from server.auth.dependencies import current_operator from server.auth.models import Operator from server.auth.passwords import hash_password, needs_rehash, verify_password from server.auth.rate_limit import rate_limit_login router = APIRouter(prefix="/api/operator", tags=["operator-auth"]) class LoginBody(BaseModel): username: str password: str class OperatorOut(BaseModel): id: str username: str display_name: str | None role: str = "operator" class LoginResponse(BaseModel): operator: OperatorOut class MeResponse(BaseModel): operator: OperatorOut class OkResponse(BaseModel): ok: bool = True def _operator_out(op: Operator) -> OperatorOut: return OperatorOut( id=op.id, username=op.username, display_name=op.display_name, role=op.role, ) @router.post("/login", response_model=LoginResponse) @rate_limit_login() async def login(body: LoginBody, request: Request) -> LoginResponse: """Rate-limited login (5/min per IP — D-041). On success: sets `request.session["operator_id"]` (signed cookie via SessionMiddleware) + updates last_login_at. On needs_rehash → rehash + update the store. On failure → 401 (no cookie set). """ pg_store = getattr(request.app.state, "pg_store", None) if pg_store is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="operator tier unavailable (no Postgres)", ) row = await pg_store.get_operator_by_username(body.username) if row is None or not row.get("is_active"): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials", ) if not verify_password(row["password_hash"], body.password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials", ) op_id = str(row["id"]) request.session["operator_id"] = op_id await pg_store.update_last_login(op_id) if needs_rehash(row["password_hash"]): new_hash = hash_password(body.password) async with pg_store.pool.acquire() as conn: await conn.execute( "UPDATE operators SET password_hash = $1 WHERE id = $2", new_hash, op_id, ) return LoginResponse( operator=OperatorOut( id=op_id, username=row["username"], display_name=row.get("display_name"), role=row.get("role", "operator"), ) ) @router.post("/logout", response_model=OkResponse) async def logout(request: Request, op: Operator = Depends(current_operator)) -> OkResponse: # Stateless (D-056): clearing the server session invalidates the signed # cookie's payload; the client also clears its cookie. request.session.clear() return OkResponse(ok=True) @router.get("/me", response_model=MeResponse) async def me(op: Operator = Depends(current_operator)) -> MeResponse: """React route guard endpoint (D-057). 200 → render; 401 → redirect.""" return MeResponse(operator=_operator_out(op)) __all__ = ["router"]