a1d045079f
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 @
185 lines
7.6 KiB
Go
185 lines
7.6 KiB
Go
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
|
|
}
|