feat(security): retire le MFA local, le code d'accès est celui du serveur (v0.8.1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 13:06:17 +02:00
co-authored by Claude Opus 5
parent dab6c1548b
commit cc9f521a95
15 changed files with 296 additions and 345 deletions
+11 -82
View File
@@ -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),