fix(ui): le bouton de saisie du code reparaît à l'expiration de l'accès (v0.8.5)

L'autorisation obtenue auprès du serveur a un terme, mais le client n'en
tenait aucun compte : `access_until`, pourtant renvoyé à chaque validation,
ne servait qu'à composer une ligne de journal. L'accès était marqué ouvert
jusqu'à la chute du tunnel, si bien qu'une fois le délai écoulé l'interface
affichait « Ouvert ✓ » et masquait le bouton comme l'entrée de systray —
laissant l'utilisateur devant un réseau muet, sans issue visible, dans la
situation même que ce bouton existe pour couvrir depuis la v0.8.2.

L'échéance remonte désormais jusqu'à la fenêtre principale, qui la relit
sans jamais la calculer : sa durée est un réglage serveur, que le poste n'a
pas à deviner. Un serveur qui n'en annonce pas laisse l'accès ouvert jusqu'à
la déconnexion, plutôt que de se voir imposer un terme inventé ici.

Corrige au passage l'heure de fin, annoncée en UTC mais présentée comme
locale — deux heures d'avance en été.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:38:03 +02:00
co-authored by Claude Opus 5
parent 9083525634
commit 4cf3e26dff
8 changed files with 131 additions and 26 deletions
+52 -11
View File
@@ -24,12 +24,46 @@ import ipaddress
import json
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import NamedTuple
from app.core.config import Config
_TIMEOUT = 10
class AuthResult(NamedTuple):
"""Réponse du serveur à une demande d'ouverture d'accès.
`access_until` porte l'échéance telle que le serveur l'annonce — il en est
la seule autorité, le client la relit sans jamais la calculer. `None`
quand aucune échéance n'est annoncée : l'accès est alors ouvert sans
terme connu, et le client s'abstient d'en inventer un.
"""
ok: bool
message: str
access_until: datetime | None = None
def _parse_until(raw: object) -> datetime | None:
"""Lit l'échéance renvoyée par le serveur, en UTC.
Une valeur absente ou illisible ne vaut pas refus : l'accès vient d'être
ouvert. Elle rend seulement l'échéance inconnue, cas déjà prévu.
"""
if not isinstance(raw, str) or not raw:
return None
try:
moment = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
# Le serveur date en UTC ; sans fuseau explicite on l'y suppose, plutôt
# que de prendre l'horodatage pour une heure locale et de décaler
# l'échéance de l'offset du poste.
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def api_base_url(cfg: Config) -> str:
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
@@ -52,10 +86,10 @@ def api_base_url(cfg: Config) -> str:
return f"http://{gateway}:8000"
def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]:
def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
Retourne (succès, message). Un échec n'est jamais silencieux : sans
Retourne un `AuthResult`. Un échec n'est jamais silencieux : sans
cette validation le tunnel reste monté mais ne mène nulle part, et
l'utilisateur doit comprendre pourquoi plutôt que de constater un
réseau inexplicablement muet.
@@ -68,12 +102,13 @@ def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]
"""
base = api_base_url(cfg)
if not base:
return False, ("Adresse du serveur d'authentification inconnue. "
"Renseignez-la dans le panneau Administrateur.")
return AuthResult(False, "Adresse du serveur d'authentification "
"inconnue. Renseignez-la dans le panneau "
"Administrateur.")
username = (username or cfg.wg.get("vpn_username") or "").strip()
if not username:
return False, "Indiquez votre identifiant VPN."
return AuthResult(False, "Indiquez votre identifiant VPN.")
payload = json.dumps({"username": username, "code": code}).encode("utf-8")
request = urllib.request.Request(
@@ -83,8 +118,13 @@ def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8"))
until = (body.get("access_until") or "").replace("T", " ")[:16]
return True, f"Accès réseau ouvert jusqu'à {until}" if until else "Accès réseau ouvert"
# Affichée dans le fuseau du poste : l'échéance arrive en UTC, et la
# présenter telle quelle annonçait une heure fausse à l'utilisateur.
until = _parse_until(body.get("access_until"))
if until is None:
return AuthResult(True, "Accès réseau ouvert")
return AuthResult(True, "Accès réseau ouvert jusqu'à "
f"{until.astimezone():%d/%m/%Y %H:%M}", until)
except urllib.error.HTTPError as exc:
# Le serveur explique lui-même le refus (code invalide, compte
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
@@ -93,9 +133,10 @@ def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError):
detail = ""
return False, detail or f"Authentification refusée (code {exc.code})"
return AuthResult(False, detail
or f"Authentification refusée (code {exc.code})")
except urllib.error.URLError as exc:
return False, (f"Serveur d'authentification injoignable ({base}) : "
f"{exc.reason}")
return AuthResult(False, f"Serveur d'authentification injoignable "
f"({base}) : {exc.reason}")
except (OSError, ValueError) as exc:
return False, f"Échec de l'authentification : {exc}"
return AuthResult(False, f"Échec de l'authentification : {exc}")