hub v0.54.0: change operator login password from the Configuration UI

Adds a "Login password" card on /configuration. The password was previously
settable only via the hub-config ConfigMap (auth.password_hash) + redeploy.

- store: hub_settings key operator_password_hash + Get/SetOperatorPasswordHash
- server: passwordHash field -> configPasswordHash (seed); new
  effectivePasswordHash() (DB override wins, else seed) is now the single
  source for the CSRF gate, RequireAuth, and handleLogin
- POST /configuration/password (handleChangePassword): requires current
  password, 8-72 byte new + confirm, bcrypt cost 10, persists DB override;
  existing sessions kept valid; ConfigMap stays the break-glass reset path
- UI: current/new/confirm form + inline mismatch pre-check + 6 flashes
- tests + red-proofs: override precedence, happy-path via handleLogin,
  wrong-current rejection, mismatch/too-short/no-op, template render
- docs: CHANGELOG, README (auth+config), REUSE, REPORT

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbMm4T7Ayzs1unB9pN6Uqd
@
This commit is contained in:
2026-07-13 22:46:49 +02:00
parent ed271fadb2
commit a1d045079f
8 changed files with 440 additions and 32 deletions
+93 -9
View File
@@ -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)
}