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:
@@ -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)
|
||||
Reference in New Issue
Block a user