agent-exec transport 34e93ed2817e051b9b824abe27d8c08a/3eba67b6e84f
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user