From cf81ce4c7b36ba5f042714d76d09f385803a2bbe Mon Sep 17 00:00:00 2001 From: Dom Date: Fri, 19 Jun 2026 16:27:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(vwb):=20Basic=20auth=20LAN=20sur=20backend?= =?UTF-8?q?=205002=20=E2=80=94=20creds=20dashboard,=20loopback=20exempt?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- visual_workflow_builder/backend/app.py | 103 +++++++++ .../backend/tests/test_vwb_basic_auth.py | 195 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 visual_workflow_builder/backend/tests/test_vwb_basic_auth.py diff --git a/visual_workflow_builder/backend/app.py b/visual_workflow_builder/backend/app.py index 7bdae57b0..c3a285cc0 100644 --- a/visual_workflow_builder/backend/app.py +++ b/visual_workflow_builder/backend/app.py @@ -28,6 +28,109 @@ load_dotenv() # fallback .env dans cwd (n'écrase pas les vars déjà définies # Initialize Flask app 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 +# 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 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) # ============================================================ diff --git a/visual_workflow_builder/backend/tests/test_vwb_basic_auth.py b/visual_workflow_builder/backend/tests/test_vwb_basic_auth.py new file mode 100644 index 000000000..f4bff4d9d --- /dev/null +++ b/visual_workflow_builder/backend/tests/test_vwb_basic_auth.py @@ -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"])