feat(windows): tunnel installé une fois, puis démarré — et code validé par le serveur
Deux changements liés, qui achèvent le déplacement de la sécurité vers le serveur. 1. Le tunnel n'est plus recréé à chaque connexion. Créer ou supprimer un service Windows n'est jamais délégable à un utilisateur standard, le démarrer peut l'être : c'est cette réinstallation systématique qui imposait une authentification administrateur à chaque connexion ET à chaque déconnexion. Désormais connect() démarre le service s'il existe déjà, disconnect() se contente de l'arrêter, et le tunnel reste installé entre les deux. Trois voies en cascade pour ce démarrage/arrêt : sc.exe sans élévation, puis le service wgsecure-helper, puis l'élévation en dernier recours. Le helper gagne donc deux commandes (start/stop_tunnel_service). Cette cascade tient quelle que soit la configuration du poste — le test en session standard a montré que l'ACL posée sur le service WireGuard ne donne aucun droit sur le service du tunnel, qui est un objet distinct. 2. Après le montage, l'application demande le code à 6 chiffres et le fait valider PAR LE SERVEUR (app/core/vpn_session.py). Jusqu'ici le code était vérifié localement, contre un secret que l'application détenait : elle validait donc un code qu'elle pouvait produire, et ne verrouillait que sa propre interface. L'ordre est imposé par le réseau — l'API n'est joignable que depuis le tunnel, qui sert de réseau de quarantaine tant que le code n'est pas passé. L'URL de l'API est déduite de l'adresse du client (10.6.0.6 → 10.6.0.1) faute de valeur explicite : c'est l'adresse du serveur lui-même, la seule jamais filtrée depuis la quarantaine. Sans identifiant VPN configuré, l'étape est ignorée : le serveur peut tourner en mode permissif, où un compte non enrôlé garde son accès, et imposer la saisie bloquerait des installations qui fonctionnent. Chemins Linux inchangés (branches is_windows()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""Authentification auprès du serveur, après montage du tunnel.
|
||||
|
||||
Le tunnel qui monte ne donne accès à rien : le serveur place le pair en
|
||||
quarantaine et ne laisse passer son trafic vers le réseau distant qu'une
|
||||
fois un code à 6 chiffres validé. C'est ce que fait ce module.
|
||||
|
||||
Le code est vérifié **par le serveur**, contre un secret que cette
|
||||
application ne détient pas — contrairement au MFA local d'origine, qui
|
||||
validait un code qu'elle pouvait elle-même produire, et ne verrouillait
|
||||
donc que sa propre interface.
|
||||
|
||||
L'ordre est imposé par le réseau : l'API du serveur n'est joignable que
|
||||
depuis l'intérieur du tunnel. On monte donc d'abord, on s'authentifie
|
||||
ensuite — le tunnel jouant le rôle d'un réseau de quarantaine tant que le
|
||||
code n'est pas passé.
|
||||
|
||||
`urllib` plutôt qu'une bibliothèque HTTP tierce : un seul appel POST, aucune
|
||||
raison d'alourdir les dépendances de l'application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from app.core.config import Config
|
||||
|
||||
_TIMEOUT = 10
|
||||
|
||||
|
||||
def api_base_url(cfg: Config) -> str:
|
||||
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
|
||||
|
||||
À défaut d'URL explicite, elle est déduite de l'adresse du client : le
|
||||
serveur occupe la première adresse de ce réseau (10.6.0.1 pour un client
|
||||
en 10.6.0.6). C'est l'adresse la plus fiable depuis la quarantaine — elle
|
||||
désigne le serveur lui-même, dont l'accès n'est jamais filtré, alors
|
||||
qu'une adresse du réseau distant le serait.
|
||||
"""
|
||||
explicit = (cfg.wg.get("api_url") or "").strip()
|
||||
if explicit:
|
||||
return explicit.rstrip("/")
|
||||
|
||||
client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip()
|
||||
try:
|
||||
host = ipaddress.ip_address(client_address)
|
||||
except ValueError:
|
||||
return ""
|
||||
gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1)
|
||||
return f"http://{gateway}:8000"
|
||||
|
||||
|
||||
def authenticate(cfg: Config, code: str) -> tuple[bool, str]:
|
||||
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
|
||||
|
||||
Retourne (succès, message). Un échec n'est jamais silencieux : sans
|
||||
cette validation le tunnel reste monté mais ne mène nulle part, et
|
||||
l'utilisateur doit comprendre pourquoi plutôt que de constater un
|
||||
réseau inexplicablement muet.
|
||||
"""
|
||||
base = api_base_url(cfg)
|
||||
if not base:
|
||||
return False, ("Adresse du serveur d'authentification inconnue. "
|
||||
"Renseignez-la dans le panneau Administrateur.")
|
||||
|
||||
username = (cfg.wg.get("vpn_username") or "").strip()
|
||||
if not username:
|
||||
return False, ("Aucun identifiant VPN configuré. Renseignez-le dans "
|
||||
"le panneau Administrateur.")
|
||||
|
||||
payload = json.dumps({"username": username, "code": code}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"{base}/api/session", data=payload,
|
||||
headers={"Content-Type": "application/json"}, method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
until = (body.get("access_until") or "").replace("T", " ")[:16]
|
||||
return True, f"Accès réseau ouvert jusqu'à {until}" if until else "Accès réseau ouvert"
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Le serveur explique lui-même le refus (code invalide, compte
|
||||
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
|
||||
# message générique qui obligerait à consulter les journaux.
|
||||
try:
|
||||
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
||||
except (ValueError, OSError):
|
||||
detail = ""
|
||||
return False, detail or f"Authentification refusée (code {exc.code})"
|
||||
except urllib.error.URLError as exc:
|
||||
return False, (f"Serveur d'authentification injoignable ({base}) : "
|
||||
f"{exc.reason}")
|
||||
except (OSError, ValueError) as exc:
|
||||
return False, f"Échec de l'authentification : {exc}"
|
||||
Reference in New Issue
Block a user