Harden admin authentication behind trusted proxy

This commit is contained in:
plx
2026-07-30 22:38:48 +00:00
parent f1b9a2022b
commit c82982a605
7 changed files with 273 additions and 44 deletions

72
app/admin_auth.py Normal file
View File

@@ -0,0 +1,72 @@
"""Explicit authentication policy shared by the admin UI and internal API."""
from __future__ import annotations
from hmac import compare_digest
from ipaddress import ip_address
from urllib.parse import urlsplit
from fastapi import HTTPException, Request
from app.config import settings
PROXY_ADMIN_USER_HEADER = "X-ClientFlow-Admin-User"
TOKEN_HEADER = "X-ClientFlow-Admin-Token"
TOKEN_COOKIE = "clientflow_admin_token"
def _is_loopback_request(request: Request) -> bool:
"""Use the transport peer, never a caller-controlled forwarded header."""
host = request.client.host if request.client else ""
try:
return ip_address(host).is_loopback
except ValueError:
return False
def require_admin_auth(request: Request, *, area: str) -> None:
mode = settings.clientflow_admin_auth_mode
detail_prefix = "internal api" if area == "internal_api" else "admin"
if mode == "proxy":
admin_user = (request.headers.get(PROXY_ADMIN_USER_HEADER) or "").strip()
if not admin_user:
raise HTTPException(status_code=401, detail=f"{detail_prefix} proxy auth required")
request.state.clientflow_admin_user = admin_user
return
if mode == "token":
expected = (settings.clientflow_admin_token or "").strip()
if not expected:
raise HTTPException(status_code=503, detail=f"{detail_prefix} auth not configured")
received = (
request.headers.get(TOKEN_HEADER)
or request.cookies.get(TOKEN_COOKIE)
or ""
).strip()
if not received or not compare_digest(received, expected):
raise HTTPException(status_code=401, detail=f"{detail_prefix} auth required")
return
if mode == "local":
if not _is_loopback_request(request):
raise HTTPException(status_code=401, detail=f"{detail_prefix} local access required")
return
# Settings validates the value, but fail closed if it is mutated at runtime.
raise HTTPException(status_code=503, detail=f"{detail_prefix} auth mode invalid")
def safe_local_redirect(referer: str | None, *, fallback: str) -> str:
"""Return only an absolute-path local redirect, preserving its query."""
value = (referer or "").strip()
if not value:
return fallback
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc or not parsed.path.startswith("/") or parsed.path.startswith("//"):
return fallback
target = parsed.path
if parsed.query:
target += f"?{parsed.query}"
return target

View File

@@ -4,7 +4,6 @@ from datetime import datetime, timezone
import html
import json
from uuid import UUID
from hmac import compare_digest
from typing import Optional
from sqlalchemy import text
from fastapi import APIRouter, Request, Depends, HTTPException
@@ -12,7 +11,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse,
from starlette.concurrency import run_in_threadpool
from app.admin_queries import list_action_runs, list_business_events
from app.integration_outbox_service import get_outbox_item, list_outbox, set_outbox_status
from app.config import is_production_like_env, settings
from app.admin_auth import require_admin_auth
from app.config import settings
from app.preparation_service import prepare_task as run_task_preparation
from app.preparation_view_model import build_preparation_view_model
from app.workflow_guard import OperationActionBlocked, get_workflow_action_plan
@@ -63,24 +63,8 @@ from app.admin_ui.layout import layout
from app.admin_ui.styles import ADMIN_UI_V451_CSS
# Route handlers moved to app.admin_ui.pages.* in v4.7.2. ADMIN_UI_CSS moved to app.admin_ui.styles. Já existe documento atual. A associação direta fica bloqueada
def require_admin_access(request: Request) -> None:
"""Proteção opcional da UI admin.
Se CLIENTFLOW_ADMIN_TOKEN estiver vazio, mantém compatibilidade local.
Em produção deve ser definido e enviado em X-ClientFlow-Admin-Token,
cookie clientflow_admin_token, ou query param admin_token atrás de HTTPS/proxy.
"""
expected = (settings.clientflow_admin_token or "").strip()
if not expected:
if is_production_like_env():
raise HTTPException(status_code=503, detail="admin auth not configured")
return
received = (
request.headers.get("X-ClientFlow-Admin-Token")
or request.cookies.get("clientflow_admin_token")
or (request.query_params.get("admin_token") if not is_production_like_env() else None)
or ""
).strip()
if not received or not compare_digest(received, expected):
raise HTTPException(status_code=401, detail="admin auth required")
"""Apply UI auth (including X-ClientFlow-Admin-Token in token mode)."""
require_admin_auth(request, area="admin_ui")
router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)])
def esc(value) -> str:
return html.escape(str(value or ""))

View File

@@ -3109,13 +3109,17 @@ async def fiscal_suggestion_accept_action(suggestion_id: str, request: Request):
@router.post("/fiscal-suggestions/{suggestion_id}/reject")
async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request):
from app.admin_auth import safe_local_redirect
try:
from app.fiscal_enrichment_service import reject_fiscal_suggestion
reject_fiscal_suggestion(suggestion_id, actor="operator_ui")
except Exception as exc:
return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500)
referer = request.headers.get("referer") or "/opportunities"
return RedirectResponse(referer, status_code=303)
redirect_target = safe_local_redirect(
request.headers.get("referer"),
fallback="/opportunities",
)
return RedirectResponse(redirect_target, status_code=303)
@@ -3422,4 +3426,3 @@ async def opportunity_odoo_link_candidate_action(opportunity_id: str, item_id: s
return PlainTextResponse(f"Erro ao associar venda Odoo: {exc}", status_code=500)
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Venda%20Odoo%20associada", status_code=303)

View File

@@ -5,28 +5,16 @@ partials and external monitoring. They do not replace the current admin pages.
"""
from __future__ import annotations
from hmac import compare_digest
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import text
from app.config import is_production_like_env, settings
from app.admin_auth import require_admin_auth
from app.config import settings
from app.db import engine
def require_internal_access(request: Request) -> None:
expected = (settings.clientflow_admin_token or "").strip()
if not expected:
if is_production_like_env():
raise HTTPException(status_code=503, detail="internal api auth not configured")
return
received = (
request.headers.get("X-ClientFlow-Admin-Token")
or request.cookies.get("clientflow_admin_token")
or (request.query_params.get("admin_token") if not is_production_like_env() else None)
or ""
).strip()
if not received or not compare_digest(received, expected):
raise HTTPException(status_code=401, detail="internal api auth required")
"""Apply API auth (including X-ClientFlow-Admin-Token in token mode)."""
require_admin_auth(request, area="internal_api")
router = APIRouter(prefix="/api/internal", tags=["internal"], dependencies=[Depends(require_internal_access)])

View File

@@ -1,3 +1,5 @@
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -21,6 +23,7 @@ class Settings(BaseSettings):
clientflow_persist: bool = True
clientflow_webhook_secret: str = ""
clientflow_admin_auth_mode: Literal["proxy", "token", "local"] = "proxy"
clientflow_admin_token: str = ""
# UI warning for cases where the original opportunity contact and the
@@ -134,10 +137,21 @@ def is_production_like_env() -> bool:
return str(settings.env or "").strip().lower() in {"prod", "production", "staging"}
if is_production_like_env() and not str(settings.clientflow_admin_token or "").strip():
raise RuntimeError(
"CLIENTFLOW_ADMIN_TOKEN é obrigatório em prod/production/staging."
)
def validate_admin_auth_settings() -> None:
if settings.clientflow_admin_auth_mode == "local" and is_production_like_env():
raise RuntimeError(
"CLIENTFLOW_ADMIN_AUTH_MODE=local não é permitido em prod/production/staging."
)
if (
settings.clientflow_admin_auth_mode == "token"
and not str(settings.clientflow_admin_token or "").strip()
):
raise RuntimeError(
"CLIENTFLOW_ADMIN_TOKEN é obrigatório quando CLIENTFLOW_ADMIN_AUTH_MODE=token."
)
validate_admin_auth_settings()
if (
is_production_like_env()