From b8b3493bb7019833c7894422ff148f488a3d39b9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 10:00:56 +0000 Subject: [PATCH] Enable admin password creation and reset --- internal/server/account_contract_test.go | 96 ++++++++++++++++++++++- internal/server/accounts.go | 55 +++++++++++-- internal/server/auth_e2e_test.go | 28 +++++-- internal/server/recovery_contract_test.go | 3 +- internal/store/account_model.go | 47 +++++++++++ 5 files changed, 212 insertions(+), 17 deletions(-) diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index e0d11f6..429d519 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/url" + "strings" "testing" "agent-system-backend/internal/store" @@ -113,7 +114,6 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { h := srv.Handler() adminCookie := bootAdmin(t, h, st) for _, body := range []string{ - `{"owner":"blocked-password","password":"old-pass-9"}`, `{"owner":"blocked-admin","isAdmin":true}`, } { if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest { @@ -152,3 +152,97 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { t.Fatalf("state/profile after disable: %+v %v", account, err) } } + +func TestAdminPasswordCreateAndResetHTTP(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + + created := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"},"password":"alice-initial-9"}`, admin) + if created.Code != http.StatusOK { + t.Fatalf("password create: %d %s", created.Code, created.Body.String()) + } + if strings.Contains(created.Body.String(), "alice-initial-9") { + t.Fatal("supplied password was echoed") + } + var createdBody struct { + UserID string `json:"userId"` + PasswordSet bool `json:"passwordSet"` + MustChangePassword bool `json:"mustChangePassword"` + InitialPassword string `json:"initialPassword"` + } + if err := json.Unmarshal(created.Body.Bytes(), &createdBody); err != nil { + t.Fatal(err) + } + if createdBody.UserID == "" || !createdBody.PasswordSet || !createdBody.MustChangePassword || createdBody.InitialPassword != "" { + t.Fatalf("supplied password projection: %+v", createdBody) + } + account, err := st.GetAccountByOwner("alice") + if err != nil || !account.HasPassword || !account.MustChangePassword || account.InitializationState != store.InitializationInitialized { + t.Fatalf("stored account state: %+v %v", account, err) + } + if state, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok || !state.MustChangePassword { + t.Fatalf("supplied password login: ok=%v state=%+v", ok, state) + } + + generated := do(h, "POST", "/api/admin/users", `{"owner":"bob"}`, admin) + if generated.Code != http.StatusOK { + t.Fatalf("generated password create: %d %s", generated.Code, generated.Body.String()) + } + var generatedBody struct { + InitialPassword string `json:"initialPassword"` + MustChangePassword bool `json:"mustChangePassword"` + } + if err := json.Unmarshal(generated.Body.Bytes(), &generatedBody); err != nil { + t.Fatal(err) + } + if generatedBody.InitialPassword == "" || !generatedBody.MustChangePassword { + t.Fatalf("generated password projection: %+v", generatedBody) + } + if state, ok := st.VerifyLogin("bob", generatedBody.InitialPassword); !ok || !state.MustChangePassword { + t.Fatalf("generated password login: ok=%v state=%+v", ok, state) + } + + login := do(h, "POST", "/api/auth/login", `{"owner":"alice","password":"alice-initial-9"}`, "") + if login.Code != http.StatusOK { + t.Fatalf("alice login: %d %s", login.Code, login.Body.String()) + } + aliceSession := cookieOf(login) + key, _, err := st.IssueLoginKey("alice", store.LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + if rejected := do(h, "POST", "/api/admin/users/alice/password", `{"password":"client-supplied"}`, admin); rejected.Code != http.StatusBadRequest { + t.Fatalf("client reset password accepted: %d %s", rejected.Code, rejected.Body.String()) + } + if _, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok { + t.Fatal("rejected reset changed the password") + } + + reset := do(h, "POST", "/api/admin/users/alice/password", `{}`, admin) + if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String()) + } + var resetBody struct { + TemporaryPassword string `json:"temporaryPassword"` + MustChangePassword bool `json:"mustChangePassword"` + } + if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil { + t.Fatal(err) + } + if resetBody.TemporaryPassword == "" || resetBody.TemporaryPassword == "alice-initial-9" || !resetBody.MustChangePassword { + t.Fatalf("reset projection: %+v", resetBody) + } + if rec := do(h, "GET", "/api/me", "", aliceSession); rec.Code != http.StatusUnauthorized { + t.Fatalf("reset did not revoke session: %d %s", rec.Code, rec.Body.String()) + } + if _, ok := st.VerifyLogin("alice", "alice-initial-9"); ok { + t.Fatal("old password remained usable after reset") + } + if state, ok := st.VerifyLogin("alice", resetBody.TemporaryPassword); !ok || !state.MustChangePassword { + t.Fatalf("temporary password login: ok=%v state=%+v", ok, state) + } + if _, _, err := st.RedeemLoginKey(key); err == nil { + t.Fatal("admin reset left unused login key redeemable") + } +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 1b0b734..8e68ddc 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -324,8 +324,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { return } var req struct { - Owner string `json:"owner"` - Profile store.AccountProfile `json:"profile"` + Owner string `json:"owner"` + Profile store.AccountProfile `json:"profile"` + Password *string `json:"password"` } if err := decodeStrictJSON(r, &req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -336,12 +337,31 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest) return } - account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile) + password := RandomPassword() + generated := req.Password == nil + if req.Password != nil { + password = *req.Password + if err := validPassword(password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + hash, err := store.HashPassword(password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + account, err := s.st.CreateAdminPasswordAccount(actor, req.Owner, req.Profile, hash) if err != nil { writeStoreErr(w, err) return } - writeJSON(w, http.StatusOK, accountProfileJSON(account)) + out := accountProfileJSON(account) + if generated { + w.Header().Set("Cache-Control", "no-store") + out["initialPassword"] = password + } + writeJSON(w, http.StatusOK, out) } func accountProfileJSON(account store.Account) map[string]any { @@ -349,7 +369,8 @@ func accountProfileJSON(account store.Account) map[string]any { "userId": account.ID, "owner": account.Owner, "profile": account.Profile, "profileVersion": account.ProfileVersion, "enabled": account.Enabled, "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, - "passwordSet": account.HasPassword, "initializationState": account.InitializationState, + "passwordSet": account.HasPassword, "mustChangePassword": account.MustChangePassword, + "initializationState": account.InitializationState, "initializationGeneration": account.InitializationGeneration, } } @@ -466,9 +487,29 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { // 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 // 登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); ok { - http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone) + actor, ok := s.requireAdmin(w, r) + if !ok { + return } + var req struct{} + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + password := RandomPassword() + hash, err := store.HashPassword(password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := s.st.AdminResetUserPassword(r.PathValue("owner"), hash, actor, "admin-http"); err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{ + "owner": r.PathValue("owner"), "temporaryPassword": password, "mustChangePassword": true, + }) } // ---- 管理员:一次性登录 Key(恢复流程主入口)---- diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 91d2a22..c3c2e34 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -423,19 +423,33 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("non-admin create must 403: %d", rec.Code) } - // 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。 - if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone { - t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String()) + // 管理员重置总是生成临时密码,销毁 alice 的旧会话并强制首登改密。 + reset := session("POST", "/api/admin/users/alice/password", `{}`) + if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String()) } - if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK { - t.Fatalf("closed reset must preserve alice session: %d", rec.Code) + var resetBody struct { + TemporaryPassword string `json:"temporaryPassword"` + } + if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil || resetBody.TemporaryPassword == "" { + t.Fatalf("admin reset response: %+v %v", resetBody, err) + } + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("reset must revoke alice session: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("old password remained usable after reset: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"`+resetBody.TemporaryPassword+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("temporary password login: %d %s", rec.Code, rec.Body.String()) } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 diff --git a/internal/server/recovery_contract_test.go b/internal/server/recovery_contract_test.go index ad983bf..9918ffc 100644 --- a/internal/server/recovery_contract_test.go +++ b/internal/server/recovery_contract_test.go @@ -99,7 +99,7 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } -func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) { +func TestLegacyAdministratorLoginKeyIssuanceEntrancesAreClosed(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() admin := bootAdmin(t, h, st) @@ -114,7 +114,6 @@ func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) { t.Fatal(err) } for _, route := range []struct{ path, body string }{ - {"/api/admin/users/victim/password", `{"password":"attacker-pass"}`}, {"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`}, {"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`}, } { diff --git a/internal/store/account_model.go b/internal/store/account_model.go index 7c81fb6..cd945c9 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -299,6 +299,53 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr return a, nil } +// CreateAdminPasswordAccount creates an ordinary user with an administrator- +// supplied password hash. The account row, hash, and audit entries commit +// together so a failed write cannot leave a credential without its account. +func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) { + if passwordHash == "" { + return Account{}, ErrConflict + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return Account{}, ErrAccountForbidden + } + a, err := s.insertPendingAccountTx(tx, owner, profile) + if err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, a.ID); err != nil { + return Account{}, err + } + a, err = s.accountByOwner(tx, owner) + if err != nil { + return Account{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_password", a.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return a, nil +} + // insertPendingAccountTx lets the trusted local initializer create and grant in // one transaction without impersonating an administrator. The caller owns its // authorization, audit and commit; this helper always inserts an ordinary user.