Audit du parcours complet — montage du tunnel, sondage de l'état, saisie du code, ouverture de l'accès. Neuf défauts, dont plusieurs laissaient le poste en quarantaine sans moyen visible d'en sortir : exactement ce que le bouton « Saisir le code d'accès » existe pour éviter depuis la v0.8.2. Adresse de l'API. api_base_url retranchait le dernier octet de l'adresse du client, ce qui suppose un /24 et ignore les IPs autorisées. Or une adresse client en /32 est la forme la plus courante et ne décrit aucun réseau : un client en 10.6.1.7/32 sur un 10.6.0.0/16 était expédié vers 10.6.1.1, qui n'existe pas. La déduction passe désormais par wireguard.tunnel_gateway, qui servait déjà au diagnostic et gère /32, IPv6 et le repli sur les IPs autorisées. Une adresse IPv6 est encadrée de crochets, sans quoi son « : » se confondait avec celui du port. Révocation invisible. Le client ne sondait qu'au retour du tunnel : une révocation avant terme — redémarrage du serveur, purge de sa table de sessions, décision d'un administrateur — n'était annoncée par rien et l'application affichait « Ouvert ✓ » devant un réseau muet, bouton masqué, jusqu'à la chute du tunnel. Sans échéance annoncée, indéfiniment. L'accès est reconfirmé chaque minute tant qu'il est cru ouvert. Authentification perdue. « Plus tard » restait actif pendant la vérification, Échap et la croix aussi. Fermer là rendait la main sur un refus alors que le serveur pouvait encore accepter : sa réponse arrivait après la sortie d'exec() et n'était lue par personne. L'accès était ouvert côté serveur, l'application affichait la quarantaine, et le code saisi était consommé pour rien. La fenêtre ne se ferme plus tant qu'une requête est en vol. Fenêtre survivante. Ni accept() ni reject() ne passent par closeEvent : le minuteur de 500 ms continuait à battre après la fermeture, et la fenêtre — parentée à la principale — survivait à sa disparition. dispose() l'arrête et programme la destruction ; _access_dialog est remis à None, son thread n'étant plus attendu à l'arrêt de l'application. Repli 404 trompeur. La sonde d'accessibilité tenait pour preuve toute réponse du serveur DNS du split-DNS, sans vérifier qu'il était joint par le tunnel. Une adresse RFC1918 routée par le réseau local du poste répondait donc hors tunnel, et le repli censé éviter le blocage le provoquait : accès déclaré ouvert sans échéance, bouton masqué. La route est vérifiée d'abord, sans émettre de paquet — connect() sur une socket UDP suffit à connaître l'adresse source retenue par le noyau. Proxy. urlopen emploie l'ouvreur par défaut, dont le ProxyHandler lit http_proxy dans l'environnement : sur un poste d'entreprise la requête vers l'adresse privée du tunnel partait au proxy, dont la réponse — 404 souvent — était prise pour celle du serveur, déclenchant le repli ci-dessus. Panne intermittente parfaite : selon l'environnement de lancement, ça passait ou non. Silence pris pour un refus. Un ok faux couvrait « le serveur refuse » et « le serveur n'a rien dit », traités pareil : un hoquet au montage du tunnel faisait surgir la fenêtre et brûler un code alors que l'accès était peut-être déjà ouvert. AuthResult porte désormais authoritative. Un refus du serveur referme du premier coup, il en est seul juge ; un silence déclenche un second sondage avant de déranger l'utilisateur, et n'entame l'accès qu'après trois minutes sans réponse. Annulation destructrice. _open_access_dialog écrivait l'état d'accès quoi qu'il arrive. Un sondage abouti pendant que la fenêtre était ouverte — les minuteries tournent dans la boucle imbriquée d'exec() — pouvait établir que l'accès l'était, que « Plus tard » remettait aussitôt à fermé. Renoncer à saisir un code ne referme rien côté serveur ; plus rien ici non plus. api_url. Le schéma est vérifié plutôt que repris tel quel : identifiant et code partent dans cette URL. Reste ouvert, et hors de ce dépôt : l'API par défaut est en clair et le serveur n'est pas authentifié. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
13 KiB
Python
304 lines
13 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)
|
|
if self._user_input is not None:
|
|
self._user_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)
|