Le bouton et l'entrée de systray étaient conditionnés à un champ vide par défaut depuis son introduction en v0.8.0 : un poste tenu en quarantaine par le serveur n'affichait donc aucun moyen d'en sortir. La saisie ne dépend plus que de l'état du tunnel ; la fenêtre demande l'identifiant quand il manque et ne l'enregistre qu'une fois le serveur l'ayant accepté. L'état « Non géré par le serveur » disparaît au passage : le client l'affirmait à partir de sa seule configuration locale, sans rien vérifier. « Non vérifié » est la vérité tant qu'aucun code n'a été validé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.2 KiB
Python
233 lines
9.2 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 `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._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 = TaskWorker(vpn_session.authenticate, self._cfg, raw,
|
|
self._username, 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:
|
|
# 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(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)
|