diff --git a/REPORT.md b/REPORT.md index 2a09f1c..ad9ed0e 100644 --- a/REPORT.md +++ b/REPORT.md @@ -2,26 +2,58 @@ > **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md). -## Controller-driven escrow ceremony — felhom.eu leg (installer + docs) — 2026-07-13 +## Hub v0.54.0 — operator login password changeable from the UI — 2026-07-13 -Companion commits to felhom-agent **v0.88.0** (`1c3a3ef`) + felhom-controller **v0.127.0** -(`08a966b`) — the customer-facing recovery-code wizard. felhom.eu commit `375cb08`. +### What & why -- **host-install v1.16.0:** the `FELHOM_ESCROW` sudoers alias ships via the EXISTING canonical - sudoers fetch (no new step; header documents it). Hub `hostInstallVersion` synced to 1.16.0 in - the same commit (`hostinstall_gates.py` green; hub green gate run; **no hub deploy** — the - const rides the next hub train, display-only lag). -- **RUNBOOK-escrow-ceremony.md rewritten:** the controller wizard is the PRIMARY path; the CLI is - the operator fallback (text mode unchanged); **F1 threat-model paragraph** (R transits the CF - tunnel once at reveal — accepted 2026-07-13, same trust class as claim code/login password; - agent→controller leg never leaves the box; LAN-direct delivery PARKED); stale-blob warning + - supersede/void semantics documented; CLI staged-secret rule spelled out (a CLI run without the - staged secret mints a hash-less blob → the new Scenario-F warning). -- Deploy + live validation evidence: felhom-agent/REPORT.md + felhom-controller/REPORT.md - (agents 0.88.0 on demo host + drill VM, 63/63 capabilities; controllers 0.127.0 on both guests; - Scenario F fired live on BOTH boxes' hash-less blobs; the drill blob repaired — - `restic_pw_sha256` now covers the local password; one-shot claim + 410 proven endpoint-exact). -- **Operator follow-ups:** (1) one supervised wizard pass with Viktor's drill login (the full - browser leg incl. re-auth + reveal — CC cannot type the customer-owned password), ideally also - on the demo box to clear ITS legacy stale warning; (2) publish agent 0.88.0 + vouch in the - Day-0 manifest at the next publish train (deployed hosts got direct deploys). +The hub login password could previously be changed **only** by editing `auth.password_hash` in the +`hub-config` ConfigMap and redeploying — no in-app path existed (operator hit this wall). Added a +**Configuration → Login password** card that changes the password at runtime, persisted in the DB, +with the ConfigMap kept as the break-glass reset path. + +### Design (matches the controller-version-floor precedence pattern) + +- **Store** (`internal/store/store.go`): new `hub_settings` key `operator_password_hash` with + `GetOperatorPasswordHash()` / `SetOperatorPasswordHash()` (thin wrappers over the existing + `getSetting`/`setSetting`). No schema change. +- **Server** (`internal/web/server.go`): the static `Server.passwordHash` field is renamed + `configPasswordHash` (the hub.yaml SEED). New `effectivePasswordHash()` = **DB override wins, else + config seed** — and it is now the single source for every auth check (CSRF gate, `RequireAuth` + session + Basic-Auth paths, `handleLogin`). +- **Handler** `POST /configuration/password` (`handleChangePassword`): requires the current password + (verified against the effective hash), new password 8–72 bytes, matching confirmation, rejects a + no-op. On success bcrypts (cost 10) and persists the override. Existing sessions stay valid; CSRF + enforced by the central `ServeHTTP` gate; no secret logged. +- **UI** (`templates/configuration.html`): current/new/confirm fields, inline client-side mismatch + pre-check, six flash outcomes. + +### Recovery posture (operator's explicit choices) + +- Requires the **current** password to change it (blocks a walk-up attacker on an open session). +- ConfigMap `auth.password_hash` remains the **break-glass fallback** — blank the DB row (or edit the + manifest + redeploy) to reset a forgotten password. + +### Tests & red-proofs (`internal/web/change_password_test.go`) + +- `TestEffectivePasswordHash_DBOverrideWins` — override wins; clearing falls back to the seed. +- `TestChangePassword_HappyPath` — end-to-end through `handleLogin` (new works, old dead). +- `TestChangePassword_WrongCurrentRejected` — security anchor: no override written. +- `TestChangePassword_ValidationRejections` — mismatch / too-short / no-op refused, no override. +- `TestConfigurationPage_RendersPasswordCard` — form renders through the production template. +- Red-proofs verified: dropping the current-password check → WrongCurrentRejected fails; breaking the + override precedence → precedence + happy-path login assertions fail. + +### Gates + +- `go build ./... && go vet ./... && go test ./...` (hub): green. +- `python scripts/hub_confirm_gate.py`: green (no native confirm/prompt in templates). + +### Docs + +- `hub/CHANGELOG.md` (v0.54.0), `hub/README.md` (Authentication + Configuration sections), + `REUSE.md` (`effectivePasswordHash`, `Get/SetOperatorPasswordHash` rows). + +### Deploy + +Hub image built + pushed as `v0.54.0`; `manifests/hub.yaml` tag bumped; ArgoCD synced; verified live +on `hub.felhom.eu`. diff --git a/REUSE.md b/REUSE.md index 5bb7acc..433fc61 100644 --- a/REUSE.md +++ b/REUSE.md @@ -43,7 +43,8 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| -| `(*Server).RequireAuth` | hub/internal/web/server.go (~L359) | `(next http.Handler) http.Handler` | Session-cookie OR Basic-auth gate for all web routes | Empty passwordHash disables auth entirely (dev mode). Browsers → /login redirect; JSON-ish requests → 401. | +| `(*Server).RequireAuth` | hub/internal/web/server.go (~L359) | `(next http.Handler) http.Handler` | Session-cookie OR Basic-auth gate for all web routes | Empty effective hash disables auth entirely (dev mode). Browsers → /login redirect; JSON-ish requests → 401. | +| `(*Server).effectivePasswordHash` | hub/internal/web/server.go (~L118) | `() string` | THE single source for the operator login hash — call this, never read `configPasswordHash` | Precedence: `hub_settings` DB override (set via Configuration UI) wins, else the hub.yaml `auth.password_hash` seed. ConfigMap = break-glass reset. Change it via `POST /configuration/password` (`handleChangePassword`). | | `(*Server).validateCSRF` | hub/internal/web/server.go (~L446) | `(r) bool` | CSRF check — enforced centrally in `web.ServeHTTP` for every non-GET | No session cookie → returns true (Basic-auth path is exempt). New POST routes get CSRF for free; forms MUST embed `csrfField`. | | `(*Server).csrfField` | hub/internal/web/server.go (~L483) | `(r) template.HTML` | Hidden `_csrf` input for HTML forms | Pass into template data on every form-rendering handler. | | `(*Server).CleanupSessions` | hub/internal/web/server.go (~L110) | `(ctx)` — goroutine | Expired-session sweeper | Started once from main; 15-min tick. | @@ -77,6 +78,7 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| | `(*Store).GetArtifactManifest` / `SetArtifactManifest` | hub/internal/store/store.go (~L933 / ~L944) | `() ArtifactManifest` / `(m) error` | The DB-backed (hub_settings) Day-0 artifact record | This is the checksum TRUST ROOT the host-bootstrap verifies against — distinct from Gitea, which only stores bytes. | +| `(*Store).GetOperatorPasswordHash` / `SetOperatorPasswordHash` | hub/internal/store/store.go (~L1350) | `() string` / `(hash) error` | The DB-backed (hub_settings) operator login password override | Read via `Server.effectivePasswordHash()`, not directly. "" = no override (config seed authoritative). Store the bcrypt hash, never the plaintext. | | `(*Server).handleSetArtifacts` + `resolveArtifactSHA` | hub/internal/web/configs.go (~L644 / ~L680) | `POST /configuration/artifacts` | Operator UI to vouch artifact versions | With a Gitea client the sha is fetched AUTHORITATIVELY (submitted sha ignored); fetch failure refuses the save. Manual sha only in the no-creds fallback. | | `(*gitea.Client).ListVersions` / `FileSHA256` | hub/internal/gitea/gitea.go (~L47 / ~L72) | `(ctx, pkg) ([]string, error)` / `(ctx, pkg, ver, file)` | Read-only Gitea generic-package metadata | sha comes from package metadata — artifact bytes are never downloaded. Newest-semver-first sort. | | `(*Server).artifactChoices` | hub/internal/web/server.go (~L155) | `(ctx, pkg, file) []artifactChoice` | Version+sha dropdown data | nil Gitea client / unreachable → nil → UI degrades to manual entry. One bad version drops itself, not the list. | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 8902a0c..512d977 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,32 @@ # Felhom Hub — Changelog +## v0.54.0 — operator login password changeable from the UI (2026-07-13) + +The hub login password was previously settable ONLY by editing the `auth.password_hash` field in +the `hub-config` ConfigMap and redeploying — there was no in-app way to change it. Added a +**"Login password"** card on the Configuration page. + +- **DB-override precedence** (same pattern as the controller-version floor). New `hub_settings` key + `operator_password_hash` (store: `Get/SetOperatorPasswordHash`). The web server no longer reads a + static field for auth: `Server.passwordHash` is renamed `configPasswordHash` (the hub.yaml SEED) + and every auth check — the CSRF gate, `RequireAuth` session/basic-auth paths, and `handleLogin` — + now goes through `effectivePasswordHash()` = **DB override wins, else config seed**. The ConfigMap + value stays the **break-glass fallback**: blank the DB row (or edit the manifest + redeploy) to + reset a lost password. +- **`POST /configuration/password`** (`handleChangePassword`): requires the **current** password + (verified against the effective hash), a new password of 8–72 bytes, and a matching confirmation; + rejects a no-op change. On success it bcrypts the new password (cost 10, matching the seed) and + persists the override. Existing sessions are intentionally kept valid — only the next sign-in and + Basic-Auth use the new hash. CSRF-enforced (existing `ServeHTTP` gate); no secret is ever logged. +- **UI**: change-password card on `configuration.html` with current/new/confirm fields, inline + client-side mismatch pre-check, and six flash outcomes (`pw_changed`, `pw_current_wrong`, + `pw_too_short`, `pw_too_long`, `pw_mismatch`, `pw_unchanged`). +- **Tests + red-proofs** (`change_password_test.go`): override-wins precedence, happy-path + end-to-end through `handleLogin` (new works, old dead), wrong-current rejection (security anchor), + mismatch/too-short/no-op rejections, and template render. Red-proofs verified — dropping the + current-password check writes the override anyway (WrongCurrentRejected fails); breaking the + override precedence kills both the precedence and happy-path login assertions. + ## (unreleased) hostInstallVersion 1.16.0 (2026-07-13) Display-const bump only, keeping `scripts/hostinstall_gates.py` green with the installer's diff --git a/hub/README.md b/hub/README.md index 9f84ad3..67d8e77 100644 --- a/hub/README.md +++ b/hub/README.md @@ -215,6 +215,7 @@ Protected by bcrypt password + session cookie (7-day expiry). - Cookie attributes: `SameSite=Lax`, `Secure` (when TLS), `HttpOnly`, 7-day `Max-Age`. - `RequireAuth` middleware validates the session token with `subtle.ConstantTimeCompare` and redirects to `/login` on failure. - `CleanupSessions(ctx)` goroutine runs hourly to purge expired sessions. +- **Login password source (v0.54.0)** — the bcrypt hash checked at login comes from `effectivePasswordHash()`: a `hub_settings.operator_password_hash` DB override (set via **Configuration → Login password**) wins, otherwise the `auth.password_hash` seed from `hub.yaml`. The ConfigMap value is the break-glass reset path (blank the DB row / edit the manifest + redeploy). Changing the password (`POST /configuration/password`) requires the current password and leaves existing sessions valid. ### CSRF Protection (`internal/web/server.go`) @@ -313,7 +314,10 @@ A manual "Refresh Assets from Image" button is available on the **Configuration* ```yaml # hub.yaml auth: - password_hash: "" # bcrypt hash for dashboard login (empty = no auth) + password_hash: "" # bcrypt SEED for dashboard login (empty = no auth). Since v0.54.0 this + # is only the fallback: a hub_settings DB override set via + # Configuration → Login password wins. This value stays the break-glass + # reset path (blank the DB row / edit here + redeploy to recover a lost pw). api: report_api_key: "" # Bearer token for API auth diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 815fec5..0ce2948 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -1347,6 +1347,22 @@ const ( settingArtifactMinAgent = "artifact_min_agent" ) +// settingOperatorPasswordHash is the hub_settings key for the operator login password bcrypt hash, +// set via the Configuration UI (v0.54.0). When present it OVERRIDES the config/env seed +// (auth.password_hash in hub.yaml) — the same DB-override-wins precedence as the controller-version +// floor. The ConfigMap value stays the break-glass fallback: clear this row (or edit the manifest + +// redeploy) to reset a lost password. +const settingOperatorPasswordHash = "operator_password_hash" + +// GetOperatorPasswordHash returns the UI-set operator password bcrypt hash, or "" when none has been +// set (the config/env seed is then authoritative). +func (s *Store) GetOperatorPasswordHash() string { return s.getSetting(settingOperatorPasswordHash) } + +// SetOperatorPasswordHash persists a new operator password bcrypt hash set via the Configuration UI. +func (s *Store) SetOperatorPasswordHash(hash string) error { + return s.setSetting(settingOperatorPasswordHash, hash) +} + // getSetting reads a single hub_settings value ("" if the row is absent). func (s *Store) getSetting(key string) string { var v string diff --git a/hub/internal/web/change_password_test.go b/hub/internal/web/change_password_test.go new file mode 100644 index 0000000..ee64bc3 --- /dev/null +++ b/hub/internal/web/change_password_test.go @@ -0,0 +1,184 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "golang.org/x/crypto/bcrypt" +) + +// serverWithPassword builds a hub server whose CONFIG seed (hub.yaml auth.password_hash) is the +// bcrypt hash of plaintext — mirroring a freshly-deployed hub with no UI override yet. +func serverWithPassword(t *testing.T, plaintext string) (*Server, *store.Store) { + t.Helper() + st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { st.Close() }) + hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("seed hash: %v", err) + } + s := New(st, string(hash), "", "test", 30*time.Minute, log.New(io.Discard, "", 0)) + return s, st +} + +func postChangePassword(t *testing.T, s *Server, current, next, confirm string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{ + "current_password": {current}, + "new_password": {next}, + "confirm_password": {confirm}, + } + r := httptest.NewRequest(http.MethodPost, "/configuration/password", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleChangePassword(w, r) + return w +} + +// TestEffectivePasswordHash_DBOverrideWins locks the precedence the whole feature rests on: a +// hub_settings override wins over the config/env seed, and clearing it falls back to the seed. +// Companion red-proof: make effectivePasswordHash return s.configPasswordHash unconditionally → the +// "DB override wins" assertion fails. +func TestEffectivePasswordHash_DBOverrideWins(t *testing.T) { + s, st := serverWithPassword(t, "seed-password") + + // No override yet → the seed is authoritative. + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) != nil { + t.Fatal("with no DB override the config seed must be effective") + } + + dbHash, _ := bcrypt.GenerateFromPassword([]byte("db-password"), bcrypt.DefaultCost) + if err := st.SetOperatorPasswordHash(string(dbHash)); err != nil { + t.Fatal(err) + } + // Override present → it wins; the seed must no longer authenticate. + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("db-password")) != nil { + t.Error("DB override must win over the config seed") + } + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) == nil { + t.Error("the config seed must NOT authenticate once a DB override is set") + } + + // Clearing the override → fall back to the seed (the break-glass reset path). + if err := st.SetOperatorPasswordHash(""); err != nil { + t.Fatal(err) + } + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) != nil { + t.Error("clearing the DB override must fall back to the config seed") + } +} + +// TestChangePassword_HappyPath: a correct current password + matching confirmation persists a DB +// override, the new password authenticates end-to-end (through handleLogin), and the OLD password is +// dead. Companion red-proof: drop the SetOperatorPasswordHash call → the new password never takes +// effect and the login assertion fails. +func TestChangePassword_HappyPath(t *testing.T) { + s, st := serverWithPassword(t, "old-password") + + w := postChangePassword(t, s, "old-password", "brand-new-password", "brand-new-password") + if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=pw_changed") { + t.Fatalf("expected redirect to pw_changed, got %d %q", w.Code, w.Header().Get("Location")) + } + + // The override is persisted and the new password is effective; the old one is gone. + if st.GetOperatorPasswordHash() == "" { + t.Fatal("change must persist a hub_settings override") + } + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("brand-new-password")) != nil { + t.Error("new password must be effective after change") + } + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("old-password")) == nil { + t.Error("old password must stop working after change") + } + + // End-to-end: /login now accepts the new password and rejects the old. + if code := loginStatus(t, s, "brand-new-password"); code != http.StatusSeeOther { + t.Errorf("login with new password: got %d, want 303", code) + } + if code := loginStatus(t, s, "old-password"); code != http.StatusUnauthorized { + t.Errorf("login with old password: got %d, want 401", code) + } +} + +// TestChangePassword_WrongCurrentRejected is the security anchor: an attacker on an open session (or a +// typo) cannot set a new password without the current one. Companion red-proof: remove the +// current-password bcrypt check in handleChangePassword → this test fails (the override gets written). +func TestChangePassword_WrongCurrentRejected(t *testing.T) { + s, st := serverWithPassword(t, "old-password") + + w := postChangePassword(t, s, "WRONG", "brand-new-password", "brand-new-password") + if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=pw_current_wrong") { + t.Fatalf("expected redirect to pw_current_wrong, got %d %q", w.Code, w.Header().Get("Location")) + } + if st.GetOperatorPasswordHash() != "" { + t.Error("a wrong current password must NOT write an override") + } + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("old-password")) != nil { + t.Error("the original password must still be effective after a rejected change") + } +} + +// TestChangePassword_ValidationRejections: mismatch, too-short, and no-op changes are all refused and +// leave the password untouched. +func TestChangePassword_ValidationRejections(t *testing.T) { + cases := []struct { + name, current, next, confirm, wantFlash string + }{ + {"mismatch", "old-password", "brand-new-password", "different-confirm", "pw_mismatch"}, + {"too_short", "old-password", "short", "short", "pw_too_short"}, + {"unchanged", "old-password", "old-password", "old-password", "pw_unchanged"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s, st := serverWithPassword(t, "old-password") + w := postChangePassword(t, s, c.current, c.next, c.confirm) + if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash="+c.wantFlash) { + t.Fatalf("expected redirect to %s, got %d %q", c.wantFlash, w.Code, w.Header().Get("Location")) + } + if st.GetOperatorPasswordHash() != "" { + t.Errorf("%s: rejected change must NOT write an override", c.name) + } + }) + } +} + +// TestConfigurationPage_RendersPasswordCard: the change-password form renders through the production +// template with the three fields and the correct POST target. +func TestConfigurationPage_RendersPasswordCard(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/configuration", nil) + w := httptest.NewRecorder() + s.handleConfiguration(w, req) + if w.Code != http.StatusOK { + t.Fatalf("configuration page: %d", w.Code) + } + body := w.Body.String() + for _, want := range []string{`action="/configuration/password"`, `name="current_password"`, `name="new_password"`, `name="confirm_password"`} { + if !strings.Contains(body, want) { + t.Errorf("configuration page missing %q", want) + } + } +} + +// loginStatus drives handleLogin with a password and returns the HTTP status (303 = accepted, +// 401 = rejected). +func loginStatus(t *testing.T, s *Server, password string) int { + t.Helper() + form := url.Values{"password": {password}} + r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleLogin(w, r) + return w.Code +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 6ff52a4..f384029 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -49,8 +49,12 @@ type hubSession struct { // Server handles the dashboard web UI. type Server struct { store *store.Store - passwordHash string - apiKey string // report API key — used for controller callbacks + // configPasswordHash is the operator login password bcrypt hash SEEDED from hub.yaml + // (auth.password_hash) at startup. It is the fallback only — a hub_settings DB override set via + // the Configuration UI wins. Never read this field directly for an auth decision; call + // effectivePasswordHash(). + configPasswordHash string + apiKey string // report API key — used for controller callbacks version string logger *log.Logger templates *template.Template @@ -100,9 +104,9 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")) return &Server{ - store: store, - passwordHash: passwordHash, - apiKey: apiKey, + store: store, + configPasswordHash: passwordHash, + apiKey: apiKey, version: version, logger: logger, templates: tmpl, @@ -111,6 +115,18 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol } } +// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB +// override (hub_settings, via the Configuration page) when present, otherwise the config/env seed +// (auth.password_hash from hub.yaml). This is the SINGLE source of truth for every auth check — the +// DB override wins and the ConfigMap value is the break-glass fallback, mirroring the +// controller-version floor's precedence. Empty return = auth disabled (dev/test only). +func (s *Server) effectivePasswordHash() string { + if h := s.store.GetOperatorPasswordHash(); h != "" { + return h + } + return s.configPasswordHash +} + // CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx). func (s *Server) CleanupSessions(ctx context.Context) { ticker := time.NewTicker(15 * time.Minute) @@ -197,7 +213,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // CSRF protection for all state-changing requests (web routes only). // API routes (/api/v1/) are Bearer-token authenticated and exempt. if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { - if path != "/login" && s.passwordHash != "" { + if path != "/login" && s.effectivePasswordHash() != "" { if !s.validateCSRF(r) { s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr) http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden) @@ -399,6 +415,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + case path == "/configuration/password": + if r.Method == http.MethodPost { + s.handleChangePassword(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"): customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/delete") @@ -472,7 +494,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth if no password configured - if s.passwordHash == "" { + if s.effectivePasswordHash() == "" { next.ServeHTTP(w, r) return } @@ -496,7 +518,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { // Check basic auth (for programmatic/CLI access) _, password, ok := r.BasicAuth() - if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { + if ok && bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte(password)) == nil { next.ServeHTTP(w, r) return } @@ -514,7 +536,8 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { password := r.FormValue("password") - if s.passwordHash != "" && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { + effHash := s.effectivePasswordHash() + if effHash != "" && bcrypt.CompareHashAndPassword([]byte(effHash), []byte(password)) == nil { // Generate random session token b := make([]byte, 32) _, _ = rand.Read(b) @@ -786,3 +809,64 @@ func (s *Server) handleConfigurationAction(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, "/configuration", http.StatusSeeOther) } } + +// minOperatorPasswordLen is the minimum accepted new operator login password length (bytes). A low +// floor by design — this is the single operator's own login, not a customer-facing credential — but +// it stops fat-finger empties/typos from silently becoming the password. bcrypt caps input at 72 +// bytes, so that is the hard upper bound. +const minOperatorPasswordLen = 8 + +// handleChangePassword updates the operator login password from the Configuration page (v0.54.0). +// It requires the CURRENT password (verified against the effective hash — DB override → config seed), +// a new password of at least minOperatorPasswordLen bytes, and a matching confirmation. On success it +// bcrypts the new password (cost 10, matching the ConfigMap seed) and persists it to hub_settings, the +// DB override that wins over the hub.yaml seed. The ConfigMap value stays the break-glass fallback: +// blank the DB row (or edit the manifest + redeploy) to reset a lost password. Existing sessions are +// intentionally left valid — only the /login and Basic-Auth checks consult the new hash. CSRF is +// already enforced by ServeHTTP for this POST. +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + current := r.FormValue("current_password") + next := r.FormValue("new_password") + confirm := r.FormValue("confirm_password") + + // Verify the current password against the effective hash. Empty effective hash (auth disabled) + // also blocks the change — there is nothing to authenticate against. + if eff := s.effectivePasswordHash(); eff == "" || bcrypt.CompareHashAndPassword([]byte(eff), []byte(current)) != nil { + s.logger.Printf("[WARN] Change-password rejected: current password mismatch from %s", r.RemoteAddr) + http.Redirect(w, r, "/configuration?flash=pw_current_wrong", http.StatusSeeOther) + return + } + if len(next) < minOperatorPasswordLen { + http.Redirect(w, r, "/configuration?flash=pw_too_short", http.StatusSeeOther) + return + } + if len(next) > 72 { // bcrypt hard limit — reject up front for a friendly message + http.Redirect(w, r, "/configuration?flash=pw_too_long", http.StatusSeeOther) + return + } + if next != confirm { + http.Redirect(w, r, "/configuration?flash=pw_mismatch", http.StatusSeeOther) + return + } + if next == current { // no-op change — keep the flash honest + http.Redirect(w, r, "/configuration?flash=pw_unchanged", http.StatusSeeOther) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost) + if err != nil { + s.logger.Printf("[ERROR] Change-password: bcrypt generate failed: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if err := s.store.SetOperatorPasswordHash(string(hash)); err != nil { + s.logger.Printf("[ERROR] Change-password: persist failed: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] Operator login password changed via Configuration UI from %s", r.RemoteAddr) + http.Redirect(w, r, "/configuration?flash=pw_changed", http.StatusSeeOther) +} diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index a0fc932..d3afc16 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -47,6 +47,24 @@ {{if eq .Flash "artifact_sha_invalid"}}
+ The password for signing in to this hub UI. Changing it takes effect immediately
+ for the next sign-in — your current session stays logged in. Enter your current password to confirm.
+ If you ever lose it, the deployment ConfigMap (auth.password_hash) remains the reset path.
+