Merge branch 'main' into feature/u8-sqlite-instance-guard-r2
This commit is contained in:
@@ -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
@@ -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(恢复流程主入口)----
|
||||
|
||||
@@ -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 身份)。
|
||||
|
||||
@@ -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"}`},
|
||||
} {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user