Files
WGSecure/app/core/vpn_session.py
T
tuxgyverandClaude Opus 5 306e512aab fix(ui): ne plus réclamer un code déjà validé après une reconnexion (v0.8.6)
Le client tenait pour acquis que l'autorisation ne survivait pas à la chute
du tunnel — un commentaire l'affirmait noir sur blanc. C'est l'inverse : le
serveur autorise l'adresse de tunnel du pair jusqu'à l'échéance enregistrée,
et cette adresse ne change pas d'une reconnexion à l'autre. Se reconnecter
ne révoque rien ; seul le client oubliait, et redemandait un code que le
serveur avait déjà accepté.

Il pose maintenant la question au lieu de la supposer, via GET /api/session
(serveur >= 0.3.0), dès que le tunnel monte : démarrage sur un tunnel déjà
actif, reconnexion automatique, ou avant toute invite de saisie. Le serveur
identifie l'appelant à l'IP source, aucun identifiant ne circule.

Serveur injoignable, réponse illisible ou serveur antérieur à la 0.3.0 ne
valent pas « accès ouvert » : sans information, le client ne conclut rien et
laisse le bouton disponible — le sens sûr, et le comportement de la 0.8.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:27:32 +02:00

185 lines
8.0 KiB
Python

"""Authentification auprès du serveur, après montage du tunnel.
Le tunnel qui monte ne donne accès à rien : le serveur place le pair en
quarantaine et ne laisse passer son trafic vers le réseau distant qu'une
fois un code à 6 chiffres validé. C'est ce que fait ce module.
Le code est vérifié **par le serveur**, contre un secret que cette
application ne détient pas — contrairement au MFA local d'origine, qui
validait un code qu'elle pouvait elle-même produire, et ne verrouillait
donc que sa propre interface.
L'ordre est imposé par le réseau : l'API du serveur n'est joignable que
depuis l'intérieur du tunnel. On monte donc d'abord, on s'authentifie
ensuite — le tunnel jouant le rôle d'un réseau de quarantaine tant que le
code n'est pas passé.
`urllib` plutôt qu'une bibliothèque HTTP tierce : un seul appel POST, aucune
raison d'alourdir les dépendances de l'application.
"""
from __future__ import annotations
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.
À défaut d'URL explicite, elle est déduite de l'adresse du client : le
serveur occupe la première adresse de ce réseau (10.6.0.1 pour un client
en 10.6.0.6). C'est l'adresse la plus fiable depuis la quarantaine — elle
désigne le serveur lui-même, dont l'accès n'est jamais filtré, alors
qu'une adresse du réseau distant le serait.
"""
explicit = (cfg.wg.get("api_url") or "").strip()
if explicit:
return explicit.rstrip("/")
client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip()
try:
host = ipaddress.ip_address(client_address)
except ValueError:
return ""
gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1)
return f"http://{gateway}:8000"
def session_status(cfg: Config) -> AuthResult:
"""Demande au serveur si l'accès de ce poste est déjà ouvert.
Un tunnel qui tombe et remonte ne révoque rien côté serveur :
l'autorisation porte sur l'adresse du pair, et cette adresse ne change
pas. Le client, lui, oubliait tout à chaque reconnexion et réclamait un
code déjà validé — c'est ce que cette question évite.
Aucun identifiant n'est envoyé : le serveur reconnaît l'appelant à l'IP
source du tunnel, et ne renseigne donc jamais sur un autre compte que
celui qui parle. Rien à demander avant que le tunnel ne soit monté,
l'API n'étant joignable que par l'intérieur.
Un `ok` faux ne distingue pas « accès fermé » de « serveur injoignable » :
l'appelant traite les deux pareil — il redemande un code, ce qui est le
comportement sûr dans le doute.
"""
base = api_base_url(cfg)
if not base:
return AuthResult(False, "Adresse du serveur d'authentification inconnue.")
request = urllib.request.Request(f"{base}/api/session", method="GET")
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8"))
until = _parse_until(body.get("access_until"))
if until is None:
return AuthResult(True, "Accès réseau déjà ouvert")
return AuthResult(True, "Accès réseau déjà ouvert jusqu'à "
f"{until.astimezone():%d/%m/%Y %H:%M}", until)
except urllib.error.HTTPError as exc:
try:
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError):
detail = ""
return AuthResult(False, detail or f"Accès fermé (code {exc.code})")
except (urllib.error.URLError, OSError, ValueError) as exc:
# Serveur ancien (route absente), injoignable, ou réponse illisible :
# aucune information, donc aucune conclusion — on ne prétend pas que
# l'accès est ouvert.
return AuthResult(False, f"État de l'accès inconnu : {exc}")
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 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.
`username` permet de fournir l'identifiant à la volée, quand il n'est pas
encore en configuration : la fenêtre de saisie le demande alors avec le
code, plutôt que de renvoyer l'utilisateur vers le panneau Administrateur
au moment précis où il cherche à ouvrir son accès. Il est enregistré
une fois le serveur l'ayant accepté.
"""
base = api_base_url(cfg)
if not base:
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 AuthResult(False, "Indiquez votre identifiant VPN.")
payload = json.dumps({"username": username, "code": code}).encode("utf-8")
request = urllib.request.Request(
f"{base}/api/session", data=payload,
headers={"Content-Type": "application/json"}, method="POST",
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8"))
# 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
# message générique qui obligerait à consulter les journaux.
try:
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError):
detail = ""
return AuthResult(False, detail
or f"Authentification refusée (code {exc.code})")
except urllib.error.URLError as exc:
return AuthResult(False, f"Serveur d'authentification injoignable "
f"({base}) : {exc.reason}")
except (OSError, ValueError) as exc:
return AuthResult(False, f"Échec de l'authentification : {exc}")