"""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 `ValueWorker` : 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 ValueWorker _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. `access_until()` porte l'échéance de l'autorisation obtenue : sans elle, l'appelant croirait l'accès ouvert indéfiniment et masquerait le moyen d'en redemander un une fois le délai écoulé. """ def __init__(self, cfg: Config, parent=None): super().__init__(parent) self._cfg = cfg self._worker: ValueWorker | None = None self._message = "" self._access_until = None 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) # Aucun identifiant n'est demandé : le serveur reconnaît le poste à # l'IP source de son tunnel. Ce champ existait parce que le serveur # exigeait l'identifiant, et il n'apportait que des ennuis — une # faute de frappe faisait refuser un code valide, et un poste n'ayant # jamais réussi à s'authentifier se le voyait redemander sans fin. 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 = ValueWorker(vpn_session.authenticate, self._cfg, raw, parent=self) self._worker.done.connect(self._on_reply) self._worker.finished.connect(self._worker.deleteLater) self._worker.start() def _on_reply(self, result): self._worker = None if result is None: # `ValueWorker` absorbe une exception imprévue en rendant None : # dire alors que l'échec vient d'ici, et non d'un refus serveur. result = vpn_session.AuthResult( False, "Échec inattendu de l'authentification.", None, False) self._message = result.message self._access_until = result.access_until if result.ok: self.accept() return self._set_busy(False) self._fail(result.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) # « Plus tard » est neutralisé le temps de la requête : la fermeture # rendait la main à l'appelant sur un refus, alors que le serveur # pouvait encore répondre par une acceptation. Celle-ci arrivait après # la sortie de `exec()` et n'était donc lue par personne — l'accès # était ouvert côté serveur, l'application affichait la quarantaine, et # le code saisi était consommé pour rien. self._btn_cancel.setEnabled(not busy) def reject(self): """Refuse d'abandonner tant qu'une validation est en vol. Couvre « Plus tard », la touche Échap et la croix de la fenêtre — les trois passent par ici. L'attente est bornée par le délai de la requête (10 s), et le bouton affiche « Vérification… » pendant ce temps. """ if self._worker is not None: return super().reject() def dispose(self) -> None: """Arrête le minuteur et programme la destruction de la fenêtre. Ni `accept()` ni `reject()` ne passent par `closeEvent` — vérifié sur Qt 6 : le minuteur de 500 ms continuait donc à battre après la fermeture, et la fenêtre, parentée à la fenêtre principale, survivait à sa propre disparition. Une de plus à chaque passage en quarantaine, son minuteur avec. Appelé par l'appelant une fois `exec()` sorti et les réponses lues : `message()` et `access_until()` ne survivent pas à la destruction. """ self._timer.stop() self.deleteLater() def wait_for_worker(self, msecs: int) -> None: """Attend la fin de la requête en cours, s'il y en a une. Le worker est parenté à cette fenêtre : la détruire pendant qu'il tourne encore avorte le process (« QThread: Destroyed while thread is still running »). La fenêtre survit à sa propre fermeture — elle est parentée à la fenêtre principale — donc le moment critique est l'arrêt de l'application, qui appelle ceci avant que quoi que ce soit ne soit détruit. """ try: if self._worker is not None and self._worker.isRunning(): self._worker.wait(msecs) except RuntimeError: pass # objet Qt déjà détruit def message(self) -> str: return self._message def access_until(self): """Échéance annoncée par le serveur, ou None s'il n'en annonce pas.""" return self._access_until def closeEvent(self, event): if self._worker is not None: # `QDialog.closeEvent` délègue à `reject()`, qui refuse pendant la # requête — mais l'événement resterait accepté et la fenêtre # disparaîtrait quand même, réponse du serveur perdue. event.ignore() return self._timer.stop() super().closeEvent(event)