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"