La fenêtre de saisie réclamait l'Identifiant VPN à chaque ouverture sur un poste neuf, et l'authentification échouait. Les deux symptômes n'en faisaient qu'un : l'identifiant n'était enregistré qu'en cas d'authentification réussie, or aucune n'aboutissait, donc il était redemandé sans fin. Il n'est plus transmis du tout. `POST /api/session` désigne désormais le compte par l'adresse source de la requête, comme `GET /api/session` le fait depuis la v0.8.6. Le serveur l'a toujours su ; le poste, lui, ne faisait que répéter un renseignement qu'il pouvait taper de travers. C'est aussi plus sûr : sur WireGuard l'adresse source est garantie par le routage par clé, alors qu'un identifiant en clair permettait d'ouvrir l'accès d'un compte depuis n'importe quelle adresse. Exige la v0.3.2 du serveur, qui accepte les deux formes. Le champ disparaît de la fenêtre de saisie. Celui du panneau Administrateur reste, purement indicatif désormais, avec une infobulle qui le dit. Trois décisions d'interface se fondaient sur « un identifiant est-il configuré ? », une question que le poste ne pouvait pas trancher : proposer ou non la saisie automatique, le libellé de la ligne Accès distant, et l'aspect du bouton. Elles suivent maintenant la réponse du serveur, qui distingue « aucun pair à cette adresse » (401, nouveau) de « accès fermé, code attendu » (403). Un poste non enrôlé n'est donc plus harcelé pour un code qui n'ouvrirait rien, et la ligne affiche « Non géré par le serveur » au lieu de le supposer. Corrige aussi une régression de la v0.9.0 : `tunnel_gateway` retenait la route par défaut des IPs autorisées comme réseau de tunnel, et rendait donc 0.0.0.1 comme passerelle en tunnel intégral — sondage et validation partaient vers une adresse inexistante. Elle ne retient plus que les réseaux contenant l'adresse du client, préfère le plus spécifique, et se replie sur /24 (ou /64) faute de candidat. Le diagnostic « Pair joignable dans le tunnel » en bénéficie aussi. Enfin, `authenticate` distingue le silence du réseau d'un refus du serveur, comme `session_status` le faisait déjà. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
12 KiB
Python
280 lines
12 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.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)
|