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
+205
View File
@@ -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)
+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),
+33 -38
View File
@@ -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
-139
View File
@@ -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)
+1 -2
View File
@@ -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",