Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auth-enforcement.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/localauth.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> (operator)
# # ftw user add -role viewer <name>
# # 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:
Expand Down
27 changes: 27 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 <name>`",
"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(),
Expand Down Expand Up @@ -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)
Expand Down
157 changes: 157 additions & 0 deletions go/cmd/ftw/user_cli.go
Original file line number Diff line number Diff line change
@@ -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 <add|list|disable|enable|delete|passwd>`,
// 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] <add|list|disable|enable|delete|passwd> [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 <name>")
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)
}
3 changes: 2 additions & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
21 changes: 20 additions & 1 deletion go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading