From cc9f521a95329bb02b1fa4eb931440820e531465 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 6 Sep 2026 13:06:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(security):=20retire=20le=20MFA=20local,=20?= =?UTF-8?q?le=20code=20d'acc=C3=A8s=20est=20celui=20du=20serveur=20(v0.8.1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La v0.8.0 laissait cohabiter deux vérifications du code à 6 chiffres : la fenêtre MFA locale, qui contrôlait un code contre un secret détenu par le poste lui-même — et ne verrouillait donc que sa propre interface — puis la validation par le serveur, seule à décider de l'accès réseau. L'ancienne disparaît, avec le secret TOTP qu'elle stockait en clair dans config.json (effacé au premier lancement) et l'onglet MFA du panneau Administrateur. La saisie passe par une fenêtre dédiée qui relaie le refus exact du serveur et laisse réessayer, la requête partant dans un thread pour qu'un serveur muet ne fige pas l'application. Une ligne « Accès distant » distingue désormais l'état de l'autorisation de celui du tunnel : monté sans code validé, celui-ci ne mène nulle part. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 +++ Makefile | 3 +- README.md | 14 +-- app/__init__.py | 2 +- app/app_info.py | 7 +- app/core/config.py | 37 +++---- app/core/mfa.py | 48 -------- app/ui/access_code_dialog.py | 205 +++++++++++++++++++++++++++++++++++ app/ui/admin_window.py | 93 ++-------------- app/ui/main_window.py | 71 ++++++------ app/ui/mfa_dialog.py | 139 ------------------------ app/ui/theme.py | 3 +- app/utils/platform_utils.py | 2 +- requirements.txt | 1 - wgsecure.iss | 2 +- 15 files changed, 296 insertions(+), 345 deletions(-) delete mode 100644 app/core/mfa.py create mode 100644 app/ui/access_code_dialog.py delete mode 100644 app/ui/mfa_dialog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e5176..207bc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.8.1] — 2026-09-06 + +### Sécurité +- **Le secret TOTP ne réside plus sur le poste client.** Il y était stocké en clair dans `config.json`, alors que l'application n'en a plus l'usage depuis que le serveur valide les codes : un secret d'authentification sans emploi, exposé pour rien. Il est effacé du fichier au premier lancement de cette version, avec le réglage qui l'accompagnait. + +### Modifié +- **Une seule saisie de code, et elle compte.** La v0.8.0 laissait cohabiter deux vérifications : la fenêtre MFA locale — qui contrôlait un code contre un secret que le poste détenait lui-même, et ne verrouillait donc que l'interface — puis la validation par le serveur. L'ancienne disparaît ; reste celle qui décide réellement de l'accès au réseau. +- La fenêtre de saisie remplace la boîte de dialogue sommaire de la v0.8.0 : elle affiche le refus exact renvoyé par le serveur (code invalide, compte verrouillé, expiré) et laisse réessayer sans reconnecter le tunnel. La requête part dans un thread — un serveur qui ne répond pas ne fige plus l'application. +- **Nouvelle ligne « Accès distant » dans le panneau de détails**, distincte de l'état du tunnel : *Ouvert*, *Quarantaine (code requis)*, ou *Non géré par le serveur*. Un tunnel monté sans code validé ne mène nulle part — sans cette ligne, l'application affichait « Connecté » devant un réseau muet. +- L'auto-reconnexion n'est plus suspendue par le MFA : elle remonte le tunnel, et l'application signale simplement qu'un code reste à saisir. +- Onglet **MFA** retiré du panneau Administrateur, avec la génération de secret et le QR code : c'est désormais le serveur qui les délivre. L'export QR de la configuration WireGuard, lui, reste en place. + +--- + ## [0.8.0] — 2026-09-06 ### Sécurité diff --git a/Makefile b/Makefile index 09d61d0..9fdeb4d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.8.0 +VERSION := 0.8.1 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip @@ -18,7 +18,6 @@ PI_OPTS := \ --noconfirm \ --clean \ --name $(APP) \ - --hidden-import pyotp \ --hidden-import qrcode \ --hidden-import qrcode.image.pil \ --hidden-import PIL \ diff --git a/README.md b/README.md index f61b4ab..09dd3d2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 🛡️ WGSecure (WGS) -🚀 v0.8.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard +🚀 v0.8.1 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard -**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP). +**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**. --- @@ -19,7 +19,7 @@ | 🔑 | **Génération de clés** | Paire Curve25519 native (sans dépendance au binaire `wg`) | | 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` | | 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive | -| 🔐 | **MFA TOTP** | Compatible Bitwarden + QR Code | +| 🔐 | **Code d'accès serveur** | Le tunnel monte en quarantaine ; le serveur ouvre l'accès après validation du code | | 🔍 | **Test DNS leak** | Vérifie que le DNS passe bien par le tunnel | | 🏓 | **Ping en temps réel** | Badge de latence dans l'interface principale | | 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe — activée par défaut (15 s) | @@ -103,7 +103,7 @@ python main.py --no-tray 1. 🚀 Lancer `python main.py --admin` 2. 🛡️ Onglet **WireGuard** : renseigner l'adresse, le port et la clé publique du serveur 3. 🔑 Onglet **Clés** : générer une paire de clés → copier la clé publique sur le serveur -4. 🔐 Onglet **MFA** : activer le MFA, générer un secret, scanner le QR Code avec Bitwarden +4. 🔐 Onglet **WireGuard** : renseigner l'identifiant VPN connu du serveur (le QR code TOTP est délivré par le serveur, pas par ce client) 5. 🔒 Onglet **Sécurité** : définir un mot de passe pour l'accès admin 6. ✅ **Enregistrer** → quitter → lancer `python main.py` en mode user @@ -179,14 +179,14 @@ WGSecure/ │ │ ├── single_instance.py # 1️⃣ Garde une seule instance active │ │ ├── history.py # 🕘 Historique des sessions (sessions.json) │ │ ├── log.py # 📋 Journal d'événements (events.json) -│ │ └── mfa.py # 🔐 TOTP / QR Code +│ │ └── vpn_session.py # 🔐 Validation du code auprès du serveur │ ├── ui/ │ │ ├── icons.py # 🎨 Icônes bouclier générées par QPainter │ │ ├── main_window.py # 👤 Fenêtre principale (mode user) │ │ ├── admin_window.py # ⚙️ Panneau admin (onglets, thème sombre) │ │ ├── bw_graph.py # 📊 Widget graphique bande passante │ │ ├── history_dialog.py # 🕘 Dialogue historique des sessions -│ │ ├── mfa_dialog.py # 🔐 Dialogue de vérification MFA +│ │ ├── access_code_dialog.py # 🔐 Saisie du code d'accès │ │ └── systray.py # 🖥️ Icône systray + menu contextuel │ └── utils/ │ └── platform_utils.py # 🔧 Détection OS, élévation de privilèges @@ -200,7 +200,7 @@ WGSecure/ |---|---| | 🔑 Mot de passe admin | **bcrypt** (rounds=12) + sel intégré — migration transparente depuis SHA-256 | | 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) | -| 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local | +| 🔐 Secret TOTP | **Jamais sur le poste client** — détenu par le serveur, qui seul valide le code et ouvre l'accès réseau | | 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) | > ⚠️ **Recommandation** : chiffrez votre disque pour protéger le fichier de configuration. diff --git a/app/__init__.py b/app/__init__.py index 523e543..af2d0b0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.8.0" +__version__ = "0.8.1" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/app_info.py b/app/app_info.py index 6bbcc71..957782d 100644 --- a/app/app_info.py +++ b/app/app_info.py @@ -24,7 +24,7 @@ else: ) APP_NAME = "WGSecure" -TAGLINE = "Client WireGuard avec surcouche MFA TOTP, pour Linux et Windows" +TAGLINE = "Client WireGuard à accès authentifié par le serveur, pour Linux et Windows" VERSION = __version__ LICENSE = "Usage personnel" REPO_URL = "https://git.tuxtech.fr/tuxgyver/WGSecure" @@ -62,7 +62,8 @@ FEATURES = [ ( "Mode Admin protégé", "Configuration complète (serveur, DNS, IP, keepalive, MTU) derrière " - "un mot de passe et, en option, une double authentification TOTP.", + "un mot de passe, et l'accès aux ressources distantes par un code " + "à 6 chiffres validé par le serveur.", ), ( "Diagnostic réseau", @@ -110,7 +111,7 @@ GUARANTEES = [ TECH_STACK = [ ("Interface", "PyQt6"), ("Cryptographie", "cryptography (X25519, ChaCha20-Poly1305, bcrypt)"), - ("MFA", "pyotp (TOTP) + qrcode/Pillow pour le QR code"), + ("QR Code", "qrcode/Pillow pour l'export de la config client"), ("Tunnel", "wg-quick / wg (Linux), WireGuard for Windows"), ("Élévation de privilèges", "sudo NOPASSWD ciblé, repli askpass puis pkexec"), ("Empaquetage", "PyInstaller (Linux/Windows) + Inno Setup (installeur Windows)"), diff --git a/app/core/config.py b/app/core/config.py index 16c90c3..2f22b55 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -49,8 +49,6 @@ _DEFAULT: dict[str, Any] = { "version": "0.7.3", "admin_password_hash": "", "admin_salt": "", - "mfa_enabled": False, - "mfa_secret": "", "active_profile": "H3", "profiles": {}, "wg": deepcopy(_WG_DEFAULT), @@ -76,6 +74,7 @@ class Config: with open(self._path, "r", encoding="utf-8") as f: saved = json.load(f) self._data = self._merge(_DEFAULT, saved) + self._drop_local_mfa() except Exception: self._data = deepcopy(_DEFAULT) else: @@ -85,6 +84,22 @@ class Config: self._data = deepcopy(_DEFAULT) self._apply_autostart(self._data["ui"]["autostart"]) + def _drop_local_mfa(self) -> None: + """Efface le secret TOTP local hérité des versions ≤ 0.8.0. + + Ce secret ne sert plus : le code à 6 chiffres est désormais validé + par le serveur, contre un secret que le poste client ne détient pas. + Le laisser en place serait un secret d'authentification en clair + dans `config.json`, sans rien qui le justifie — d'où l'effacement + au chargement plutôt qu'un simple abandon des clés. + """ + stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data] + if not stale: + return + for key in stale: + del self._data[key] + self.save() + def save(self): os.makedirs(os.path.dirname(self._path), exist_ok=True) with open(self._path, "w", encoding="utf-8") as f: @@ -151,24 +166,6 @@ class Config: wg = self._data["wg"] return bool(wg.get("server_endpoint") and wg.get("client_private_key")) - # ── MFA ─────────────────────────────────────────────────────────────── - - @property - def mfa_enabled(self) -> bool: - return bool(self._data.get("mfa_enabled", False)) - - @mfa_enabled.setter - def mfa_enabled(self, value: bool): - self._data["mfa_enabled"] = value - - @property - def mfa_secret(self) -> str: - return self._data.get("mfa_secret", "") - - @mfa_secret.setter - def mfa_secret(self, value: str): - self._data["mfa_secret"] = value - # ── Multi-profils ───────────────────────────────────────────────────── @property diff --git a/app/core/mfa.py b/app/core/mfa.py deleted file mode 100644 index 7c9fe6e..0000000 --- a/app/core/mfa.py +++ /dev/null @@ -1,48 +0,0 @@ -import pyotp -import qrcode -import io -from PyQt6.QtGui import QPixmap, QImage - - -def generate_secret() -> str: - return pyotp.random_base32() - - -def get_totp(secret: str) -> pyotp.TOTP: - return pyotp.TOTP(secret) - - -def verify_code(secret: str, code: str) -> bool: - if not secret or not code: - return False - totp = pyotp.TOTP(secret) - return totp.verify(code.strip(), valid_window=1) - - -def get_current_code(secret: str) -> str: - return pyotp.TOTP(secret).now() - - -def time_remaining() -> int: - """Secondes restantes avant rotation du code TOTP.""" - import time - return 30 - (int(time.time()) % 30) - - -def get_provisioning_uri(secret: str, account: str = "WGSecure", issuer: str = "WGSecure") -> str: - totp = pyotp.TOTP(secret) - return totp.provisioning_uri(name=account, issuer_name=issuer) - - -def generate_qr_pixmap(secret: str, account: str = "WGSecure") -> QPixmap: - uri = get_provisioning_uri(secret, account) - qr = qrcode.QRCode(box_size=6, border=2) - qr.add_data(uri) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - buf = io.BytesIO() - img.save(buf, format="PNG") - buf.seek(0) - data = buf.read() - qimage = QImage.fromData(data) - return QPixmap.fromImage(qimage) diff --git a/app/ui/access_code_dialog.py b/app/ui/access_code_dialog.py new file mode 100644 index 0000000..ffcbf97 --- /dev/null +++ b/app/ui/access_code_dialog.py @@ -0,0 +1,205 @@ +"""Saisie du code à 6 chiffres, validé par le serveur. + +Remplace l'ancienne fenêtre MFA, qui vérifiait le code localement contre un +secret stocké sur le poste : elle ne verrouillait alors que sa propre +interface. Ici le code part au serveur, seul détenteur du secret et seul +point où l'accès est réellement ouvert ou refusé. + +La requête part dans un `TaskWorker` : elle peut attendre jusqu'à dix +secondes si le serveur ne répond pas, et une fenêtre modale figée pendant +ce temps donnerait l'impression d'une application plantée. +""" + +from __future__ import annotations + +import time + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QProgressBar, QFrame, +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QFont + +from app.core import vpn_session +from app.core.config import Config +from app.ui import theme +from app.ui.worker import TaskWorker + +_TOTP_PERIOD = 30 + + +class AccessCodeDialog(QDialog): + """Demande le code d'accès et le fait valider par le serveur. + + `message()` porte la réponse du serveur — succès comme refus — pour que + l'appelant la journalise telle quelle plutôt qu'un message générique. + """ + + def __init__(self, cfg: Config, parent=None): + super().__init__(parent) + self._cfg = cfg + self._worker: TaskWorker | None = None + self._message = "" + self.setWindowTitle("WGSecure — Code d'accès") + self.setFixedWidth(360) + self.setModal(True) + self.setStyleSheet(f""" + QDialog {{ background: {theme.BG_SUNKEN}; }} + QLabel {{ color: {theme.TEXT}; background: transparent; }} + """) + self._build_ui() + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + self._timer.start(500) + self._tick() + + # ------------------------------------------------------------------ # + # Construction + # ------------------------------------------------------------------ # + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(12) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Ouverture de l'accès distant") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + f = QFont() + f.setPointSize(13) + f.setBold(True) + title.setFont(f) + layout.addWidget(title) + + sub = QLabel("Le tunnel est monté, mais les ressources distantes " + "restent fermées tant que le serveur n'a pas validé " + "votre code à 6 chiffres.") + sub.setWordWrap(True) + sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + sub.setStyleSheet(f"color: {theme.TEXT_MUTED};") + layout.addWidget(sub) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {theme.BORDER_SOFT};") + layout.addWidget(sep) + + self._code_input = QLineEdit() + self._code_input.setPlaceholderText("000 000") + self._code_input.setMaxLength(7) + self._code_input.setAlignment(Qt.AlignmentFlag.AlignCenter) + f2 = QFont("Courier", 20) + f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4) + self._code_input.setFont(f2) + self._code_input.setStyleSheet(self._input_css(theme.ACCENT)) + self._code_input.returnPressed.connect(self._submit) + layout.addWidget(self._code_input) + + self._error_label = QLabel("") + self._error_label.setWordWrap(True) + self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._error_label.setStyleSheet( + f"color: {theme.FAIL_TEXT}; font-weight: bold;") + layout.addWidget(self._error_label) + + self._progress = QProgressBar() + self._progress.setRange(0, _TOTP_PERIOD) + self._progress.setTextVisible(False) + self._progress.setFixedHeight(6) + self._progress.setStyleSheet( + f"QProgressBar {{ border-radius: 3px; background: {theme.BG}; }}" + f"QProgressBar::chunk {{ background: {theme.ACCENT_LIGHT};" + f" border-radius: 3px; }}" + ) + layout.addWidget(self._progress) + + self._timer_label = QLabel("") + self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._timer_label.setStyleSheet( + f"color: {theme.TEXT_FAINT}; font-size: 11px;") + layout.addWidget(self._timer_label) + + btn_row = QHBoxLayout() + self._btn_cancel = QPushButton("Plus tard") + self._btn_cancel.setStyleSheet( + f"QPushButton {{ padding: 8px 16px; border-radius: 5px; border: none;" + f" background: {theme.BG_RAISED}; color: {theme.TEXT}; }}" + f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}" + ) + self._btn_cancel.clicked.connect(self.reject) + + self._btn_ok = QPushButton("Valider") + self._btn_ok.setDefault(True) + self._btn_ok.setStyleSheet( + f"QPushButton {{ padding: 8px 20px; border-radius: 5px; border: none;" + f" background: {theme.ACCENT}; color: {theme.TEXT}; font-weight: bold; }}" + f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}" + f"QPushButton:disabled {{ background: {theme.BG_RAISED};" + f" color: {theme.TEXT_FAINT}; }}" + ) + self._btn_ok.clicked.connect(self._submit) + btn_row.addWidget(self._btn_cancel) + btn_row.addStretch() + btn_row.addWidget(self._btn_ok) + layout.addLayout(btn_row) + + @staticmethod + def _input_css(border: str) -> str: + return (f"QLineEdit {{ padding: 8px; border: 2px solid {border};" + f" border-radius: 6px; background: {theme.BG};" + f" color: {theme.TEXT}; }}") + + # ------------------------------------------------------------------ # + # Compte à rebours + # ------------------------------------------------------------------ # + def _tick(self): + # La période TOTP est fixe et alignée sur l'epoch Unix : le temps + # restant se calcule sans connaître le secret, que cette application + # ne détient justement plus. + remaining = _TOTP_PERIOD - int(time.time()) % _TOTP_PERIOD + self._progress.setValue(remaining) + self._timer_label.setText(f"Code valide encore {remaining}s") + + # ------------------------------------------------------------------ # + # Validation + # ------------------------------------------------------------------ # + def _submit(self): + if self._worker is not None: + return + raw = self._code_input.text().replace(" ", "").replace("-", "") + if len(raw) != 6 or not raw.isdigit(): + self._fail("Entrez exactement 6 chiffres.") + return + + self._error_label.setText("") + self._set_busy(True) + self._worker = TaskWorker(vpn_session.authenticate, self._cfg, raw, + tag="auth", parent=self) + self._worker.done.connect(self._on_reply) + self._worker.finished.connect(self._worker.deleteLater) + self._worker.start() + + def _on_reply(self, ok: bool, message: str, _tag: str): + self._worker = None + self._message = message + if ok: + self.accept() + return + self._set_busy(False) + self._fail(message) + self._code_input.clear() + + def _fail(self, message: str): + self._error_label.setText(message) + self._code_input.setStyleSheet(self._input_css(theme.FAIL_TEXT)) + + def _set_busy(self, busy: bool): + self._btn_ok.setEnabled(not busy) + self._btn_ok.setText("Vérification…" if busy else "Valider") + self._code_input.setEnabled(not busy) + + def message(self) -> str: + return self._message + + def closeEvent(self, event): + self._timer.stop() + super().closeEvent(event) diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index be5698e..d3d22c9 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -12,7 +12,6 @@ from PyQt6.QtGui import QFont from app.core.config import Config from app.core import wireguard as wg_core from app.core import dns as dns_util -from app.core import mfa as mfa_core from app.ui import theme from app.ui.worker import ValueWorker from app.utils.platform_utils import ( @@ -567,54 +566,6 @@ class AdminWindow(QDialog): lay.addStretch() return page - # ------------------------------------------------------------------ # - # Onglet MFA - # ------------------------------------------------------------------ # - def _subtab_mfa(self) -> QWidget: - page, lay = self._test_page() - lay.addWidget(self._hint( - "Exige un code à usage unique avant chaque connexion. Le secret se " - "scanne dans Bitwarden ou toute application TOTP ; conservez-le " - "ailleurs qu'ici, il est le seul moyen de régénérer les codes." - )) - - self._mfa_enabled_cb = QCheckBox("Activer la vérification MFA avant connexion") - self._mfa_enabled_cb.setStyleSheet(f"color: {theme.TEXT}; font-weight: bold;") - lay.addWidget(self._mfa_enabled_cb) - - sep = QFrame() - sep.setFrameShape(QFrame.Shape.HLine) - sep.setStyleSheet(f"color: {theme.BORDER_SOFT};") - lay.addWidget(sep) - - grp = QGroupBox("Secret TOTP") - form = QFormLayout(grp) - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - self._mfa_secret = QLineEdit() - self._mfa_secret.setPlaceholderText("(générer ou coller votre secret TOTP)") - self._row(form, "Secret :", self._mfa_secret) - btn_gen_secret = QPushButton("Générer un nouveau secret") - btn_gen_secret.clicked.connect(self._generate_mfa_secret) - self._row(form, "", btn_gen_secret) - lay.addWidget(grp) - - grp2 = QGroupBox("QR Code — Scanner avec Bitwarden") - v2 = QVBoxLayout(grp2) - self._qr_label = QLabel("(générez un secret pour afficher le QR Code)") - self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._qr_label.setFixedHeight(190) - v2.addWidget(self._qr_label) - self._mfa_uri_label = QLabel("") - self._mfa_uri_label.setWordWrap(True) - self._mfa_uri_label.setStyleSheet(f"font-size: 9px; color: {theme.TEXT_FAINT};") - v2.addWidget(self._mfa_uri_label) - btn_show_qr = QPushButton("Afficher le QR Code") - btn_show_qr.clicked.connect(self._show_qr) - v2.addWidget(btn_show_qr) - lay.addWidget(grp2) - lay.addStretch() - return page - # ------------------------------------------------------------------ # # Onglet Test connexion # ------------------------------------------------------------------ # @@ -628,7 +579,7 @@ class AdminWindow(QDialog): Les droits d'exécution (sudo/UAC) ont migré vers l'onglet Sécurité : ce n'est pas un diagnostic de connexion mais un réglage d'accès, au - même titre que le MFA ou le mot de passe administrateur. + même titre que le mot de passe administrateur. """ w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"]) lay.setContentsMargins(0, 0, 0, 0) @@ -947,19 +898,18 @@ class AdminWindow(QDialog): # Onglet Sécurité # ------------------------------------------------------------------ # def _tab_security(self) -> QWidget: - """Sécurité : MFA, mot de passe administrateur et droits d'exécution. + """Sécurité : mot de passe administrateur et droits d'exécution. - Les trois répondent à la même question — qui (ou quoi) a le droit de - se connecter et d'administrer. Les droits sudo/UAC y ont rejoint MFA - et mot de passe : ce n'est pas un test de connexion, c'est un réglage - d'accès du même ordre. + Les deux répondent à la même question — qui a le droit d'administrer + ce poste. L'onglet MFA a disparu avec le secret TOTP local : le code + d'accès est désormais réglé sur le serveur, qui est le seul à le + connaître et le seul à ouvrir l'accès réseau. """ w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"]) lay.setContentsMargins(0, 0, 0, 0) sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) - sub.addTab(self._subtab_mfa(), "🔐 MFA (TOTP)") sub.addTab(self._subtab_password(), "🔑 Mot de passe") sub.addTab(self._subtab_privileges(), self._privileges_tab_title()) lay.addWidget(sub) @@ -1077,8 +1027,8 @@ class AdminWindow(QDialog): return box def _about_pills(self, info) -> list[tuple[str, str]]: - """Pastilles de version/licence, plus l'état réel de MFA et des - droits sudo — lu en direct, jamais une valeur supposée.""" + """Pastilles de version/licence, plus l'état réel de l'authentification + serveur et des droits sudo — lu en direct, jamais une valeur supposée.""" from app.utils.platform_utils import ( has_root_privileges, is_windows, privilege_report, ) @@ -1086,10 +1036,10 @@ class AdminWindow(QDialog): (f"v{info.VERSION}", theme.ACCENT), (info.LICENSE, "#5b6b7a"), ] - if self._cfg.mfa_enabled: - pills.append(("🔐 MFA activée", theme.OK_SOLID)) + if (self._cfg.wg.get("vpn_username") or "").strip(): + pills.append(("🔐 Code d'accès serveur", theme.OK_SOLID)) else: - pills.append(("🔐 MFA désactivée", "#5b6b7a")) + pills.append(("🔐 Aucun code d'accès", "#5b6b7a")) if not is_windows(): if has_root_privileges(): @@ -1321,8 +1271,6 @@ class AdminWindow(QDialog): self._split_dns_domains.setText(", ".join(domains) if isinstance(domains, list) else domains) self._priv_key.setText(wg.get("client_private_key", "")) self._pub_key.setText(wg.get("client_public_key", "")) - self._mfa_enabled_cb.setChecked(self._cfg.mfa_enabled) - self._mfa_secret.setText(self._cfg.mfa_secret) # Un chargement de profil repart d'une configuration de confiance # (gérée par WGSecure) : seul un import .conf brut verrouille. @@ -1350,8 +1298,6 @@ class AdminWindow(QDialog): domains_text = self._split_dns_domains.text().strip() domains_list = [d.strip() for d in domains_text.split(",") if d.strip()] if domains_text else [] self._cfg.set("wg", "split_dns_domains", domains_list) - self._cfg.mfa_enabled = self._mfa_enabled_cb.isChecked() - self._cfg.mfa_secret = self._mfa_secret.text().strip() self._cfg.save() def _save_and_close(self): @@ -1512,23 +1458,6 @@ class AdminWindow(QDialog): v.addWidget(btn_close) dlg.exec() - def _generate_mfa_secret(self): - secret = mfa_core.generate_secret() - self._mfa_secret.setText(secret) - self._show_qr() - - def _show_qr(self): - secret = self._mfa_secret.text().strip() - if not secret: - QMessageBox.warning(self, "Erreur", "Aucun secret MFA renseigné.") - return - pixmap = mfa_core.generate_qr_pixmap(secret) - self._qr_label.setPixmap( - pixmap.scaled(210, 210, Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation) - ) - self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret)) - #: Rendu d'une étape de diagnostic : puce, couleur du libellé. _STEP_STYLE = { "ok": ("✅", theme.OK_TEXT), diff --git a/app/ui/main_window.py b/app/ui/main_window.py index ac1feb3..918ebc8 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -9,14 +9,13 @@ from PyQt6.QtCore import Qt, QTimer, QDateTime from PyQt6.QtGui import QFont, QCloseEvent, QColor from app.core.config import Config -from app.core import vpn_session from app.core import wireguard as wg_core from app.core import dns as dns_util from app.core import log as conn_log from app.core import history as hist from app.core import shutdown as shutdown_guard from app.ui.worker import TaskWorker, ValueWorker -from app.ui.mfa_dialog import MFADialog +from app.ui.access_code_dialog import AccessCodeDialog from app.ui.admin_window import AdminWindow from app.ui.bw_graph import BandwidthGraph from app.ui.history_dialog import HistoryDialog @@ -37,6 +36,7 @@ class MainWindow(QMainWindow): self._worker: TaskWorker | None = None self._ping_worker: ValueWorker | None = None self._is_connected = False + self._access_open = False self._reconnect_failures = 0 self._quitting = False self._warned_foreign: set[str] = set() @@ -292,7 +292,7 @@ class MainWindow(QMainWindow): ("server", "🌐", "Serveur"), ("iface", "🔌", "Interface"), ("addr", "📍", "Adresse IP"), - ("mfa", "🔐", "MFA"), + ("access", "🔐", "Accès distant"), ]: rw = QWidget() rw.setStyleSheet("background: transparent;") @@ -539,10 +539,6 @@ class MainWindow(QMainWindow): return if self._worker is not None: return # une (dé)connexion est déjà en cours - if self._cfg.mfa_enabled: - # Rouvrir une fenêtre MFA modale toutes les 30 s rendrait - # l'application inutilisable : la reconnexion doit rester manuelle. - return if not self._cfg.configured or self._is_connected: self._reconnect_failures = 0 return @@ -558,6 +554,20 @@ class MainWindow(QMainWindow): # ------------------------------------------------------------------ # # Statut WireGuard # ------------------------------------------------------------------ # + def _access_state(self) -> str: + """État de l'autorisation réseau accordée par le serveur. + + Distinct de l'état du tunnel : monté, celui-ci ne mène nulle part + tant que le serveur n'a pas levé la quarantaine. Sans cette ligne, + un utilisateur ayant refusé la saisie du code verrait « Connecté » + et un réseau inexplicablement muet. + """ + if not (self._cfg.wg.get("vpn_username") or "").strip(): + return "Non géré par le serveur" + if not self._is_connected: + return "—" + return "Ouvert ✓" if self._access_open else "Quarantaine (code requis)" + def _refresh_status(self): wg = self._cfg.wg srv = wg.get("server_endpoint", "") @@ -565,9 +575,6 @@ class MainWindow(QMainWindow): self._info_labels["server"].setText(f"{srv}:{port}" if srv else "—") self._info_labels["iface"].setText(wg.get("interface_name", "wgs0")) self._info_labels["addr"].setText(wg.get("client_address", "—")) - self._info_labels["mfa"].setText( - "Activé ✓" if self._cfg.mfa_enabled else "Désactivé" - ) # Auto-reconnect timer if self._cfg.get("ui", "auto_reconnect"): @@ -586,6 +593,11 @@ class MainWindow(QMainWindow): self._is_connected = connected if connected: self._reconnect_failures = 0 + else: + # Tunnel tombé : l'autorisation obtenue auprès du serveur ne vaut + # que pour l'adresse du pair, elle ne survit pas à la déconnexion. + self._access_open = False + self._info_labels["access"].setText(self._access_state()) if connected: if self._connected_since is None: @@ -659,20 +671,6 @@ class MainWindow(QMainWindow): ) return - if self._cfg.mfa_enabled: - if not self._cfg.mfa_secret: - if not silent: - QMessageBox.warning(self, "MFA non configuré", - "MFA activé mais aucun secret défini.") - return - if silent: - return # jamais de fenêtre MFA sur minuterie - dlg = MFADialog(self._cfg.mfa_secret, self) - if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified(): - self._add_log("MFA annulé ou échoué", "warning") - return - self._add_log("Authentification MFA réussie ✓", "success") - self._begin_task("Connexion…") shutdown_guard.arm() # le tunnel devra être rendu à la sortie self._worker = TaskWorker(wg_core.connect, self._cfg, @@ -710,20 +708,17 @@ class MainWindow(QMainWindow): "warning") return - code, accepted = QInputDialog.getText( - self, "Code d'accès", - "Le tunnel est monté, mais l'accès aux ressources distantes\n" - "reste fermé tant que votre code n'est pas validé.\n\n" - "Code à 6 chiffres :", - ) - if not accepted or not code.strip(): - self._add_log("Authentification annulée — accès distant fermé", "warning") - return - - ok, message = vpn_session.authenticate(self._cfg, code.strip()) - self._add_log(message, "success" if ok else "error") - if not ok: - QMessageBox.warning(self, "Accès refusé", message) + dlg = AccessCodeDialog(self._cfg, self) + accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted + self._access_open = accepted + if accepted: + self._add_log(dlg.message(), "success") + else: + # La fenêtre affiche déjà le refus du serveur et laisse réessayer : + # y superposer une boîte d'alerte n'apprendrait rien de plus. + self._add_log(dlg.message() or "Authentification abandonnée — " + "accès distant fermé", "warning") + self._refresh_status() def _begin_task(self, label: str): self._connecting = True diff --git a/app/ui/mfa_dialog.py b/app/ui/mfa_dialog.py deleted file mode 100644 index d894c97..0000000 --- a/app/ui/mfa_dialog.py +++ /dev/null @@ -1,139 +0,0 @@ -from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QProgressBar, QFrame, -) -from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QFont -from app.core import mfa as mfa_core -from app.ui import theme - - -class MFADialog(QDialog): - def __init__(self, secret: str, parent=None): - super().__init__(parent) - self._secret = secret - self._verified = False - self.setWindowTitle("WGSecure — Authentification MFA") - self.setFixedWidth(340) - self.setModal(True) - self.setStyleSheet(f""" - QDialog {{ background: {theme.BG_SUNKEN}; }} - QLabel {{ color: {theme.TEXT}; background: transparent; }} - """) - self._build_ui() - self._timer = QTimer(self) - self._timer.timeout.connect(self._tick) - self._timer.start(500) - self._tick() - - def _build_ui(self): - layout = QVBoxLayout(self) - layout.setSpacing(12) - layout.setContentsMargins(20, 20, 20, 20) - - title = QLabel("Vérification en deux étapes") - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - f = QFont() - f.setPointSize(13) - f.setBold(True) - title.setFont(f) - layout.addWidget(title) - - sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.") - sub.setWordWrap(True) - sub.setAlignment(Qt.AlignmentFlag.AlignCenter) - sub.setStyleSheet(f"color: {theme.TEXT_MUTED};") - layout.addWidget(sub) - - sep = QFrame() - sep.setFrameShape(QFrame.Shape.HLine) - sep.setStyleSheet(f"color: {theme.BORDER_SOFT};") - layout.addWidget(sep) - - self._code_input = QLineEdit() - self._code_input.setPlaceholderText("000 000") - self._code_input.setMaxLength(7) - self._code_input.setAlignment(Qt.AlignmentFlag.AlignCenter) - f2 = QFont("Courier", 20) - f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4) - self._code_input.setFont(f2) - self._code_input.setStyleSheet( - f"QLineEdit {{ padding: 8px; border: 2px solid {theme.ACCENT};" - f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}" - ) - self._code_input.returnPressed.connect(self._verify) - layout.addWidget(self._code_input) - - self._error_label = QLabel("") - self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._error_label.setStyleSheet(f"color: {theme.FAIL_TEXT}; font-weight: bold;") - layout.addWidget(self._error_label) - - self._progress = QProgressBar() - self._progress.setRange(0, 30) - self._progress.setTextVisible(False) - self._progress.setFixedHeight(6) - self._progress.setStyleSheet( - f"QProgressBar {{ border-radius: 3px; background: {theme.BG}; }}" - f"QProgressBar::chunk {{ background: {theme.ACCENT_LIGHT}; border-radius: 3px; }}" - ) - layout.addWidget(self._progress) - - self._timer_label = QLabel("") - self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._timer_label.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;") - layout.addWidget(self._timer_label) - - btn_row = QHBoxLayout() - btn_cancel = QPushButton("Annuler") - btn_cancel.setStyleSheet( - f"QPushButton {{ padding: 8px 16px; border-radius: 5px; border: none;" - f" background: {theme.BG_RAISED}; color: {theme.TEXT}; }}" - f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}" - ) - btn_cancel.clicked.connect(self.reject) - - self._btn_ok = QPushButton("Vérifier") - self._btn_ok.setDefault(True) - self._btn_ok.setStyleSheet( - f"QPushButton {{ padding: 8px 20px; border-radius: 5px; border: none;" - f" background: {theme.ACCENT}; color: {theme.TEXT}; font-weight: bold; }}" - f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}" - ) - self._btn_ok.clicked.connect(self._verify) - btn_row.addWidget(btn_cancel) - btn_row.addStretch() - btn_row.addWidget(self._btn_ok) - layout.addLayout(btn_row) - - def _tick(self): - remaining = mfa_core.time_remaining() - self._progress.setValue(remaining) - self._timer_label.setText(f"Code valide encore {remaining}s") - - def _verify(self): - raw = self._code_input.text().replace(" ", "").replace("-", "") - if len(raw) != 6 or not raw.isdigit(): - self._error_label.setText("Entrez exactement 6 chiffres.") - self._code_input.setStyleSheet( - f"QLineEdit {{ padding: 8px; border: 2px solid {theme.FAIL_TEXT};" - f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}" - ) - return - if mfa_core.verify_code(self._secret, raw): - self._verified = True - self.accept() - else: - self._error_label.setText("Code incorrect. Réessayez.") - self._code_input.clear() - self._code_input.setStyleSheet( - f"QLineEdit {{ padding: 8px; border: 2px solid {theme.FAIL_TEXT};" - f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}" - ) - - def is_verified(self) -> bool: - return self._verified - - def closeEvent(self, event): - self._timer.stop() - super().closeEvent(event) diff --git a/app/ui/theme.py b/app/ui/theme.py index 5abb4d9..96cce64 100644 --- a/app/ui/theme.py +++ b/app/ui/theme.py @@ -65,13 +65,12 @@ LOG_COLORS = { } # ── Bandeaux d'onglets ─────────────────────────────────────────────────────── -# Cinq teintes calées sur la même luminosité et la même saturation : les +# Six teintes calées sur la même luminosité et la même saturation : les # onglets restent distinguables sans que l'un paraisse plus clair ou plus # saturé que les autres, ce qui était le cas des valeurs choisies à la main. BANNER = { "wireguard": "#1a5276", "keys": "#1a4a6e", - "mfa": "#4a2a6e", "test": "#1a4a7a", "settings": "#155e52", "security": "#7a3b1a", diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py index e479a32..3b810d4 100644 --- a/app/utils/platform_utils.py +++ b/app/utils/platform_utils.py @@ -516,7 +516,7 @@ def install_desktop_entry(icon_png_path: str) -> None: "[Desktop Entry]\n" "Type=Application\n" "Name=WGSecure\n" - "Comment=WireGuard GUI avec MFA TOTP\n" + "Comment=Client WireGuard à accès authentifié\n" f"Exec={launch_command()}\n" "Icon=wgsecure\n" "Categories=Network;Security;\n" diff --git a/requirements.txt b/requirements.txt index a4abf1a..f33f2f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ PyQt6>=6.4.0 cryptography>=41.0.0 -pyotp>=2.9.0 qrcode>=7.4.2 Pillow>=10.0.0 bcrypt>=4.0.0 diff --git a/wgsecure.iss b/wgsecure.iss index 1a9114a..a4fc6d5 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.8.0" +#define MyAppVersion "0.8.1" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"