fix(auth): fiabilise la sortie de quarantaine
Audit du parcours complet — montage du tunnel, sondage de l'état, saisie du code, ouverture de l'accès. Neuf défauts, dont plusieurs laissaient le poste en quarantaine sans moyen visible d'en sortir : exactement ce que le bouton « Saisir le code d'accès » existe pour éviter depuis la v0.8.2. Adresse de l'API. api_base_url retranchait le dernier octet de l'adresse du client, ce qui suppose un /24 et ignore les IPs autorisées. Or une adresse client en /32 est la forme la plus courante et ne décrit aucun réseau : un client en 10.6.1.7/32 sur un 10.6.0.0/16 était expédié vers 10.6.1.1, qui n'existe pas. La déduction passe désormais par wireguard.tunnel_gateway, qui servait déjà au diagnostic et gère /32, IPv6 et le repli sur les IPs autorisées. Une adresse IPv6 est encadrée de crochets, sans quoi son « : » se confondait avec celui du port. Révocation invisible. Le client ne sondait qu'au retour du tunnel : une révocation avant terme — redémarrage du serveur, purge de sa table de sessions, décision d'un administrateur — n'était annoncée par rien et l'application affichait « Ouvert ✓ » devant un réseau muet, bouton masqué, jusqu'à la chute du tunnel. Sans échéance annoncée, indéfiniment. L'accès est reconfirmé chaque minute tant qu'il est cru ouvert. Authentification perdue. « Plus tard » restait actif pendant la vérification, Échap et la croix aussi. Fermer là rendait la main sur un refus alors que le serveur pouvait encore accepter : sa réponse arrivait après la sortie d'exec() et n'était lue par personne. L'accès était ouvert côté serveur, l'application affichait la quarantaine, et le code saisi était consommé pour rien. La fenêtre ne se ferme plus tant qu'une requête est en vol. Fenêtre survivante. Ni accept() ni reject() ne passent par closeEvent : le minuteur de 500 ms continuait à battre après la fermeture, et la fenêtre — parentée à la principale — survivait à sa disparition. dispose() l'arrête et programme la destruction ; _access_dialog est remis à None, son thread n'étant plus attendu à l'arrêt de l'application. Repli 404 trompeur. La sonde d'accessibilité tenait pour preuve toute réponse du serveur DNS du split-DNS, sans vérifier qu'il était joint par le tunnel. Une adresse RFC1918 routée par le réseau local du poste répondait donc hors tunnel, et le repli censé éviter le blocage le provoquait : accès déclaré ouvert sans échéance, bouton masqué. La route est vérifiée d'abord, sans émettre de paquet — connect() sur une socket UDP suffit à connaître l'adresse source retenue par le noyau. Proxy. urlopen emploie l'ouvreur par défaut, dont le ProxyHandler lit http_proxy dans l'environnement : sur un poste d'entreprise la requête vers l'adresse privée du tunnel partait au proxy, dont la réponse — 404 souvent — était prise pour celle du serveur, déclenchant le repli ci-dessus. Panne intermittente parfaite : selon l'environnement de lancement, ça passait ou non. Silence pris pour un refus. Un ok faux couvrait « le serveur refuse » et « le serveur n'a rien dit », traités pareil : un hoquet au montage du tunnel faisait surgir la fenêtre et brûler un code alors que l'accès était peut-être déjà ouvert. AuthResult porte désormais authoritative. Un refus du serveur referme du premier coup, il en est seul juge ; un silence déclenche un second sondage avant de déranger l'utilisateur, et n'entame l'accès qu'après trois minutes sans réponse. Annulation destructrice. _open_access_dialog écrivait l'état d'accès quoi qu'il arrive. Un sondage abouti pendant que la fenêtre était ouverte — les minuteries tournent dans la boucle imbriquée d'exec() — pouvait établir que l'accès l'était, que « Plus tard » remettait aussitôt à fermé. Renoncer à saisir un code ne referme rien côté serveur ; plus rien ici non plus. api_url. Le schéma est vérifié plutôt que repris tel quel : identifiant et code partent dans cette URL. Reste ouvert, et hors de ce dépôt : l'API par défaut est en clair et le serveur n'est pas authentifié. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
205258cbf1
commit
556f22e7c9
+111
-21
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.core.config import Config
|
||||
from app.core.wireguard import tunnel_gateway
|
||||
|
||||
_TIMEOUT = 10
|
||||
# Le sondage d'état est borné plus court que la validation d'un code : il part
|
||||
@@ -41,6 +42,16 @@ _PROBE_TIMEOUT = 4
|
||||
# le tunnel, deux secondes suffisent largement — au-delà, c'est filtré.
|
||||
_REACH_TIMEOUT = 2
|
||||
|
||||
# Ouvreur sans proxy. `urllib.request.urlopen` emploie l'ouvreur par défaut,
|
||||
# dont le `ProxyHandler` lit `http_proxy` / `HTTP_PROXY` dans l'environnement :
|
||||
# sur un poste d'entreprise, la requête vers l'adresse privée du tunnel partait
|
||||
# au proxy au lieu du serveur. Sa réponse — souvent un 404 — était prise pour
|
||||
# celle du serveur, et un 404 déclenche justement le repli d'observation
|
||||
# réseau. Symptôme parfait de panne intermittente : selon l'environnement de
|
||||
# lancement, ça passait ou non. Le serveur d'authentification n'est de toute
|
||||
# façon joignable que par l'intérieur du tunnel, jamais par un proxy.
|
||||
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
|
||||
class AuthResult(NamedTuple):
|
||||
"""Réponse du serveur à une demande d'ouverture d'accès.
|
||||
@@ -49,11 +60,20 @@ class AuthResult(NamedTuple):
|
||||
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.
|
||||
|
||||
`authoritative` dit si le serveur a réellement répondu. Un `ok` faux
|
||||
couvrait deux situations très différentes — « le serveur refuse » et « le
|
||||
serveur n'a rien dit » — que l'appelant traitait pareil : un hoquet réseau
|
||||
au montage du tunnel faisait donc surgir la fenêtre de saisie et brûler un
|
||||
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.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
access_until: datetime | None = None
|
||||
authoritative: bool = True
|
||||
|
||||
|
||||
def _parse_until(raw: object) -> datetime | None:
|
||||
@@ -78,22 +98,78 @@ 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.
|
||||
serveur occupe la première adresse du réseau du tunnel. 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.
|
||||
|
||||
La déduction est déléguée à `wireguard.tunnel_gateway`, qui sert déjà au
|
||||
diagnostic. Elle se faisait ici à part, en retranchant le dernier octet :
|
||||
cela supposait un /24 et ignorait les IPs autorisées, alors qu'une adresse
|
||||
client en /32 — la forme la plus courante — ne décrit aucun réseau. Un
|
||||
client en 10.6.1.7/32 sur un réseau 10.6.0.0/16 se voyait ainsi expédié
|
||||
vers 10.6.1.1, qui n'existe pas : sondage et validation échouaient tous
|
||||
deux, sans autre issue que de renseigner `api_url` à la main.
|
||||
"""
|
||||
explicit = (cfg.wg.get("api_url") or "").strip()
|
||||
if explicit:
|
||||
# Le schéma est vérifié plutôt que repris tel quel : identifiant et
|
||||
# code partent dans cette URL, et une valeur saisie de travers — un
|
||||
# hôte nu sans schéma, un `file:` — enverrait la requête ailleurs que
|
||||
# prévu, ou la ferait échouer sans dire pourquoi. `https` est accepté
|
||||
# pour un serveur qui l'expose ; `http` reste le cas courant, la
|
||||
# confidentialité venant alors du tunnel lui-même.
|
||||
if not explicit.lower().startswith(("http://", "https://")):
|
||||
return ""
|
||||
return explicit.rstrip("/")
|
||||
|
||||
client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip()
|
||||
try:
|
||||
host = ipaddress.ip_address(client_address)
|
||||
except ValueError:
|
||||
gateway = tunnel_gateway(cfg.wg.get("client_address") or "",
|
||||
cfg.wg.get("allowed_ips") or "")
|
||||
if not gateway:
|
||||
return ""
|
||||
gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1)
|
||||
return f"http://{gateway}:8000"
|
||||
# Une adresse IPv6 doit être encadrée de crochets dans une URL, sans quoi
|
||||
# son propre « : » se confond avec celui du port.
|
||||
host = f"[{gateway}]" if ":" in gateway else gateway
|
||||
return f"http://{host}:8000"
|
||||
|
||||
|
||||
def _routed_through_tunnel(cfg: Config, target: str) -> bool:
|
||||
"""Le trafic vers `target` sort-il bien par le tunnel ?
|
||||
|
||||
Rien ne garantit que le serveur DNS déclaré ne soit joignable *que* par le
|
||||
tunnel : une adresse RFC1918 d'entreprise peut très bien être routée par le
|
||||
réseau local du poste — télétravail depuis un site jumeau, VPN d'un autre
|
||||
fournisseur déjà monté. La sonde d'accessibilité réussissait alors hors
|
||||
tunnel et déclarait la quarantaine levée alors qu'elle tenait toujours :
|
||||
l'accès s'affichait « Ouvert ✓ », sans échéance donc sans fin, et le bouton
|
||||
de saisie restait masqué. Le repli censé éviter le blocage le provoquait.
|
||||
|
||||
La question se tranche sans émettre le moindre paquet : `connect()` sur une
|
||||
socket UDP ne fait que fixer la destination, et le noyau y attache déjà
|
||||
l'adresse source qu'il emploierait — celle du tunnel si et seulement si
|
||||
c'est par là que passe la route.
|
||||
"""
|
||||
client_ip = ((cfg.wg.get("client_address") or "")
|
||||
.split(",")[0].split("/")[0].strip())
|
||||
if not client_ip:
|
||||
return False
|
||||
try:
|
||||
family, socktype, proto, _, sockaddr = socket.getaddrinfo(
|
||||
target, 53, type=socket.SOCK_DGRAM)[0]
|
||||
probe = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
probe.connect(sockaddr)
|
||||
source = probe.getsockname()[0]
|
||||
finally:
|
||||
probe.close()
|
||||
except (OSError, IndexError):
|
||||
return False
|
||||
# Comparaison numérique : le noyau peut rendre une forme différente de
|
||||
# celle écrite en configuration (zéros abrégés d'une adresse IPv6).
|
||||
try:
|
||||
return ipaddress.ip_address(source) == ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _remote_network_reachable(cfg: Config) -> bool:
|
||||
@@ -109,11 +185,15 @@ def _remote_network_reachable(cfg: Config) -> bool:
|
||||
refus de connexion en est une : le paquet a atteint l'hôte, qui a répondu
|
||||
par un RST. Seul un silence signifie « filtré ».
|
||||
|
||||
Encore faut-il que la cible soit atteinte *par le tunnel* — sans quoi on
|
||||
observe le réseau local et on en conclut n'importe quoi (cf.
|
||||
`_routed_through_tunnel`).
|
||||
|
||||
Renvoie False dès qu'aucune cible n'est configurée : sans point de
|
||||
comparaison, on n'observe rien et on ne conclut rien.
|
||||
"""
|
||||
target = (cfg.wg.get("split_dns_server") or "").strip()
|
||||
if not target:
|
||||
if not target or not _routed_through_tunnel(cfg, target):
|
||||
return False
|
||||
try:
|
||||
socket.create_connection((target, 53), timeout=_REACH_TIMEOUT).close()
|
||||
@@ -136,17 +216,19 @@ def session_status(cfg: Config) -> AuthResult:
|
||||
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.
|
||||
Un `ok` faux vaut « accès non ouvert » ; c'est `authoritative` qui dit si
|
||||
le serveur l'a affirmé ou si l'on n'en sait rien. L'affichage reste
|
||||
prudent dans les deux cas — le bouton de saisie demeure atteignable — mais
|
||||
l'appelant ne fait plus surgir la fenêtre sur un simple silence du réseau.
|
||||
"""
|
||||
base = api_base_url(cfg)
|
||||
if not base:
|
||||
return AuthResult(False, "Adresse du serveur d'authentification inconnue.")
|
||||
return AuthResult(False, "Adresse du serveur d'authentification "
|
||||
"inconnue.", None, False)
|
||||
|
||||
request = urllib.request.Request(f"{base}/api/session", method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=_PROBE_TIMEOUT) as response:
|
||||
with _OPENER.open(request, timeout=_PROBE_TIMEOUT) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
until = _parse_until(body.get("access_until"))
|
||||
if until is None:
|
||||
@@ -167,12 +249,20 @@ def session_status(cfg: Config) -> AuthResult:
|
||||
return AuthResult(True, "Accès réseau ouvert (constaté sur le "
|
||||
"réseau ; serveur trop ancien pour "
|
||||
"l'annoncer)")
|
||||
if exc.code == 404:
|
||||
# Route absente et rien d'observable sur le réseau : c'est un
|
||||
# serveur trop ancien pour répondre à la question, pas un refus.
|
||||
return AuthResult(False, detail or "État de l'accès inconnu "
|
||||
"(serveur sans /api/session)",
|
||||
None, 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})")
|
||||
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}")
|
||||
# Serveur injoignable ou réponse illisible : aucune information, donc
|
||||
# aucune conclusion — on ne prétend pas que l'accès est ouvert, mais
|
||||
# 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:
|
||||
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
|
||||
@@ -204,7 +294,7 @@ def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
|
||||
headers={"Content-Type": "application/json"}, method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
|
||||
with _OPENER.open(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.
|
||||
|
||||
Reference in New Issue
Block a user