diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 4bde31a..7a80447 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -521,10 +521,18 @@ func newLinkHandler(mux *http.ServeMux) http.Handler { // par le tunnel. Le navigateur distant ne connaît que le token (vérifié par le // relais) ; c'est ici, en local, qu'on satisfait l'auth de l'API web sans // exposer la clé au client. +// +// ⚠️ La clé est relue À CHAQUE REQUÊTE, et surtout pas capturée une fois pour +// toutes à l'ouverture du tunnel. Avec une capture, changer la clé (depuis +// l'interface ou `ajean set-web-key`) faisait injecter l'ANCIENNE jusqu'au +// prochain redémarrage du service : tout l'accès distant tombait en 401, et +// comme le chat n'affiche pas le code HTTP d'un flux qui n'arrive jamais, le +// symptôme était un « chargement de la conversation » infini, sans la moindre +// erreur pour dire pourquoi. Le coût est nul : requireWebAuth relit déjà la clé +// à chaque requête de l'autre côté. func withLocalAuth(next http.Handler) http.Handler { - webKey := readWebKey() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if webKey != "" && r.Header.Get("Authorization") == "" { + if webKey := readWebKey(); webKey != "" && r.Header.Get("Authorization") == "" { r.Header.Set("Authorization", "Bearer "+webKey) } next.ServeHTTP(w, r) diff --git a/internal/ajean/relay_localauth_test.go b/internal/ajean/relay_localauth_test.go new file mode 100644 index 0000000..7cdad30 --- /dev/null +++ b/internal/ajean/relay_localauth_test.go @@ -0,0 +1,64 @@ +package ajean + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// Changer la clé de pilotage ne doit PAS couper l'accès distant jusqu'au +// prochain redémarrage. La clé était capturée à l'ouverture du tunnel : après un +// `ajean set-web-key`, le tunnel injectait l'ancienne, tout passait en 401, et +// le portail restait bloqué sur « chargement de la conversation » sans jamais +// dire pourquoi. +func TestWithLocalAuthSuitLaCleCourante(t *testing.T) { + testHome(t) + if err := putStr(bkState, "web_key", "cle-numero-1"); err != nil { + t.Fatal(err) + } + // Handler protégé, comme l'API web réelle. + protege := withLocalAuth(requireWebAuth(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + appel := func() int { + rr := httptest.NewRecorder() + protege.ServeHTTP(rr, httptest.NewRequest("GET", "/api/status", nil)) + return rr.Code + } + if code := appel(); code != 200 { + t.Fatalf("clé initiale : HTTP %d", code) + } + // La clé change pendant que le tunnel tourne (bouton de l'interface, CLI). + if err := putStr(bkState, "web_key", "cle-numero-2"); err != nil { + t.Fatal(err) + } + if code := appel(); code != 200 { + t.Fatalf("après changement de clé : HTTP %d — le tunnel injecte encore l'ancienne", code) + } + // Clé retirée : l'API est ouverte, le tunnel ne doit rien casser non plus. + if err := putStr(bkState, "web_key", ""); err != nil { + t.Fatal(err) + } + if code := appel(); code != 200 { + t.Fatalf("sans clé : HTTP %d", code) + } +} + +// Une requête qui porte DÉJÀ une autorisation n'est pas réécrite : c'est le +// client distant qui décide dans ce cas. +func TestWithLocalAuthNecraseAucunEnTete(t *testing.T) { + testHome(t) + if err := putStr(bkState, "web_key", "cle-locale"); err != nil { + t.Fatal(err) + } + var vu string + protege := withLocalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vu = r.Header.Get("Authorization") + })) + req := httptest.NewRequest("GET", "/api/status", nil) + req.Header.Set("Authorization", "Bearer venue-du-client") + protege.ServeHTTP(httptest.NewRecorder(), req) + if vu != "Bearer venue-du-client" { + t.Fatalf("en-tête client écrasé : %q", vu) + } +}