feat(vwb): Basic auth LAN sur backend 5002 — creds dashboard, loopback exempté
VWB backend exposé au LAN sans auth (point pré-clinique). Ajoute HTTP Basic auth (mêmes identifiants que le dashboard: DASHBOARD_USER/DASHBOARD_PASSWORD) via @app.before_request ; exempte loopback (intégration dashboard/agent_chat intacte), /health et OPTIONS. Frontend = Create React App (pas Vite) → auth backend suffit (navigateur LAN challengé au 1er XHR vers 5002) ; build statique = cible clinique. Déployé + vérifié DGX: loopback 200, LAN no-creds 401, LAN+creds 200. 10 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,109 @@ load_dotenv() # fallback .env dans cwd (n'écrase pas les vars déjà définies
|
|||||||
# Initialize Flask app
|
# Initialize Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTTP Basic Auth LAN (cohérent avec le dashboard 5001)
|
||||||
|
# ============================================================
|
||||||
|
# Le VWB (backend 5002) était exposé au LAN SANS authentification. On ajoute
|
||||||
|
# un middleware before_request qui exige un header Authorization: Basic <b64>
|
||||||
|
# pour toute requête NON-loopback (LAN), avec les MÊMES credentials que le
|
||||||
|
# dashboard : DASHBOARD_USER / DASHBOARD_PASSWORD (dans .env.local).
|
||||||
|
#
|
||||||
|
# GARDE-FOU CRITIQUE — exemption loopback :
|
||||||
|
# Le dashboard (agent_chat/app.py `_fetch_vwb_workflows`) et les healthchecks
|
||||||
|
# appellent ce backend en boucle locale (http://localhost:5002 → 127.0.0.1).
|
||||||
|
# Exiger l'auth en loopback CASSERAIT l'intégration dashboard↔VWB. On exempte
|
||||||
|
# donc 127.0.0.1 / ::1 (et ::ffff:127.0.0.1) de toute auth.
|
||||||
|
#
|
||||||
|
# Différence assumée avec le dashboard (fail-closed) : ici on NE crashe PAS si
|
||||||
|
# DASHBOARD_PASSWORD est absent. On log un warning et on laisse passer le LAN
|
||||||
|
# (mode POC dev/dégradé). En clinique, DASHBOARD_PASSWORD est défini dans
|
||||||
|
# .env.local (chargé ci-dessus, lignes 24-26) → l'auth LAN est effective.
|
||||||
|
import base64 as _base64
|
||||||
|
import hmac as _hmac
|
||||||
|
|
||||||
|
_VWB_AUTH_USER = os.getenv("DASHBOARD_USER", "lea").strip()
|
||||||
|
_VWB_AUTH_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "").strip()
|
||||||
|
# Désactivation explicite (dev/tests, parité avec le dashboard).
|
||||||
|
_VWB_AUTH_DISABLED = os.getenv("DASHBOARD_AUTH_DISABLED", "").lower() in (
|
||||||
|
"1", "true", "yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Adresses considérées comme loopback (server-to-server, jamais challengées).
|
||||||
|
_VWB_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "::ffff:127.0.0.1"}
|
||||||
|
|
||||||
|
# Paths publics (pas d'auth) — healthchecks systemd / NPM / smokes.
|
||||||
|
_VWB_PUBLIC_PATHS = {"/health", "/api/health"}
|
||||||
|
|
||||||
|
if not _VWB_AUTH_PASSWORD and not _VWB_AUTH_DISABLED:
|
||||||
|
logging.getLogger("vwb.auth").warning(
|
||||||
|
"[SECURITE] DASHBOARD_PASSWORD non defini : l'auth Basic LAN du VWB "
|
||||||
|
"(5002) est INACTIVE (le LAN passe sans credentials). Definir "
|
||||||
|
"DASHBOARD_PASSWORD dans .env.local pour l'activer (cible clinique)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vwb_auth_ok(header_value: str) -> bool:
|
||||||
|
"""Valide le header Authorization Basic. Comparaison constant-time.
|
||||||
|
|
||||||
|
Logique identique au dashboard (`web_dashboard/app.py::_dashboard_auth_ok`).
|
||||||
|
"""
|
||||||
|
if not header_value or not header_value.lower().startswith("basic "):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = _base64.b64decode(header_value[6:].strip()).decode("utf-8")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
if ":" not in decoded:
|
||||||
|
return False
|
||||||
|
user, _, password = decoded.partition(":")
|
||||||
|
user_ok = _hmac.compare_digest(user, _VWB_AUTH_USER)
|
||||||
|
pwd_ok = _hmac.compare_digest(password, _VWB_AUTH_PASSWORD)
|
||||||
|
return user_ok and pwd_ok
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _vwb_basic_auth_middleware():
|
||||||
|
"""Middleware d'auth HTTP Basic LAN sur le backend VWB (port 5002).
|
||||||
|
|
||||||
|
- Bypass total si DASHBOARD_AUTH_DISABLED=true (dev/tests).
|
||||||
|
- Bypass total si DASHBOARD_PASSWORD absent (mode POC degrade, warning emis
|
||||||
|
au demarrage) — on ne casse pas le service faute de secret.
|
||||||
|
- Loopback (127.0.0.1 / ::1) : JAMAIS challenge (proxy dashboard, healthcheck).
|
||||||
|
- Preflight CORS (OPTIONS) : laisse passer (le navigateur n'envoie pas
|
||||||
|
l'en-tete Authorization au preflight).
|
||||||
|
- Paths publics (_VWB_PUBLIC_PATHS) : healthchecks externes.
|
||||||
|
- Sinon (requete LAN) : header Authorization: Basic <b64> obligatoire, sinon 401.
|
||||||
|
"""
|
||||||
|
from flask import request, Response
|
||||||
|
|
||||||
|
# Dev / tests / mode degrade sans secret : bypass total
|
||||||
|
if _VWB_AUTH_DISABLED or not _VWB_AUTH_PASSWORD:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Preflight CORS : pas d'auth (le navigateur n'envoie pas les credentials)
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Exemption loopback (server-to-server : dashboard, healthcheck)
|
||||||
|
if (request.remote_addr or "") in _VWB_LOOPBACK_ADDRS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Paths publics (healthchecks externes)
|
||||||
|
if (request.path or "/") in _VWB_PUBLIC_PATHS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _vwb_auth_ok(request.headers.get("Authorization", "")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pas authentifie — challenge 401 avec WWW-Authenticate
|
||||||
|
return Response(
|
||||||
|
'{"error": "authentication required"}',
|
||||||
|
status=401,
|
||||||
|
mimetype="application/json",
|
||||||
|
headers={"WWW-Authenticate": 'Basic realm="RPA Vision V3 VWB"'},
|
||||||
|
)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Logging — fichier rotatif + console (idempotent)
|
# Logging — fichier rotatif + console (idempotent)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
195
visual_workflow_builder/backend/tests/test_vwb_basic_auth.py
Normal file
195
visual_workflow_builder/backend/tests/test_vwb_basic_auth.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Tests de l'auth HTTP Basic LAN du backend VWB (port 5002).
|
||||||
|
|
||||||
|
Le VWB etait expose au LAN SANS authentification. Le middleware
|
||||||
|
`_vwb_basic_auth_middleware` ajoute un challenge 401 sur toute requete
|
||||||
|
NON-loopback, avec les MEMES credentials que le dashboard
|
||||||
|
(DASHBOARD_USER / DASHBOARD_PASSWORD).
|
||||||
|
|
||||||
|
Controles cles :
|
||||||
|
- Loopback (127.0.0.1) sans credentials -> 200 (proxy dashboard / healthcheck).
|
||||||
|
- LAN (REMOTE_ADDR non loopback) sans credentials -> 401 + WWW-Authenticate.
|
||||||
|
- LAN avec mauvais mot de passe -> 401.
|
||||||
|
- LAN avec bons credentials -> passage (pas de 401).
|
||||||
|
- /health public meme en LAN.
|
||||||
|
- DASHBOARD_AUTH_DISABLED=true -> bypass total.
|
||||||
|
- DASHBOARD_PASSWORD absent -> auth inactive (mode POC degrade, pas de crash).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Le backend VWB s'importe en tant que module top-level `app`
|
||||||
|
# (cf. tests/conftest.py : `from app import app, db`). On ajoute le repertoire
|
||||||
|
# backend au path pour pouvoir le recharger avec les variables d'env voulues.
|
||||||
|
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND_DIR))
|
||||||
|
|
||||||
|
# Adresse LAN simulee (non loopback)
|
||||||
|
_LAN_ADDR = "192.168.1.50"
|
||||||
|
_LAN_ENV = {"REMOTE_ADDR": _LAN_ADDR}
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(user: str, password: str) -> str:
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
return f"Basic {token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_app():
|
||||||
|
"""Recharge le module `app` pour relire les constantes d'auth depuis l'env."""
|
||||||
|
if "app" in sys.modules:
|
||||||
|
return importlib.reload(sys.modules["app"])
|
||||||
|
return importlib.import_module("app")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_enabled_client(monkeypatch):
|
||||||
|
"""Client VWB avec auth LAN active (DASHBOARD_USER/PASSWORD definis)."""
|
||||||
|
monkeypatch.setenv("DASHBOARD_USER", "lea")
|
||||||
|
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
|
||||||
|
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
|
||||||
|
mod = _reload_app()
|
||||||
|
mod.app.config["TESTING"] = True
|
||||||
|
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||||
|
with mod.app.test_client() as c:
|
||||||
|
with mod.app.app_context():
|
||||||
|
mod.db.create_all()
|
||||||
|
yield c
|
||||||
|
mod.db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_disabled_client(monkeypatch):
|
||||||
|
"""Client VWB avec auth desactivee (DASHBOARD_AUTH_DISABLED=true)."""
|
||||||
|
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
|
||||||
|
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
|
||||||
|
mod = _reload_app()
|
||||||
|
mod.app.config["TESTING"] = True
|
||||||
|
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||||
|
with mod.app.test_client() as c:
|
||||||
|
with mod.app.app_context():
|
||||||
|
mod.db.create_all()
|
||||||
|
yield c
|
||||||
|
mod.db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_password_client(monkeypatch):
|
||||||
|
"""Client VWB sans DASHBOARD_PASSWORD (mode POC degrade : auth inactive)."""
|
||||||
|
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
|
||||||
|
mod = _reload_app()
|
||||||
|
mod.app.config["TESTING"] = True
|
||||||
|
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||||
|
with mod.app.test_client() as c:
|
||||||
|
with mod.app.app_context():
|
||||||
|
mod.db.create_all()
|
||||||
|
yield c
|
||||||
|
mod.db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVwbBasicAuth:
|
||||||
|
"""Auth HTTP Basic LAN sur le backend VWB (5002)."""
|
||||||
|
|
||||||
|
def test_loopback_no_creds_passes(self, auth_enabled_client):
|
||||||
|
"""Requete loopback (127.0.0.1) sans creds -> PAS de 401.
|
||||||
|
|
||||||
|
Garde-fou critique : le dashboard proxifie en loopback. La requete
|
||||||
|
ne doit jamais etre challengee (200, ou autre code applicatif != 401).
|
||||||
|
"""
|
||||||
|
resp = auth_enabled_client.get("/api/v3/session/state")
|
||||||
|
assert resp.status_code != 401, (
|
||||||
|
f"Loopback ne doit jamais etre challenge (got {resp.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lan_no_creds_returns_401(self, auth_enabled_client):
|
||||||
|
"""Requete LAN (non loopback) sans creds -> 401 + WWW-Authenticate."""
|
||||||
|
resp = auth_enabled_client.get(
|
||||||
|
"/api/v3/session/state", environ_base=_LAN_ENV
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "WWW-Authenticate" in resp.headers
|
||||||
|
assert "Basic" in resp.headers["WWW-Authenticate"]
|
||||||
|
|
||||||
|
def test_lan_wrong_password_returns_401(self, auth_enabled_client):
|
||||||
|
"""Requete LAN avec mauvais mot de passe -> 401."""
|
||||||
|
resp = auth_enabled_client.get(
|
||||||
|
"/api/v3/session/state",
|
||||||
|
environ_base=_LAN_ENV,
|
||||||
|
headers={"Authorization": _basic_auth_header("lea", "wrong")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_lan_wrong_user_returns_401(self, auth_enabled_client):
|
||||||
|
"""Requete LAN avec mauvais utilisateur -> 401."""
|
||||||
|
resp = auth_enabled_client.get(
|
||||||
|
"/api/v3/session/state",
|
||||||
|
environ_base=_LAN_ENV,
|
||||||
|
headers={"Authorization": _basic_auth_header("intruder", "secret-test-pwd")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_lan_valid_credentials_pass(self, auth_enabled_client):
|
||||||
|
"""Requete LAN avec bons creds -> PAS de 401 (auth franchie)."""
|
||||||
|
resp = auth_enabled_client.get(
|
||||||
|
"/api/v3/session/state",
|
||||||
|
environ_base=_LAN_ENV,
|
||||||
|
headers={"Authorization": _basic_auth_header("lea", "secret-test-pwd")},
|
||||||
|
)
|
||||||
|
assert resp.status_code != 401, (
|
||||||
|
f"Bons creds doivent franchir l'auth (got {resp.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lan_malformed_header_returns_401(self, auth_enabled_client):
|
||||||
|
"""Requete LAN avec header mal forme (Bearer) -> 401."""
|
||||||
|
resp = auth_enabled_client.get(
|
||||||
|
"/api/v3/session/state",
|
||||||
|
environ_base=_LAN_ENV,
|
||||||
|
headers={"Authorization": "Bearer tototoken"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_lan_health_is_public(self, auth_enabled_client):
|
||||||
|
"""/health reste public meme en LAN (healthcheck externe)."""
|
||||||
|
resp = auth_enabled_client.get("/health", environ_base=_LAN_ENV)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_lan_options_preflight_not_blocked(self, auth_enabled_client):
|
||||||
|
"""Preflight CORS (OPTIONS) en LAN -> pas de 401 (CORS preserve)."""
|
||||||
|
resp = auth_enabled_client.open(
|
||||||
|
"/api/v3/session/state", method="OPTIONS", environ_base=_LAN_ENV
|
||||||
|
)
|
||||||
|
assert resp.status_code != 401
|
||||||
|
|
||||||
|
def test_auth_disabled_bypass_lan(self, auth_disabled_client):
|
||||||
|
"""DASHBOARD_AUTH_DISABLED=true -> LAN passe sans creds."""
|
||||||
|
resp = auth_disabled_client.get(
|
||||||
|
"/api/v3/session/state", environ_base=_LAN_ENV
|
||||||
|
)
|
||||||
|
assert resp.status_code != 401
|
||||||
|
|
||||||
|
def test_no_password_degraded_lan_passes(self, no_password_client):
|
||||||
|
"""DASHBOARD_PASSWORD absent -> mode POC degrade : LAN passe (pas de crash)."""
|
||||||
|
resp = no_password_client.get(
|
||||||
|
"/api/v3/session/state", environ_base=_LAN_ENV
|
||||||
|
)
|
||||||
|
assert resp.status_code != 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_module(monkeypatch):
|
||||||
|
"""Restaure le module `app` en mode auth desactivee apres chaque test,
|
||||||
|
pour ne pas contaminer les autres tests VWB (qui importent `app`)."""
|
||||||
|
yield
|
||||||
|
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
|
||||||
|
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("DASHBOARD_USER", raising=False)
|
||||||
|
if "app" in sys.modules:
|
||||||
|
importlib.reload(sys.modules["app"])
|
||||||
Reference in New Issue
Block a user