diff --git a/.changeset/lan-auth-ui.md b/.changeset/lan-auth-ui.md new file mode 100644 index 00000000..4fb41e1a --- /dev/null +++ b/.changeset/lan-auth-ui.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +When LAN auth is on, the dashboard asks for the house password and keeps a session cookie. Settings → System turns the lock on and off. diff --git a/docs/architecture.md b/docs/architecture.md index 243c9700..615f3b62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -367,11 +367,11 @@ The honest limits, which belong here rather than in a comment nobody reads: `appenroll` on every privileged request so a socket cannot outlive a revoke, and the next handshake fails. Nothing can un-send bytes already in a phone's cache; -- **the LAN is still unauthenticated.** `api.Authenticate` mints a local owner - for anything that arrives without a caller. That writes down what the LAN - already is rather than changing it, and it is the one branch that has to - change to authenticate the LAN later — every handler downstream reads its - caller from `apiauth.From`. +- **the LAN is unauthenticated unless `api.lan_auth` is on.** With the flag + off, `api.Authenticate` mints a local owner for anything that arrives + without a caller. With it on, a LAN peer must present the house password + (Bearer or session cookie) to act as owner; live reads stay open as a + viewer. Every handler downstream reads its caller from `apiauth.From`. ## Fleet ping diff --git a/docs/operations.md b/docs/operations.md index 0d0e98c5..e4aca3b0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -136,10 +136,17 @@ mutations remain locked. tunnel credential for future remote access. That expansion point is described in [architecture.md](architecture.md#future-remote-access-boundary). -`api.lan_auth` is off by default. When on, protected LAN routes need -`Authorization: Bearer `. Loopback (`127.0.0.1` / `::1`) never -does. Live status stays readable without the password; a viewer caller is -minted for those reads. The browser login page is not part of this change. +`api.lan_auth` is off by default. Turn it on from Settings → System (LAN +password). When on, protected LAN routes need the house password. `curl` +sends `Authorization: Bearer `. The browser login form +sets a session cookie (`ftw_lan`, 12 hours). Loopback (`127.0.0.1` / `::1`) +never asks. Live status stays readable without the password; a viewer +caller is minted for those reads. + +The FTW app and Home Assistant MQTT are unchanged. + +Recovery: `curl` to `127.0.0.1`, or set `api.lan_auth: false` in +`config.yaml` and restart Core. ## Logs and health diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 2ee9916a..4c9634b6 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -372,6 +372,8 @@ func (s *Server) routes() { s.handle("GET /api/health", Read, s.handleHealth) s.handle("GET /api/status", Read, s.handleStatus) s.handle("GET /api/auth/status", Read, s.handleAuthStatus) + s.handle("POST /api/auth/login", Configure, s.handleAuthLogin) + s.handle("POST /api/auth/logout", Configure, s.handleAuthLogout) s.handle("POST /api/auth/password", Configure, s.handleAuthPassword) s.handle("GET /api/system/info", Read, s.handleSysInfo) s.handle("GET /api/storage/inventory", Read, s.handleStorageInventory) diff --git a/go/internal/api/api_lan_auth_test.go b/go/internal/api/api_lan_auth_test.go index e4fceca5..40d8d471 100644 --- a/go/internal/api/api_lan_auth_test.go +++ b/go/internal/api/api_lan_auth_test.go @@ -18,6 +18,13 @@ import ( const testHousePassword = "house-pass-ok" +func resetLANSessions() { + lanSessionMu.Lock() + lanSessions = map[string]lanSession{} + lanSessionNow = time.Now + lanSessionMu.Unlock() +} + func resetLANGuesses(t *testing.T) { t.Helper() lanGuessMu.Lock() @@ -25,15 +32,61 @@ func resetLANGuesses(t *testing.T) { lanGuessLockedUntil = time.Time{} lanGuessNow = time.Now lanGuessMu.Unlock() + resetLANSessions() t.Cleanup(func() { lanGuessMu.Lock() lanGuessFailures = 0 lanGuessLockedUntil = time.Time{} lanGuessNow = time.Now lanGuessMu.Unlock() + resetLANSessions() }) } +func mustIssueLANSession(t *testing.T) string { + t.Helper() + token, err := issueLANSession() + if err != nil { + t.Fatal(err) + } + return token +} + +func lanSessionFromRecorder(t *testing.T, rr *httptest.ResponseRecorder) string { + t.Helper() + for _, c := range rr.Result().Cookies() { + if c.Name == lanSessionCookieName && c.Value != "" { + return c.Value + } + } + t.Fatalf("missing %s cookie: %q", lanSessionCookieName, rr.Header().Get("Set-Cookie")) + return "" +} + +func enableStoredLANAuth(t *testing.T, srv *Server) { + t.Helper() + body := `{"password":"` + testHousePassword + `","enabled":true}` + post := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/auth/password", strings.NewReader(body)) + post.RemoteAddr = "127.0.0.1:43210" + post.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, post) + if rr.Code != http.StatusOK { + t.Fatalf("enable status = %d, body=%s", rr.Code, rr.Body.String()) + } +} + +func postLANAuthJSON(srv *Server, method, url, remote, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, url, strings.NewReader(body)) + req.RemoteAddr = remote + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + return rr +} + func lanAuthPolicy(secret string) MutationPolicy { return MutationPolicy{ LANAuthEnabled: func() bool { return true }, @@ -479,3 +532,165 @@ func TestLANPasswordHashRoundTrip(t *testing.T) { t.Fatal("wrong password accepted") } } + +func TestLANAuthProtectedConfigAcceptsSessionCookie(t *testing.T) { + resetLANGuesses(t) + token := mustIssueLANSession(t) + var caller apiauth.Caller + req := lanAuthRequest(http.MethodGet, "http://ftw.local:8080/api/config", "192.168.1.10:43210", "") + req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: token}) + rr := serveLANAuth(lanAuthPolicy(testHousePassword), req, func(r *http.Request) { + caller, _ = apiauth.FromRequest(r) + }) + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204 (body=%s)", rr.Code, rr.Body.String()) + } + if caller.Kind != apiauth.KindLAN || caller.Role != apiauth.RoleOwner { + t.Fatalf("caller = %+v, want LAN owner", caller) + } +} + +func TestLANAuthProtectedConfigRejectsUnknownCookie(t *testing.T) { + resetLANGuesses(t) + req := lanAuthRequest(http.MethodGet, "http://ftw.local:8080/api/config", "192.168.1.10:43210", "") + req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}) + rr := serveLANAuth(lanAuthPolicy(testHousePassword), req, nil) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (body=%s)", rr.Code, rr.Body.String()) + } +} + +func TestLANAuthBearerWinsOverSessionCookie(t *testing.T) { + resetLANGuesses(t) + token := mustIssueLANSession(t) + req := lanAuthRequest(http.MethodGet, "http://ftw.local:8080/api/config", "192.168.1.10:43210", "Bearer wrong-password") + req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: token}) + rr := serveLANAuth(lanAuthPolicy(testHousePassword), req, nil) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 when Bearer is present and wrong (body=%s)", rr.Code, rr.Body.String()) + } +} + +func TestAuthLoginSetsCookieAndConfigSucceeds(t *testing.T) { + resetLANGuesses(t) + srv := newLANAuthServer(t) + enableStoredLANAuth(t, srv) + + rr := postLANAuthJSON(srv, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", + `{"password":"`+testHousePassword+`"}`) + if rr.Code != http.StatusOK { + t.Fatalf("login status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"status":"ok"`) { + t.Fatalf("login body = %s", rr.Body.String()) + } + var cookie *http.Cookie + for _, c := range rr.Result().Cookies() { + if c.Name == lanSessionCookieName { + cookie = c + break + } + } + if cookie == nil || cookie.Value == "" { + t.Fatalf("missing session cookie: %q", rr.Header().Get("Set-Cookie")) + } + if !cookie.HttpOnly { + t.Fatal("session cookie is not HttpOnly") + } + if cookie.Path != "/" { + t.Fatalf("cookie Path = %q, want /", cookie.Path) + } + if cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("cookie SameSite = %v, want Strict", cookie.SameSite) + } + if cookie.Secure { + t.Fatal("session cookie must not set Secure (LAN is http)") + } + if cookie.MaxAge != int(lanSessionTTL/time.Second) { + t.Fatalf("cookie MaxAge = %d, want %d", cookie.MaxAge, int(lanSessionTTL/time.Second)) + } + raw := rr.Header().Get("Set-Cookie") + if strings.Contains(strings.ToLower(raw), "secure") { + t.Fatalf("Set-Cookie advertised Secure: %q", raw) + } + + req := httptest.NewRequest(http.MethodGet, "http://ftw.local:8080/api/config", nil) + req.RemoteAddr = "192.168.1.10:43210" + req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: cookie.Value}) + got := httptest.NewRecorder() + srv.Handler().ServeHTTP(got, req) + if got.Code != http.StatusOK { + t.Fatalf("config with session cookie status = %d, want 200 (body=%s)", got.Code, got.Body.String()) + } + if !strings.Contains(got.Body.String(), `"lan_auth":true`) { + t.Fatalf("config body = %s", got.Body.String()) + } +} + +func TestAuthLoginWrongPasswordNoCookie(t *testing.T) { + resetLANGuesses(t) + srv := newLANAuthServer(t) + enableStoredLANAuth(t, srv) + + rr := postLANAuthJSON(srv, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", + `{"password":"wrong-password"}`) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("login status = %d, want 401 (body=%s)", rr.Code, rr.Body.String()) + } + for _, c := range rr.Result().Cookies() { + if c.Name == lanSessionCookieName && c.Value != "" { + t.Fatalf("wrong password set a session cookie: %+v", c) + } + } +} + +func TestAuthLoginRejectedWhenDisabled(t *testing.T) { + resetLANGuesses(t) + srv := newLANAuthServer(t) + rr := postLANAuthJSON(srv, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", + `{"password":"`+testHousePassword+`"}`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("login while off status = %d, want 400 (body=%s)", rr.Code, rr.Body.String()) + } +} + +func TestAuthLogoutThenConfigUnauthorized(t *testing.T) { + resetLANGuesses(t) + srv := newLANAuthServer(t) + enableStoredLANAuth(t, srv) + + login := postLANAuthJSON(srv, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", + `{"password":"`+testHousePassword+`"}`) + if login.Code != http.StatusOK { + t.Fatalf("login status = %d, body=%s", login.Code, login.Body.String()) + } + token := lanSessionFromRecorder(t, login) + + logout := httptest.NewRequest(http.MethodPost, "http://ftw.local:8080/api/auth/logout", strings.NewReader(`{}`)) + logout.RemoteAddr = "192.168.1.10:43210" + logout.Header.Set("Content-Type", "application/json") + logout.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: token}) + out := httptest.NewRecorder() + srv.Handler().ServeHTTP(out, logout) + if out.Code != http.StatusOK { + t.Fatalf("logout status = %d, want 200 (body=%s)", out.Code, out.Body.String()) + } + cleared := false + for _, c := range out.Result().Cookies() { + if c.Name == lanSessionCookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared && !strings.Contains(out.Header().Get("Set-Cookie"), "Max-Age=0") { + t.Fatalf("logout did not clear cookie: %q", out.Header().Get("Set-Cookie")) + } + + req := httptest.NewRequest(http.MethodGet, "http://ftw.local:8080/api/config", nil) + req.RemoteAddr = "192.168.1.10:43210" + req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: token}) + got := httptest.NewRecorder() + srv.Handler().ServeHTTP(got, req) + if got.Code != http.StatusUnauthorized { + t.Fatalf("config after logout status = %d, want 401 (body=%s)", got.Code, got.Body.String()) + } +} diff --git a/go/internal/api/lan_auth.go b/go/internal/api/lan_auth.go index 0b9723c6..411fd70e 100644 --- a/go/internal/api/lan_auth.go +++ b/go/internal/api/lan_auth.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/subtle" "encoding/base64" + "encoding/hex" "fmt" "net/http" "strconv" @@ -24,6 +25,10 @@ const ( lanGuessLimit = 5 lanGuessCooldown = 30 * time.Second + lanSessionCookieName = "ftw_lan" + lanSessionBytes = 32 + lanSessionTTL = 12 * time.Hour + // Encoded in the stored hash so a later bump still verifies old rows. lanArgonTime uint32 = 3 lanArgonMemory uint32 = 64 * 1024 @@ -52,8 +57,79 @@ var ( lanGuessFailures int lanGuessLockedUntil time.Time lanGuessNow = time.Now + + lanSessionMu sync.Mutex + lanSessions = map[string]lanSession{} + lanSessionNow = time.Now ) +type lanSession struct { + expires time.Time +} + +func lanSessionCookieValue(r *http.Request) (string, bool) { + c, err := r.Cookie(lanSessionCookieName) + if err != nil || c.Value == "" { + return "", false + } + return c.Value, true +} + +func lanSessionValid(token string) bool { + if token == "" { + return false + } + lanSessionMu.Lock() + defer lanSessionMu.Unlock() + sess, ok := lanSessions[token] + if !ok { + return false + } + if !sess.expires.After(lanSessionNow()) { + delete(lanSessions, token) + return false + } + return true +} + +func issueLANSession() (string, error) { + raw := make([]byte, lanSessionBytes) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + lanSessionMu.Lock() + lanSessions[token] = lanSession{expires: lanSessionNow().Add(lanSessionTTL)} + lanSessionMu.Unlock() + return token, nil +} + +func dropLANSession(token string) { + if token == "" { + return + } + lanSessionMu.Lock() + delete(lanSessions, token) + lanSessionMu.Unlock() +} + +func dropAllLANSessions() { + lanSessionMu.Lock() + lanSessions = map[string]lanSession{} + lanSessionMu.Unlock() +} + +func setLANSessionCookie(w http.ResponseWriter, token string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: lanSessionCookieName, + Value: token, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + // admitLANSecret is the process-global guess limiter for the house password. // Five failed VerifyLANSecret calls lock every further attempt, including // the right password, for 30s. The clock is swapped in tests. @@ -183,6 +259,56 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, _ *http.Request) { }) } +type lanAuthLoginRequest struct { + Password string `json:"password"` +} + +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + if s.deps.State == nil || s.deps.Cfg == nil || s.deps.CfgMu == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "config store unavailable"}) + return + } + s.deps.CfgMu.RLock() + lanAuth := s.deps.Cfg.API.LANAuth + s.deps.CfgMu.RUnlock() + if !lanAuth { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "LAN auth is not enabled"}) + return + } + if !lanPasswordConfigured(s.deps.State) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "LAN password is not configured"}) + return + } + var req lanAuthLoginRequest + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: " + err.Error()}) + return + } + st := s.deps.State + if !admitLANSecret(func(secret string) bool { + return VerifyStoredLANSecret(st, secret) + }, req.Password) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid password"}) + return + } + token, err := issueLANSession() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create session"}) + return + } + setLANSessionCookie(w, token, int(lanSessionTTL/time.Second)) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + if token, ok := lanSessionCookieValue(r); ok { + dropLANSession(token) + } + // MaxAge < 0 emits Max-Age=0 so the browser drops ftw_lan. + setLANSessionCookie(w, "", -1) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + type lanAuthPasswordRequest struct { Password string `json:"password"` Enabled *bool `json:"enabled"` @@ -227,6 +353,7 @@ func (s *Server) handleAuthPassword(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save failed: " + err.Error()}) return } + dropAllLANSessions() } } @@ -239,6 +366,7 @@ func (s *Server) handleAuthPassword(w http.ResponseWriter, r *http.Request) { return } if !enabled { + dropAllLANSessions() if err := s.deps.State.SaveConfig(lanAuthPasswordKey, ""); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save failed: " + err.Error()}) return diff --git a/go/internal/api/security.go b/go/internal/api/security.go index 2cecf608..e838fec4 100644 --- a/go/internal/api/security.go +++ b/go/internal/api/security.go @@ -50,9 +50,10 @@ func WithSecurityHeaders(next http.Handler) http.Handler { // existed. It is kept as it is. KindApp is never replaced and never // asked for the house password. // - anything else arrived on the LAN listener. With api.lan_auth off -// (the default), or from loopback, or with a matching house Bearer, -// it is minted as a local owner — today's behaviour. With lan_auth -// on, a LAN peer without that proof is a viewer. +// (the default), or from loopback, or with a matching house Bearer +// or session cookie, it is minted as a local owner — today's +// behaviour. With lan_auth on, a LAN peer without that proof is a +// viewer. // // The guarding half rejects browser cross-site writes, non-JSON request // bodies, malformed Host/Origin metadata and unauthenticated protected @@ -137,9 +138,10 @@ func decideLANCaller(r *http.Request, policy MutationPolicy, houseOK bool) apiau return lanViewerCaller(r) } -// resolveLANSecret verifies a presented house Bearer at most once per -// request. The outer listener and Server.Handler both wrap Authenticate; -// a context flag stops the inner wrap from hashing or counting twice. +// resolveLANSecret verifies a presented house Bearer or session cookie at +// most once per request. Bearer wins when both are present. The outer +// listener and Server.Handler both wrap Authenticate; a context flag +// stops the inner wrap from hashing or counting twice. func resolveLANSecret(r *http.Request, policy MutationPolicy) (bool, *http.Request) { if ok, checked := lanSecretFrom(r.Context()); checked { return ok, r @@ -147,12 +149,14 @@ func resolveLANSecret(r *http.Request, policy MutationPolicy) (bool, *http.Reque if !lanAuthOn(policy) || isLoopbackClient(r.RemoteAddr) || !isLocalClient(r.RemoteAddr) { return false, r } - secret, ok := parseBearer(r.Header.Get("Authorization")) - if !ok { - return false, r + if secret, ok := parseBearer(r.Header.Get("Authorization")); ok { + houseOK := admitLANSecret(policy.VerifyLANSecret, secret) + return houseOK, r.WithContext(withLANSecret(r.Context(), houseOK)) + } + if token, ok := lanSessionCookieValue(r); ok && lanSessionValid(token) { + return true, r.WithContext(withLANSecret(r.Context(), true)) } - houseOK := admitLANSecret(policy.VerifyLANSecret, secret) - return houseOK, r.WithContext(withLANSecret(r.Context(), houseOK)) + return false, r } // localCaller is what a request off the LAN listener carries. diff --git a/web/index.html b/web/index.html index 504274bf..221b2aff 100644 --- a/web/index.html +++ b/web/index.html @@ -931,6 +931,7 @@

Price bars (top of the chart)

+ @@ -947,7 +948,7 @@

Price bars (top of the chart)

- + diff --git a/web/lan-auth.js b/web/lan-auth.js new file mode 100644 index 00000000..c2569e69 --- /dev/null +++ b/web/lan-auth.js @@ -0,0 +1,166 @@ +// House-password login for the LAN. Patches same-origin /api/ fetch so a +// 401 "valid LAN password required" opens a modal. The session cookie is +// HttpOnly; the password stays in the form only. +(function (window, document) { + "use strict"; + if (window.FTWLanAuth) return; + if (typeof window.fetch !== "function") return; + + var nativeFetch = window.fetch.bind(window); + var LAN_ERROR = "valid LAN password required"; + var pendingLogin = null; + + function requestURL(input) { + if (typeof input === "string") return input; + if (input && typeof input.url === "string") return input.url; + return ""; + } + + function sameOriginAPI(url) { + if (!url) return false; + try { + var resolved = new URL(url, window.location && window.location.href ? window.location.href : "http://localhost/"); + var origin = window.location && window.location.origin; + if (origin && resolved.origin !== origin) return false; + return resolved.pathname.indexOf("/api/") === 0; + } catch (e) { + return url.charAt(0) === "/" && url.indexOf("/api/") === 0; + } + } + + function isAuthPath(url) { + try { + var path = new URL(url, window.location && window.location.href ? window.location.href : "http://localhost/").pathname; + return path === "/api/auth/login" || path === "/api/auth/logout" || path === "/api/auth/status"; + } catch (e) { + return /\/api\/auth\/(login|logout|status)$/.test(url); + } + } + + function readError(res) { + return res.clone().json().then(function (body) { + return body && body.error ? String(body.error) : ""; + }).catch(function () { + return ""; + }); + } + + function ensureModal() { + var el = document.getElementById("lan-auth-modal"); + if (el) return el; + el = document.createElement("div"); + el.id = "lan-auth-modal"; + el.className = "modal hidden"; + el.setAttribute("role", "dialog"); + el.setAttribute("aria-modal", "true"); + el.setAttribute("aria-labelledby", "lan-auth-title"); + el.style.zIndex = "1100"; + el.innerHTML = + ''; + (document.body || document.documentElement).appendChild(el); + return el; + } + + function openLoginModal() { + if (pendingLogin) return pendingLogin; + pendingLogin = new Promise(function (resolve) { + var modal = ensureModal(); + var form = document.getElementById("lan-auth-form"); + var input = document.getElementById("lan-auth-password"); + var errEl = document.getElementById("lan-auth-error"); + var cancel = document.getElementById("lan-auth-cancel"); + var settled = false; + + function finish(ok) { + if (settled) return; + settled = true; + modal.classList.add("hidden"); + if (input) input.value = ""; + if (errEl) errEl.textContent = ""; + document.removeEventListener("keydown", onKey); + resolve(ok); + } + + function onKey(e) { + if (e.key === "Escape") finish(false); + } + + modal.classList.remove("hidden"); + if (errEl) errEl.textContent = ""; + if (input) { + input.value = ""; + input.focus(); + } + document.addEventListener("keydown", onKey); + + if (cancel) cancel.onclick = function () { finish(false); }; + modal.onclick = function (e) { + if (e.target === modal) finish(false); + }; + if (form) form.onsubmit = function (e) { + e.preventDefault(); + var password = input ? input.value : ""; + if (errEl) errEl.textContent = ""; + nativeFetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: password }), + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error((body && body.error) || "login failed"); + finish(true); + }); + }).catch(function (err) { + if (errEl) errEl.textContent = err.message || "login failed"; + if (input) input.focus(); + }); + }; + }).finally(function () { + pendingLogin = null; + }); + return pendingLogin; + } + + window.fetch = function (input, init) { + return nativeFetch(input, init).then(function (res) { + if (res.status !== 401) return res; + var url = requestURL(input); + if (!sameOriginAPI(url) || isAuthPath(url)) return res; + return readError(res).then(function (err) { + if (err !== LAN_ERROR) return res; + return openLoginModal().then(function (ok) { + if (!ok) return res; + return nativeFetch(input, init); + }); + }); + }); + }; + + function boot() { + nativeFetch("/api/auth/status").catch(function () {}); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } + + window.FTWLanAuth = { + ensure: function () { return openLoginModal(); }, + onUnauthorized: function () { return openLoginModal(); }, + }; +})(window, document); diff --git a/web/lan-auth.test.mjs b/web/lan-auth.test.mjs new file mode 100644 index 00000000..12b96c56 --- /dev/null +++ b/web/lan-auth.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; + +const lanAuth = readFileSync(new URL("./lan-auth.js", import.meta.url), "utf8"); +const system = readFileSync(new URL("./settings/tabs/system.js", import.meta.url), "utf8"); +const index = readFileSync(new URL("./index.html", import.meta.url), "utf8"); + +describe("lan-auth UI", () => { + it("does not store the house password in localStorage", () => { + assert.doesNotMatch(lanAuth, /localStorage/); + assert.doesNotMatch(system, /localStorage/); + assert.doesNotMatch(lanAuth, /sessionStorage/); + }); + + it("logs in through POST /api/auth/login", () => { + assert.match(lanAuth, /\/api\/auth\/login/); + assert.match(lanAuth, /JSON\.stringify\(\s*\{\s*password:/); + assert.match(index, /lan-auth\.js/); + }); + + it("turns the lock from Settings with /api/auth/password, not the config checkbox", () => { + assert.match(system, /\/api\/auth\/password/); + assert.match(system, /LAN password/); + assert.doesNotMatch(system, /data-path="api\.lan_auth"/); + }); +}); diff --git a/web/settings/tabs/system.js b/web/settings/tabs/system.js index 368ebb53..efc63391 100644 --- a/web/settings/tabs/system.js +++ b/web/settings/tabs/system.js @@ -76,6 +76,20 @@ ' .sys-help-secondary { margin:10px 0 0; color:var(--text-dim); font-size:0.8rem; }' + '' + '
' + + 'LAN password' + + '

' + + '' + + '' + + '' + + '' + + '
' + + ' ' + + ' ' + + '
' + + '

' + + '

Asks for this password on the LAN before settings and writes. Live status stays visible. curl still uses Bearer.

' + + '
' + + '
' + 'Host' + '
' + '
' + @@ -269,8 +283,89 @@ }); } + function setLanMsg(txt) { + var el = document.getElementById("sys-lan-auth-msg"); + if (el) el.textContent = txt || ""; + } + + function lanStatusText(d) { + if (!d || typeof d !== "object") return "Status unavailable"; + if (d.lan_auth && d.configured) return "On — password is set"; + if (d.lan_auth && !d.configured) return "On — no password stored"; + if (!d.lan_auth && d.configured) return "Off — password is stored"; + return "Off"; + } + + function refreshLanAuth() { + apiFetch("/api/auth/status").then(function (r) { return r.json(); }).then(function (d) { + var statusEl = document.getElementById("sys-lan-auth-status"); + if (statusEl) statusEl.textContent = lanStatusText(d); + var disableBtn = document.getElementById("sys-lan-auth-disable"); + if (disableBtn) disableBtn.disabled = !d.lan_auth; + }).catch(function () { + var statusEl = document.getElementById("sys-lan-auth-status"); + if (statusEl) statusEl.textContent = "Status unavailable"; + }); + } + + var enableBtn = document.getElementById("sys-lan-auth-enable"); + if (enableBtn) enableBtn.onclick = function () { + var pwEl = document.getElementById("sys-lan-auth-password"); + var cfEl = document.getElementById("sys-lan-auth-confirm"); + var pw = pwEl ? pwEl.value : ""; + var cf = cfEl ? cfEl.value : ""; + if (pw.length < 10) { + setLanMsg("Password must be at least 10 characters"); + return; + } + if (pw !== cf) { + setLanMsg("Password and confirm do not match"); + return; + } + enableBtn.disabled = true; + setLanMsg(""); + apiFetch("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: pw, enabled: true }), + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error(body.error || "enable failed"); + if (pwEl) pwEl.value = ""; + if (cfEl) cfEl.value = ""; + setLanMsg("LAN password is on"); + refreshLanAuth(); + }); + }).catch(function (err) { + setLanMsg(err.message || "enable failed"); + }).then(function () { + enableBtn.disabled = false; + }); + }; + + var disableBtn = document.getElementById("sys-lan-auth-disable"); + if (disableBtn) disableBtn.onclick = function () { + disableBtn.disabled = true; + setLanMsg(""); + apiFetch("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error(body.error || "disable failed"); + setLanMsg("LAN password is off"); + refreshLanAuth(); + }); + }).catch(function (err) { + setLanMsg(err.message || "disable failed"); + disableBtn.disabled = false; + }); + }; + refresh(); refreshComponents(); + refreshLanAuth(); if (window._systemStatusTimer) clearInterval(window._systemStatusTimer); window._systemStatusTimer = setInterval(refresh, 5000); }, diff --git a/web/setup.html b/web/setup.html index 00343ab5..33f52a1c 100644 --- a/web/setup.html +++ b/web/setup.html @@ -828,6 +828,7 @@

Configuration saved!

dashboard component catalogue (ftw-energy-flow etc.) — dead weight on the first setup paint. --> +