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

View File

@@ -67,7 +67,11 @@ PACKLINK_SENDER_EMAIL=
PACKLINK_FALLBACK_PHONE= PACKLINK_FALLBACK_PHONE=
PACKLINK_FALLBACK_EMAIL= PACKLINK_FALLBACK_EMAIL=
# Admin interno: configure atrás de proxy/auth se exposto fora da rede local # Admin interno: proxy (header autenticado), token, ou local (apenas loopback)
CLIENTFLOW_ADMIN_AUTH_MODE=proxy
# Em proxy mode, o reverse proxy deve substituir (não apenas encaminhar)
# X-ClientFlow-Admin-User pelo utilizador autenticado.
# Obrigatório apenas quando CLIENTFLOW_ADMIN_AUTH_MODE=token.
CLIENTFLOW_ADMIN_TOKEN= CLIENTFLOW_ADMIN_TOKEN=
# Jasmin API validada em testes reais # Jasmin API validada em testes reais

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 html
import json import json
from uuid import UUID from uuid import UUID
from hmac import compare_digest
from typing import Optional from typing import Optional
from sqlalchemy import text from sqlalchemy import text
from fastapi import APIRouter, Request, Depends, HTTPException 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 starlette.concurrency import run_in_threadpool
from app.admin_queries import list_action_runs, list_business_events 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.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_service import prepare_task as run_task_preparation
from app.preparation_view_model import build_preparation_view_model from app.preparation_view_model import build_preparation_view_model
from app.workflow_guard import OperationActionBlocked, get_workflow_action_plan 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 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 # 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: def require_admin_access(request: Request) -> None:
"""Proteção opcional da UI admin. """Apply UI auth (including X-ClientFlow-Admin-Token in token mode)."""
Se CLIENTFLOW_ADMIN_TOKEN estiver vazio, mantém compatibilidade local. require_admin_auth(request, area="admin_ui")
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")
router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)]) router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)])
def esc(value) -> str: def esc(value) -> str:
return html.escape(str(value or "")) 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") @router.post("/fiscal-suggestions/{suggestion_id}/reject")
async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request): async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request):
from app.admin_auth import safe_local_redirect
try: try:
from app.fiscal_enrichment_service import reject_fiscal_suggestion from app.fiscal_enrichment_service import reject_fiscal_suggestion
reject_fiscal_suggestion(suggestion_id, actor="operator_ui") reject_fiscal_suggestion(suggestion_id, actor="operator_ui")
except Exception as exc: except Exception as exc:
return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500) return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500)
referer = request.headers.get("referer") or "/opportunities" redirect_target = safe_local_redirect(
return RedirectResponse(referer, status_code=303) 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 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) 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 __future__ import annotations
from hmac import compare_digest
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import text 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 from app.db import engine
def require_internal_access(request: Request) -> None: def require_internal_access(request: Request) -> None:
expected = (settings.clientflow_admin_token or "").strip() """Apply API auth (including X-ClientFlow-Admin-Token in token mode)."""
if not expected: require_admin_auth(request, area="internal_api")
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")
router = APIRouter(prefix="/api/internal", tags=["internal"], dependencies=[Depends(require_internal_access)]) 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 from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -21,6 +23,7 @@ class Settings(BaseSettings):
clientflow_persist: bool = True clientflow_persist: bool = True
clientflow_webhook_secret: str = "" clientflow_webhook_secret: str = ""
clientflow_admin_auth_mode: Literal["proxy", "token", "local"] = "proxy"
clientflow_admin_token: str = "" clientflow_admin_token: str = ""
# UI warning for cases where the original opportunity contact and the # 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"} 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(): def validate_admin_auth_settings() -> None:
raise RuntimeError( if settings.clientflow_admin_auth_mode == "local" and is_production_like_env():
"CLIENTFLOW_ADMIN_TOKEN é obrigatório em prod/production/staging." 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 ( if (
is_production_like_env() is_production_like_env()

View File

@@ -0,0 +1,164 @@
from pathlib import Path
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.admin_auth import require_admin_auth, safe_local_redirect
from app.config import settings, validate_admin_auth_settings
ROOT = Path(__file__).resolve().parents[1]
def request_from(
host: str = "127.0.0.1",
*,
headers: list[tuple[bytes, bytes]] | None = None,
query_string: bytes = b"",
) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"scheme": "http",
"path": "/",
"raw_path": b"/",
"query_string": query_string,
"headers": headers or [],
"client": (host, 12345),
"server": ("127.0.0.1", 8020),
}
)
def assert_denied(request: Request, *, area: str, status_code: int = 401) -> None:
with pytest.raises(HTTPException) as exc_info:
require_admin_auth(request, area=area)
assert exc_info.value.status_code == status_code
@pytest.mark.parametrize("area", ["admin_ui", "internal_api"])
def test_proxy_mode_accepts_authenticated_user_header(monkeypatch, area):
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "proxy")
request = request_from(
headers=[(b"x-clientflow-admin-user", b"alice")],
)
require_admin_auth(request, area=area)
assert request.state.clientflow_admin_user == "alice"
@pytest.mark.parametrize("area", ["admin_ui", "internal_api"])
def test_proxy_mode_rejects_missing_authenticated_user_header(monkeypatch, area):
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "proxy")
assert_denied(request_from(), area=area)
@pytest.mark.parametrize("area", ["admin_ui", "internal_api"])
def test_token_mode_accepts_valid_header_and_cookie_and_rejects_invalid_token(
monkeypatch,
area,
):
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "token")
monkeypatch.setattr(settings, "clientflow_admin_token", "correct-secret")
require_admin_auth(
request_from(headers=[(b"x-clientflow-admin-token", b"correct-secret")]),
area=area,
)
require_admin_auth(
request_from(headers=[(b"cookie", b"clientflow_admin_token=correct-secret")]),
area=area,
)
assert_denied(
request_from(headers=[(b"x-clientflow-admin-token", b"wrong-secret")]),
area=area,
)
def test_token_mode_requires_configured_token(monkeypatch):
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "token")
monkeypatch.setattr(settings, "clientflow_admin_token", "")
assert_denied(request_from(), area="internal_api", status_code=503)
@pytest.mark.parametrize("area", ["admin_ui", "internal_api"])
def test_local_mode_accepts_loopback_and_rejects_non_loopback(monkeypatch, area):
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "local")
require_admin_auth(request_from("127.0.0.1"), area=area)
require_admin_auth(request_from("::1"), area=area)
assert_denied(
request_from(
"192.0.2.10",
headers=[(b"x-forwarded-for", b"127.0.0.1")],
),
area=area,
)
@pytest.mark.parametrize("env", ["dev", "test"])
def test_environment_name_never_bypasses_explicit_auth_mode(monkeypatch, env):
monkeypatch.setattr(settings, "env", env)
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "proxy")
assert_denied(request_from(), area="admin_ui")
@pytest.mark.parametrize("env", ["production", "staging"])
def test_local_mode_is_rejected_at_startup_in_production_like_env(monkeypatch, env):
monkeypatch.setattr(settings, "env", env)
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "local")
with pytest.raises(RuntimeError, match="AUTH_MODE=local não é permitido"):
validate_admin_auth_settings()
def test_local_mode_is_allowed_at_startup_in_dev(monkeypatch):
monkeypatch.setattr(settings, "env", "dev")
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "local")
validate_admin_auth_settings()
@pytest.mark.parametrize("area", ["admin_ui", "internal_api"])
def test_query_string_admin_token_is_rejected(monkeypatch, area):
monkeypatch.setattr(settings, "clientflow_admin_auth_mode", "token")
monkeypatch.setattr(settings, "clientflow_admin_token", "correct-secret")
assert_denied(
request_from(query_string=b"admin_token=correct-secret"),
area=area,
)
def test_external_referer_is_rejected_as_redirect_target():
assert (
safe_local_redirect(
"https://attacker.example/steal?next=/admin",
fallback="/opportunities",
)
== "/opportunities"
)
assert (
safe_local_redirect(
"/opportunities?notice=done#ignored",
fallback="/opportunities",
)
== "/opportunities?notice=done"
)
def test_health_and_chatwoot_webhook_are_not_subject_to_admin_auth():
main_source = (ROOT / "app" / "main.py").read_text()
webhook_source = (ROOT / "app" / "webhooks_chatwoot.py").read_text()
assert '@app.get("/health")' in main_source
assert 'APIRouter(prefix="/webhooks"' in webhook_source
assert '@router.post("/chatwoot")' in webhook_source
assert "require_admin_access" not in webhook_source
assert "require_internal_access" not in webhook_source