prepare_quit() attend les threads en cours avant toute destruction, mais sa liste en oubliait deux : le sondage d'état ajouté en 0.8.6 — ma régression — et le thread de validation de la fenêtre de saisie, parenté à cette fenêtre depuis la v0.8.2 et jamais attendu. Fermer la fenêtre de code pendant qu'une requête est en vol suffisait à avorter le process, ce qui arrive dès que le serveur tarde. Reproduit avec un serveur qui accepte sans jamais répondre, puis vérifié corrigé sur les deux chemins. Le sondage est ramené à 4 s : l'arrêt l'attend désormais, et dix secondes y seraient une interface figée. Son échec ne coûte rien, contrairement à une validation ratée qui gâche un code déjà saisi. Ajoute le temps restant sur la ligne Accès distant, et un repli pour les serveurs sans GET /api/session : sur un 404, le client observe si le trafic atteint le réseau distant plutôt que de réclamer un code déjà validé. Un 403 reste une réponse qui fait autorité. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""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._username = ""
|
|
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)
|
|
|
|
# L'identifiant n'est demandé que s'il manque encore. Le renvoyer au
|
|
# panneau Administrateur à cet instant précis — tunnel monté, accès
|
|
# fermé, code en main — était un cul-de-sac : il est saisi ici, puis
|
|
# enregistré dès que le serveur l'a reconnu.
|
|
self._user_input: QLineEdit | None = None
|
|
if not (self._cfg.wg.get("vpn_username") or "").strip():
|
|
self._user_input = QLineEdit()
|
|
self._user_input.setPlaceholderText("Identifiant VPN (ex. TestB)")
|
|
self._user_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self._user_input.setStyleSheet(self._input_css(theme.BORDER_SOFT))
|
|
self._user_input.returnPressed.connect(self._submit)
|
|
layout.addWidget(self._user_input)
|
|
|
|
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._username = ""
|
|
if self._user_input is not None:
|
|
self._username = self._user_input.text().strip()
|
|
if not self._username:
|
|
self._error_label.setText("Indiquez votre identifiant VPN.")
|
|
self._user_input.setStyleSheet(self._input_css(theme.FAIL_TEXT))
|
|
return
|
|
|
|
self._error_label.setText("")
|
|
self._set_busy(True)
|
|
self._worker = ValueWorker(vpn_session.authenticate, self._cfg, raw,
|
|
self._username, 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.")
|
|
self._message = result.message
|
|
self._access_until = result.access_until
|
|
if result.ok:
|
|
# Enregistré seulement maintenant : un identifiant que le serveur
|
|
# a refusé n'a rien à faire dans la configuration.
|
|
if self._username:
|
|
self._cfg.set("wg", "vpn_username", self._username)
|
|
self._cfg.save()
|
|
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)
|
|
|
|
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):
|
|
self._timer.stop()
|
|
super().closeEvent(event)
|