agent-exec transport 34e93ed2817e051b9b824abe27d8c08a/3eba67b6e84f
governance / governance (pull_request) Successful in 22s
ci / vet-and-test (pull_request) Successful in 1m15s

This commit is contained in:
agent-exec-transport
2026-10-20 01:45:06 +00:00
parent 4d1ede2adf
commit a3b67ee891
7 changed files with 126 additions and 52 deletions
+3 -11
View File
@@ -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)
}
})
}
+9 -26
View File
@@ -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")
+1 -1
View File
@@ -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)
}
+1 -5
View File
@@ -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
+52 -6
View File
@@ -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()
+56
View File
@@ -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)
+4 -3
View File
@@ -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