From a3b67ee8915007472c6c95e894b494909951388d Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 01:45:06 +0000 Subject: [PATCH] agent-exec transport 34e93ed2817e051b9b824abe27d8c08a/3eba67b6e84f --- cmd/agent-system/account_io_test.go | 14 ++---- cmd/agent-system/main.go | 35 ++++---------- cmd/agent-system/user_contract_test.go | 2 +- internal/server/accounts.go | 6 +-- internal/server/one_time_password_test.go | 58 ++++++++++++++++++++--- internal/store/auth.go | 56 ++++++++++++++++++++++ internal/store/one_time_password.go | 7 +-- 7 files changed, 126 insertions(+), 52 deletions(-) diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go index 7601a0a..9306498 100644 --- a/cmd/agent-system/account_io_test.go +++ b/cmd/agent-system/account_io_test.go @@ -43,15 +43,14 @@ func cliStore(t *testing.T) (string, *store.Store) { } return dir, s } -func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { +func TestCreateAndInitializeAccountDoNotRequireCredentialOutput(t *testing.T) { for _, sub := range []string{"create", "initialize-account"} { t.Run(sub, func(t *testing.T) { dir, s := cliStore(t) if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil { t.Fatal(e) } - out := filepath.Join(t.TempDir(), "secret") - args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out} + args := []string{sub, "--data", dir, "--owner", "ada"} if sub == "initialize-account" { a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"}) if e != nil { @@ -61,14 +60,7 @@ func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { } rc, _ := accountCLI(t, args...) if rc != 0 { - t.Fatalf("credential command failed: %d", rc) - } - info, e := os.Stat(out) - if e != nil { - t.Fatal("credential output file missing") - } - if info.Mode().Perm() != 0600 { - t.Fatal("credential output permissions are not private") + t.Fatalf("command failed without credential output file: %d", rc) } }) } diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 8160258..d10cfe7 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "strings" - "time" "agent-system-backend/internal/app" "agent-system-backend/internal/config" @@ -448,7 +447,7 @@ func runUser(args []string) int { name := fs.String("name", "", "显示名") password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)") passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码") - outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)") + outFile := fs.String("out-file", "", "凭据交付文件(仅历史兼容子命令可用)") instanceID := fs.String("instance-id", "", "严格核验库内服务身份") admin := fs.Bool("admin", false, "管理员(现已拒绝:仅超级管理员可在服务端任命管理员)") role := fs.String("role", "user", "角色 user|admin(admin 现已拒绝)") @@ -458,7 +457,7 @@ func runUser(args []string) int { fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)") fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)") keyID := fs.String("id", "", "revoke-login-key 凭据编号") - out := fs.String("out", "", "凭据保护交付文件(0600 JSON)") + out := fs.String("out", "", "凭据交付文件(JSON;仅历史兼容子命令可用)") fs.Parse(args[1:]) if sub == "reset-password" || sub == "issue-login-key" { fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") @@ -493,24 +492,14 @@ func runUser(args []string) int { } *dsn = resolvedDSN *password = resolvedPassword - credentialAction := sub == "create" || sub == "initialize-account" - if credentialAction && *outFile == "" && *out == "" { - fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery") + if (sub == "create" || sub == "initialize-account") && (*outFile != "" || *out != "") { + fmt.Fprintln(os.Stderr, "user:", "local activation redemption is removed; do not use --out/--out-file. Ask an administrator to issue POST /api/admin/users/{owner}/one-time-password") return 2 } - if !credentialAction && (*outFile != "" || *out != "") { + if sub != "create" && sub != "initialize-account" && (*outFile != "" || *out != "") { fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据") return 2 } - var delivery *credentialOutput - if credentialAction { - delivery, err = reserveCredentialOutput(*outFile, *out) - if err != nil { - fmt.Fprintln(os.Stderr, "user:", err) - return 2 - } - defer delivery.abort() - } switch sub { case "create": if *owner == "" { @@ -526,15 +515,12 @@ func runUser(args []string) int { fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role) return 2 } - pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) + _, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) if err != nil { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if err := delivery.deliver("login-key", *owner, pw, nil); err != nil { - fmt.Fprintln(os.Stderr, "user create:", err) - return 1 - } + fmt.Fprintf(os.Stderr, "owner=%s created as pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", *owner, *owner) fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) return 0 case "initialize-account": @@ -542,15 +528,12 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填") return 2 } - plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) + _, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) if err != nil { fmt.Fprintln(os.Stderr, "user initialize-account:", err) return 1 } - if err := delivery.deliver("activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { - fmt.Fprintln(os.Stderr, "user initialize-account:", err) - return 1 - } + fmt.Fprintf(os.Stderr, "owner=%s remains pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", targetOwner, targetOwner) return 0 case "reset-password": fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go index 9c8fb0d..5a5131b 100644 --- a/cmd/agent-system/user_contract_test.go +++ b/cmd/agent-system/user_contract_test.go @@ -69,7 +69,7 @@ func TestUserCLIAdministratorAppointmentLeavesExistingStoreUnchanged(t *testing. if _, err := s.GetUser("mallory"); err == nil { t.Fatal("rejected CLI appointment created an account") } - rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada", "--out-file", filepath.Join(t.TempDir(), "ada")) + rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada") if rc != 0 { t.Fatalf("ordinary local create failed: %d", rc) } diff --git a/internal/server/accounts.go b/internal/server/accounts.go index c904a7c..62cbab5 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -178,7 +178,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { scope = store.SessionScopeSetPasswordOnly maxAge = 10 * 60 } - token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver) + token, err := s.st.CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope, time.Duration(maxAge)*time.Second, ver) if err != nil { if errors.Is(err, store.ErrConflict) { http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized) @@ -187,10 +187,6 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.InvalidateUnusedOneTimePasswords(owner); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } setSessionCookie(w, r, token, maxAge) out := authStateJSON(st) out["sessionScope"] = scope diff --git a/internal/server/one_time_password_test.go b/internal/server/one_time_password_test.go index 5b0b614..9385787 100644 --- a/internal/server/one_time_password_test.go +++ b/internal/server/one_time_password_test.go @@ -108,6 +108,29 @@ func TestNormalLoginInvalidatesAdminOneTimePasswordWithoutForcedChange(t *testin } } +func TestNormalLoginRevokesExistingOneTimePasswordRestrictedSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + otpUser(t, st, "ivy", "ivy-normal-1") + otp := issueOTP(t, h, admin, "ivy") + restrictedLogin := do(h, "POST", "/api/auth/login", loginBody("ivy", otp), "") + if restrictedLogin.Code != http.StatusOK || !strings.Contains(restrictedLogin.Body.String(), `"passwordChangeMode":"one_time_password"`) { + t.Fatalf("otp restricted login: %d %s", restrictedLogin.Code, restrictedLogin.Body.String()) + } + restricted := cookieOf(restrictedLogin) + normal := do(h, "POST", "/api/auth/login", loginBody("ivy", "ivy-normal-1"), "") + if normal.Code != http.StatusOK || !strings.Contains(normal.Body.String(), `"sessionScope":"full"`) { + t.Fatalf("normal login: %d %s", normal.Code, normal.Body.String()) + } + if rec := do(h, "POST", "/api/auth/password", `{"next":"ivy-reset-2"}`, restricted); rec.Code != http.StatusUnauthorized { + t.Fatalf("restricted session survived normal login for password endpoint: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusUnauthorized { + t.Fatalf("restricted session survived normal login for me endpoint: %d %s", rec.Code, rec.Body.String()) + } +} + func TestAdminOneTimePasswordIssuanceBoundaries(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() @@ -127,12 +150,6 @@ func TestAdminOneTimePasswordIssuanceBoundaries(t *testing.T) { if rec := do(h, "POST", "/api/admin/users/carl/one-time-password", `{"password":"chosen"}`, admin); rec.Code != http.StatusBadRequest { t.Fatalf("administrator-chosen password accepted: %d %s", rec.Code, rec.Body.String()) } - if _, err := st.CreatePendingAccount("admin", "pending", store.AccountProfile{}); err != nil { - t.Fatal(err) - } - if rec := do(h, "POST", "/api/admin/users/pending/one-time-password", `{}`, admin); rec.Code != http.StatusConflict { - t.Fatalf("pending account issuance: %d %s", rec.Code, rec.Body.String()) - } otp := issueOTP(t, h, admin, "carl") if rec := do(h, "POST", "/api/auth/login", loginBody("bob", otp), ""); rec.Code != http.StatusUnauthorized { t.Fatalf("one-time password used by another owner: %d", rec.Code) @@ -145,6 +162,35 @@ func TestAdminOneTimePasswordIssuanceBoundaries(t *testing.T) { } } +func TestAdminOneTimePasswordInitializesPendingAccount(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + account, err := st.CreatePendingAccount("admin", "pending", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + otp := issueOTP(t, h, admin, "pending") + login := do(h, "POST", "/api/auth/login", loginBody("pending", otp), "") + if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"one_time_password"`) || + !strings.Contains(login.Body.String(), `"sessionScope":"set_password_only"`) { + t.Fatalf("pending otp login: %d %s", login.Code, login.Body.String()) + } + restricted := cookieOf(login) + if rec := do(h, "POST", "/api/auth/password", `{"next":"pending-pass-2"}`, restricted); rec.Code != http.StatusOK { + t.Fatalf("pending completion: %d %s", rec.Code, rec.Body.String()) + } + fresh := do(h, "POST", "/api/auth/login", loginBody("pending", "pending-pass-2"), "") + if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) { + t.Fatalf("pending normal login: %d %s", fresh.Code, fresh.Body.String()) + } + view := do(h, "GET", "/api/admin/users/"+account.ID, "", admin) + if view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `"initializationState":"initialized"`) || + !strings.Contains(view.Body.String(), `"passwordSet":true`) { + t.Fatalf("pending account state after completion: %d %s", view.Code, view.Body.String()) + } +} + func TestAdminOneTimePasswordInitializesHistoricalPasswordlessAccount(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() diff --git a/internal/store/auth.go b/internal/store/auth.go index 8ab514c..922e262 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -461,6 +461,62 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex return token, tx.Commit() } +// CreateSessionVerifiedWithPasswordLoginCleanup creates a password session +// bound to expectVersion and atomically applies post-login OTP cleanup. +func (s *Store) CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope string, ttl time.Duration, expectVersion int) (string, error) { + if scope == "" { + scope = SessionScopeFull + } + if ttl <= 0 { + ttl = sessionTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + exp := time.Now().Add(ttl) + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + var ver int + var enabled bool + var origin CredentialOrigin + versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + versionQuery += ` FOR UPDATE` + } + err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if !enabled || ver != expectVersion { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin) + VALUES(?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=? AND scope=?`), + owner, AuthMethodOneTimePassword, SessionScopeSetPasswordOnly); err != nil { + return "", err + } + return token, tx.Commit() +} + // SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。 func (s *Store) SessionOwner(token string) (string, bool) { owner, _, ok := s.SessionInfo(token) diff --git a/internal/store/one_time_password.go b/internal/store/one_time_password.go index 9d3729c..204514f 100644 --- a/internal/store/one_time_password.go +++ b/internal/store/one_time_password.go @@ -50,13 +50,14 @@ func (s *Store) IssueOneTimePassword(actorOwner, owner, passwordHash string) (Lo return LoginKeyMeta{}, err } initialization := !target.HasPassword - if initialization && target.InitializationState == InitializationPending { - return LoginKeyMeta{}, ErrConflict - } event, action := "one_time_password_issued", "account.one_time_password.issue" if initialization { event, action = "one_time_password_initialization_issued", "account.one_time_password.initialize" } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeActivate, LoginKeyStatusUnused); err != nil { + return LoginKeyMeta{}, err + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil { return LoginKeyMeta{}, err