fix(auth): le serveur reconnaît le poste à son adresse, plus à un identifiant

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>
This commit is contained in:
tuxgyver
2026-09-08 11:10:42 +02:00
co-authored by Claude Opus 5
parent b3c0515244
commit 023ec81e03
5 changed files with 120 additions and 66 deletions
+35 -14
View File
@@ -68,12 +68,19 @@ class AuthResult(NamedTuple):
code, alors que l'accès était peut-être déjà ouvert. Faux ici veut dire
« on ne sait pas » : serveur injoignable, réponse illisible, ou route
`/api/session` absente d'un serveur antérieur à la v0.3.0.
`enrolled` ne vaut faux que lorsque le serveur affirme ne connaître aucun
pair à cette adresse — le poste n'est pas enrôlé et aucun code ne
l'ouvrira. Renseigné par `session_status` seul : sur une demande
d'ouverture, le même code HTTP signifie « identifiant ou code invalide »,
ce qui est tout autre chose.
"""
ok: bool
message: str
access_until: datetime | None = None
authoritative: bool = True
enrolled: bool = True
def _parse_until(raw: object) -> datetime | None:
@@ -255,6 +262,14 @@ def session_status(cfg: Config) -> AuthResult:
return AuthResult(False, detail or "État de l'accès inconnu "
"(serveur sans /api/session)",
None, False)
if exc.code == 401:
# Le serveur ne connaît aucun pair à cette adresse : ce poste
# n'est pas enrôlé, et aucun code n'y changera rien. Distinct du
# 403 « accès fermé », où une saisie a tout son sens. Les deux
# rendaient 403 avant la v0.3.2 du serveur, et le client réclamait
# donc un code à des postes qui n'en avaient aucun à donner.
return AuthResult(False, detail or "Ce poste n'est pas enrôlé sur "
"le serveur.", None, True, False)
# Toute autre réponse HTTP vient du serveur lui-même : elle fait
# autorité, un 403 comme le reste.
return AuthResult(False, detail or f"Accès fermé (code {exc.code})")
@@ -264,7 +279,7 @@ def session_status(cfg: Config) -> AuthResult:
# on ne prétend pas non plus qu'il est fermé.
return AuthResult(False, f"État de l'accès inconnu : {exc}", None, False)
def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
def authenticate(cfg: Config, code: 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
@@ -272,23 +287,25 @@ def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
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é.
Aucun identifiant n'est transmis : le serveur reconnaît l'appelant à l'IP
source de son tunnel, comme il le fait déjà pour `session_status`. Il
l'exigeait, et le poste devait donc le connaître — la fenêtre de saisie
le réclamait à l'utilisateur, qui pouvait le taper de travers, et un poste
n'ayant jamais réussi à s'authentifier le redemandait indéfiniment. Le
serveur, lui, l'a toujours su.
Cette identification est plus sûre, pas seulement plus simple : 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.
"""
base = api_base_url(cfg)
if not base:
return AuthResult(False, "Adresse du serveur d'authentification "
"inconnue. Renseignez-la dans le panneau "
"Administrateur.")
"Administrateur.", None, False)
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")
payload = json.dumps({"code": code}).encode("utf-8")
request = urllib.request.Request(
f"{base}/api/session", data=payload,
headers={"Content-Type": "application/json"}, method="POST",
@@ -314,7 +331,11 @@ def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
return AuthResult(False, detail
or f"Authentification refusée (code {exc.code})")
except urllib.error.URLError as exc:
# `authoritative` faux : le serveur n'a rien dit. Il n'a donc contesté
# ni le code ni l'identifiant, et l'appelant peut retenir ce dernier
# plutôt que de le redemander à chaque essai (cf. AccessCodeDialog).
return AuthResult(False, f"Serveur d'authentification injoignable "
f"({base}) : {exc.reason}")
f"({base}) : {exc.reason}", None, False)
except (OSError, ValueError) as exc:
return AuthResult(False, f"Échec de l'authentification : {exc}")
return AuthResult(False, f"Échec de l'authentification : {exc}",
None, False)