From 4852361ea67b762ba0697302932a4d62f61832a6 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 9 Oct 2026 16:43:26 +0000 Subject: [PATCH] =?UTF-8?q?Connexion=20:=20formulaire=20utilisable=20sans?= =?UTF-8?q?=20JavaScript,=20refus=20expliqu=C3=A9s=20dans=20le=20Terminal,?= =?UTF-8?q?=20rtg=20mot-de-passe=20--tester=20;=20nginx=20:=20location=20^?= =?UTF-8?q?~=20/admin/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim --- README.md | 2 +- exemples/nginx-admin.conf | 4 +++- rtg/acces.py | 3 +++ rtg/cli.py | 8 +++++++ rtg/interface/modeles/connexion.html | 4 ++-- rtg/interface/serveur.py | 36 ++++++++++++++++++++++------ 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 01ef815..6e5590f 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Pour administrer le site depuis son adresse publique (bouton « Connexion » en - Traefik seul (fichier de configuration dynamique) : copier `exemples/traefik-rtg-admin.yml` dans le dossier de configuration dynamique de Traefik, en y mettant les mêmes `entryPoints` et `certResolver` que pour le site. Le fichier indique l'adresse à mettre selon que Traefik tourne dans Docker sur le Mac, directement sur le Mac, ou sur une autre machine (dans ce dernier cas, lancer RTG avec `--ecoute 0.0.0.0` en plus). - nginx, dans le bloc `server` du site en HTTPS : ```nginx - location /admin/ { + location ^~ /admin/ { proxy_pass http://127.0.0.1:8765; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/exemples/nginx-admin.conf b/exemples/nginx-admin.conf index 7a3b7a6..7db8a42 100644 --- a/exemples/nginx-admin.conf +++ b/exemples/nginx-admin.conf @@ -6,7 +6,9 @@ # extra_hosts: # - "host.docker.internal:host-gateway" - location /admin/ { + # « ^~ » : sinon une règle du type « location ~* \.(js|css|png)$ » déjà + # présente dans nginx.conf prendrait les fichiers de l'interface pour ceux du site. + location ^~ /admin/ { proxy_pass http://host.docker.internal:8765; proxy_set_header Host $host; # Traefik a déjà noté l'adresse du visiteur et le https : on les transmet tels quels. diff --git a/rtg/acces.py b/rtg/acces.py index bfffc3e..53ee4f8 100644 --- a/rtg/acces.py +++ b/rtg/acces.py @@ -10,6 +10,7 @@ import hashlib import hmac import os import secrets +import unicodedata from rtg.config import ecrire_toml, lire_toml @@ -26,6 +27,8 @@ def chemin_acces(): def _empreinte(mdp, sel, n, r, p): + # NFC : un « é » tapé dans le Terminal ou dans le navigateur donne les mêmes octets. + mdp = unicodedata.normalize("NFC", mdp) return hashlib.scrypt(mdp.encode("utf-8"), salt=sel, n=n, r=r, p=p, maxmem=128 * r * n * 2, dklen=32) diff --git a/rtg/cli.py b/rtg/cli.py index c972e52..a4ad0b3 100644 --- a/rtg/cli.py +++ b/rtg/cli.py @@ -61,6 +61,7 @@ def main(argv=None): mp = sp.add_parser("mot-de-passe", help="définir ou supprimer le mot de passe de l'interface") mp.add_argument("--supprimer", action="store_true") + mp.add_argument("--tester", action="store_true", help="vérifier un mot de passe sans le changer") args = p.parse_args(argv) @@ -70,6 +71,13 @@ def main(argv=None): print("✅ Mot de passe supprimé." if acces.supprimer() else "ℹ️ Aucun mot de passe défini.") return import getpass + if args.tester: + if not acces.defini(): + sys.exit(f"❌ Aucun mot de passe défini ({acces.chemin_acces()}).") + ok = acces.verifier(getpass.getpass("Mot de passe à vérifier : ")) + print(f"✅ C'est bien le mot de passe enregistré ({acces.chemin_acces()})." if ok + else f"❌ Ce n'est pas le mot de passe enregistré ({acces.chemin_acces()}).") + sys.exit(0 if ok else 1) mdp = getpass.getpass("Nouveau mot de passe : ") if mdp != getpass.getpass("Le même, encore une fois : "): sys.exit("❌ Les deux saisies diffèrent.") diff --git a/rtg/interface/modeles/connexion.html b/rtg/interface/modeles/connexion.html index fd072f3..37bac32 100644 --- a/rtg/interface/modeles/connexion.html +++ b/rtg/interface/modeles/connexion.html @@ -20,12 +20,12 @@ elle contient la clé d'accès de cette session{% if mdp %}, ou entrez le mot de passe{% endif %}.

{% endif %} {% if mdp %} -
+ -
{% if attente %}
Trop d'essais : réessayez dans {{ attente }} s.
{% endif %}
+
{% if message %}
{{ message }}
{% elif attente %}
Trop d'essais : réessayez dans {{ attente }} s.
{% endif %}
{% elif not distant %}

Pour entrer aussi avec un mot de passe (depuis un autre navigateur ou une autre machine) : diff --git a/rtg/interface/serveur.py b/rtg/interface/serveur.py index fc577fc..e308474 100644 --- a/rtg/interface/serveur.py +++ b/rtg/interface/serveur.py @@ -696,14 +696,18 @@ def creer_gestionnaire(app, port, gardien=None): connecte = gardien.session_valide(self.session()) form = {} if methode == "POST": - if self.headers.get("HX-Request") != "true": + # La connexion marche aussi sans JavaScript (formulaire classique), + # à condition de venir d'une page de ce site. + if self.headers.get("HX-Request") != "true" and not ( + chemin == "/connexion" and self.meme_origine()): raise Erreur(403, "Requête refusée") n = int(self.headers.get("Content-Length") or 0) if n > 2_000_000: raise Erreur(413, "Requête trop grosse") form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True) if chemin in PUBLIQUES: - return self.envoyer(self.connexion(methode, form, connecte), tete) + rep = self.connexion(methode, form, connecte) + return self.envoyer(rep, tete) if rep else None if not connecte: if self.headers.get("HX-Request") == "true": return self.envoyer(Reponse("", entetes={"HX-Redirect": P + "/connexion"}), tete) @@ -741,18 +745,36 @@ def creer_gestionnaire(app, port, gardien=None): def https(self): return gardien.https or (gardien.proxy and self.headers.get("X-Forwarded-Proto") == "https") + def meme_origine(self): + origine = self.headers.get("Origin") + if not origine: + return True # navigateurs anciens : pas d'en-tête Origin + hote = self.headers.get("X-Forwarded-Host") if gardien.proxy else None + return urlsplit(origine).netloc in {hote or self.headers.get("Host", ""), *hotes} + def connexion(self, methode, form, connecte): ip = self.ip() + htmx = self.headers.get("HX-Request") == "true" + message = "" if methode == "POST": - ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", "")) + mdp = _valeur(form, "mot_de_passe", "") + ok, message = gardien.essayer(ip, mdp) if ok: + print(f"🔓 Connexion à l'interface depuis {ip}") sid = gardien.ouvrir_session() - return Reponse("", entetes={"HX-Redirect": P + "/", - "Set-Cookie": gardien.entete_cookie(sid, self.https())}) - return Reponse(f'

{_echapper(message)}
') + cookie = gardien.entete_cookie(sid, self.https()) + if htmx: + return Reponse("", entetes={"HX-Redirect": P + "/", "Set-Cookie": cookie}) + self.rediriger(P + "/", cookie) + return None + # Dans le Terminal, pour comprendre un refus (jamais le mot de passe). + raison = "champ vide (formulaire mal transmis ?)" if not mdp else message + print(f"🔒 Connexion refusée depuis {ip} : {raison}") + if htmx: + return Reponse(f'
{_echapper(message)}
') return page("connexion.html", mdp=acces.defini(), connecte=connecte, distant=gardien.distant or gardien.proxy, attente=gardien.attente(ip), - site=self.adresse_site()) + site=self.adresse_site(), message=message) def adresse_site(self): """Lien « Retour au site » de la page de connexion, derrière le serveur web."""