Land admin password management and interpreter path fix pinned by Dev #211

Merged
weichen merged 4 commits from fix/execenv-python-sandbox-path into main 2026-10-18 09:36:56 +00:00
5 changed files with 214 additions and 20 deletions
+95 -1
View File
@@ -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")
}
}
+50 -10
View File
@@ -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,
}
}
@@ -462,13 +483,32 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
// 登录 Key、吊销全部会话、审计。
// resetAdminUserPassword:管理员生成随机临时密码。单事务: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(恢复流程主入口)----
+21 -7
View File
@@ -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 身份)。
+1 -2
View File
@@ -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"}`},
} {
+47
View File
@@ -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.