From b9c0a344b6d6e71ac8bd2eaa6842eb6a053b92b4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:20:48 +0000 Subject: [PATCH] Implement owner-confirmed account recovery and account controls Why: Administrator-issued password and login-key paths could take over target accounts, while old credentials and plugin handles ignored current account state. Evidence: SELF receipts /tmp/iam-u3-backend-all-replay.json, /tmp/iam-u3-backend-final-pg-core.json, /tmp/iam-u3-backend-build.json, and focused HTTP/concurrency receipts in /tmp/iam-u3-*.json. Impact: Adds trusted pending-account initialization and reissue, preexisting owner-session proof, proposal/rotation/grant transactions, server-side admin search, current Host guards, and legacy HTTP/CLI closure. Production cutover and downstream pins remain pending. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- cmd/agent-system/main.go | 110 +++-- cmd/agent-system/user_contract_test.go | 57 +++ cmd/storage-migrate/main.go | 14 +- internal/app/account_directory.go | 47 ++- internal/app/account_directory_test.go | 6 + internal/app/account_guard.go | 24 ++ internal/app/account_guard_test.go | 84 ++++ internal/app/app.go | 121 +++--- internal/app/hostcaps.go | 73 +++- internal/app/local_account_test.go | 48 +++ internal/app/project_lifecycle.go | 18 + internal/server/account_contract_test.go | 62 +++ internal/server/account_search.go | 205 +++++++++ internal/server/accounts.go | 98 ++--- internal/server/auth.go | 2 +- internal/server/auth_e2e_test.go | 26 +- internal/server/login_key_test.go | 8 +- internal/server/login_recovery_test.go | 156 ++----- internal/server/recovery.go | 120 ++++++ internal/server/recovery_contract_test.go | 161 +++++++ internal/server/server.go | 63 ++- internal/store/account_directory.go | 61 +++ internal/store/account_directory_test.go | 37 ++ internal/store/account_model.go | 4 +- internal/store/account_state.go | 3 + internal/store/account_state_test.go | 54 +++ internal/store/auth.go | 27 +- internal/store/login_keys.go | 409 ++++++++++++++---- internal/store/login_keys_test.go | 88 ++-- internal/store/migrate.go | 33 ++ internal/store/migrate_compat.go | 1 + internal/store/owner_initialization.go | 137 ++++++ internal/store/owner_initialization_test.go | 56 +++ internal/store/recovery_proposals.go | 438 ++++++++++++++++++++ internal/store/recovery_proposals_test.go | 220 ++++++++++ internal/store/store_test.go | 7 + pkg/extension/account_directory.go | 7 +- pkg/extension/surface.txt | 1 + 38 files changed, 2605 insertions(+), 481 deletions(-) create mode 100644 cmd/agent-system/user_contract_test.go create mode 100644 internal/app/account_guard.go create mode 100644 internal/app/account_guard_test.go create mode 100644 internal/app/local_account_test.go create mode 100644 internal/server/account_search.go create mode 100644 internal/server/recovery.go create mode 100644 internal/server/recovery_contract_test.go create mode 100644 internal/store/account_directory_test.go create mode 100644 internal/store/owner_initialization.go create mode 100644 internal/store/owner_initialization_test.go create mode 100644 internal/store/recovery_proposals.go create mode 100644 internal/store/recovery_proposals_test.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f06c6f8..f6a5299 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -238,12 +238,11 @@ func runAPIKey(args []string) int { } } -// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报 -// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。 +// deliverCredential writes a new 0600 file without following/overwriting an +// existing path. A secret never goes to stdout or a world-readable old file. func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error { if outPath == "" { - fmt.Println(plaintext) - return nil + return fmt.Errorf("protected --out delivery path required") } payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} if expiresAt != nil { @@ -253,18 +252,25 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er if err != nil { return err } - if err := os.WriteFile(outPath, buf, 0o600); err != nil { + file, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := file.Write(buf); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { return err } fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) return nil } -// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。 -// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。 +// runUser manages local owner initialization and metadata-only account actions. func runUser(args []string) int { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") + fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") return 2 } sub := args[0] @@ -272,20 +278,27 @@ func runUser(args []string) int { data := fs.String("data", "./data", "数据目录") dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") owner := fs.String("owner", "", "用户名(owner)") + userID := fs.String("user-id", "", "待初始化账号稳定 ID") + expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号当前 stateVersion") + expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号当前 generation") name := fs.String("name", "", "显示名") - password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)") + password := fs.String("password", "", "旧建号密码参数(现已拒绝)") admin := fs.Bool("admin", false, "管理员") role := fs.String("role", "user", "角色 user|admin") openExisting := fs.Bool("open-existing", false, "只打开既有存储") - generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码") - purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover") - ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)") - revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话") + generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)") + fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)") + 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,代替 stdout 明文;非交互推荐)") + out := fs.String("out", "", "凭据保护交付文件(0600 JSON,必填)") fs.Parse(args[1:]) switch sub { case "create": + if *out == "" { + fmt.Fprintln(os.Stderr, "user create: --out 必填(保护交付文件)") + return 2 + } if *owner == "" { fmt.Fprintln(os.Stderr, "user create: --owner 必填") return 2 @@ -308,32 +321,25 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if *out == "" { - if *password == "" && !*generatePassword { - fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") - } else if *password == "" { - fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") - } + return 0 + case "initialize-account": + if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 || *out == "" { + fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation、--out 必填") + return 2 + } + plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) + if err != nil { + fmt.Fprintln(os.Stderr, "user initialize-account:", err) + return 1 + } + if err := deliverCredential(*out, "activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { + fmt.Fprintln(os.Stderr, "user initialize-account:", err) + return 1 } return 0 case "reset-password": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填") - return 2 - } - pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting) - if err != nil { - fmt.Fprintln(os.Stderr, "user reset-password:", err) - return 1 - } - if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil { - fmt.Fprintln(os.Stderr, "user reset-password:", err) - return 1 - } - if *out == "" && *password == "" { - fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") - } - return 0 + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password") + return 2 case "list": users, err := app.ListUserAccounts(*dsn, *data, *openExisting) if err != nil { @@ -344,27 +350,8 @@ func runUser(args []string) int { fmt.Println(string(out)) return 0 case "issue-login-key": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填") - return 2 - } - if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover { - fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose) - return 2 - } - plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute) - if err != nil { - fmt.Fprintln(os.Stderr, "user issue-login-key:", err) - return 1 - } - if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil { - fmt.Fprintln(os.Stderr, "user issue-login-key:", err) - return 1 - } - if *out == "" { - fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") - } - return 0 + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") + return 2 case "revoke-login-key": if *keyID == "" { fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填") @@ -377,6 +364,10 @@ func runUser(args []string) int { fmt.Println("revoked") return 0 case "bootstrap-admin": + if *out == "" { + fmt.Fprintln(os.Stderr, "user bootstrap-admin: --out 必填(保护交付文件)") + return 2 + } if *owner == "" { fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填") return 2 @@ -390,9 +381,6 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) return 1 } - if *out == "" { - fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") - } return 0 default: fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go new file mode 100644 index 0000000..1abf705 --- /dev/null +++ b/cmd/agent-system/user_contract_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) { + data := t.TempDir() + for _, args := range [][]string{ + {"reset-password", "--data", data, "--owner", "victim", "--password", "attacker-password"}, + {"issue-login-key", "--data", data, "--owner", "victim", "--purpose", "recover", "--revoke-all-credentials"}, + } { + if code := runUser(args); code != 2 { + t.Fatalf("legacy command did not reject before storage: %v code=%d", args[0], code) + } + } + out := filepath.Join(data, "delivery.json") + if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 1 { + t.Fatalf("password-bearing local create accepted: %d", code) + } + if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected commands opened/created a database: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected commands wrote a delivery file: %v", err) + } +} + +func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "credential.json") + if err := os.WriteFile(out, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := deliverCredential(out, "activation", "alice", "secret", nil); err == nil { + t.Fatal("existing permissive file was overwritten") + } + bytes, err := os.ReadFile(out) + if err != nil || string(bytes) != "old" { + t.Fatalf("existing file changed: %q %v", bytes, err) + } + out = filepath.Join(dir, "new.json") + if err := deliverCredential(out, "activation", "alice", "secret", nil); err != nil { + t.Fatal(err) + } + info, err := os.Stat(out) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("delivery mode: %v %v", info, err) + } + bytes, err = os.ReadFile(out) + if err != nil || !strings.Contains(string(bytes), `"secret": "secret"`) { + t.Fatalf("delivery payload: %v", err) + } +} diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 7ee9cb2..496e5f7 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -281,7 +281,7 @@ func cmdBackup(args []string) error { // copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。 // admission_leases 是运行态执行许可(迁移无意义),明确排除。 var copyOrder = []string{ - "projects", "users", "profiles", "settings", "plugins", + "projects", "users", "account_directory_revision", "profiles", "settings", "plugins", "conversations", "runs", "messages", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", "activity_cards", "project_event_shares", "command_audit", "prompt_modules", @@ -297,6 +297,7 @@ var copyOrder = []string{ "agent_system_identity", "account_events", "redeem_attempts", + "recovery_proposals", "recovery_proposal_proofs", } const controlDDL = ` @@ -707,6 +708,14 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error { return err } defer tx.Rollback() + // The destination migrator creates a fresh singleton directory revision at + // schema startup. Replace it with the source's opaque revision so existing + // cursors/visible-change history remain tied to the migrated account data. + if table == "account_directory_revision" { + if _, err := tx.Exec(`DELETE FROM account_directory_revision`); err != nil { + return err + } + } stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders)) if err != nil { return err @@ -784,7 +793,8 @@ func advanceSequences(db *sql.DB, table string) error { // checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local' // 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。 func checkOwners(db *sql.DB, src *sql.DB) error { - ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"} + ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", + "project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"} for _, t := range ownerTables { if !sqliteHasTable(src, t) { continue diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go index faf0b48..e8beb24 100644 --- a/internal/app/account_directory.go +++ b/internal/app/account_directory.go @@ -51,6 +51,7 @@ type directoryPolicy struct { allowed map[string]bool match []string candidates map[string]bool + owners map[string]bool all bool status string version string @@ -68,6 +69,9 @@ func knownDirectoryField(field string) bool { func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery) (directoryPolicy, error) { var p directoryPolicy + if err := h.requireEffective(); err != nil { + return p, err + } if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion { return p, store.ErrAccountForbidden } @@ -153,12 +157,19 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire } sort.Strings(p.match) p.candidates = make(map[string]bool, len(scope.CandidateIDs)) + p.owners = make(map[string]bool, len(scope.CandidateOwners)) for _, id := range scope.CandidateIDs { if id == "" { return p, store.ErrAccountForbidden } p.candidates[id] = true } + for _, owner := range scope.CandidateOwners { + if owner == "" { + return p, store.ErrAccountForbidden + } + p.owners[owner] = true + } p.all = scope.AllCandidates if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { p.all = true @@ -172,13 +183,18 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire ids = append(ids, id) } sort.Strings(ids) + owners := make([]string, 0, len(p.owners)) + for owner := range p.owners { + owners = append(owners, owner) + } + sort.Strings(owners) fingerprint := struct { Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string - Match, IDs []string + Match, IDs, Owners []string All bool }{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID, Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, Public: revision.Public, - Scope: scope.ScopeVersion, Match: p.match, IDs: ids, All: p.all} + Scope: scope.ScopeVersion, Match: p.match, IDs: ids, Owners: owners, All: p.all} if p.allowed["staffNo"] { fingerprint.Staff = revision.StaffNo } @@ -254,7 +270,7 @@ func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, quer } func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool { - if !p.all && !p.candidates[row.UserID] { + if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] { return false } return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled) @@ -335,7 +351,7 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco if err != nil { return extension.AccountDirectoryLookup{}, err } - if !p.all && !p.candidates[userID] { + if !p.all && len(p.owners) == 0 && !p.candidates[userID] { return extension.AccountDirectoryLookup{}, store.ErrNotFound } account, err := h.caps.st.GetAccount(userID) @@ -350,4 +366,27 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil } +func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) { + if owner == "" || q.Cursor != "" { + return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile + } + p, err := h.directoryPolicy(ctx, q) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + if !p.all && len(p.candidates) == 0 && !p.owners[owner] { + return extension.AccountDirectoryLookup{}, store.ErrNotFound + } + account, err := h.caps.st.GetAccountByOwner(owner) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name, + StaffNo: account.Profile.StaffNo, Enabled: account.Enabled} + if !directoryRowAllowed(row, p) { + return extension.AccountDirectoryLookup{}, store.ErrNotFound + } + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil +} + var _ extension.AccountDirectoryProvider = inprocHost{} diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go index 65588d0..c1b35ce 100644 --- a/internal/app/account_directory_test.go +++ b/internal/app/account_directory_test.go @@ -106,6 +106,12 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { } query.SearchFields = nil query.Query = "同名" + if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID { + t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err) + } + if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("owner lookup leaked outsider: %v", err) + } if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) { t.Fatalf("exact lookup leaked outsider: %v", err) } diff --git a/internal/app/account_guard.go b/internal/app/account_guard.go new file mode 100644 index 0000000..46e2632 --- /dev/null +++ b/internal/app/account_guard.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + "errors" + + "agent-system-backend/internal/store" +) + +// accountEffectiveAuthorization is shared by runmgr and in-process Host +// capabilities. The implicit local singleton is the only auth-off missing-row +// exception; an existing disabled local account remains denied. +func accountEffectiveAuthorization(st *store.Store, authOn bool) func(context.Context, string) (bool, error) { + return func(_ context.Context, owner string) (bool, error) { + account, err := st.GetAccountByOwner(owner) + if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault { + return true, nil + } + if err != nil { + return false, err + } + return account.Enabled, nil + } +} diff --git a/internal/app/account_guard_test.go b/internal/app/account_guard_test.go new file mode 100644 index 0000000..2f78e35 --- /dev/null +++ b/internal/app/account_guard_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" +) + +func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/host.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + check := accountEffectiveAuthorization(st, true) + h := inprocHost{caps: hostcaps{st: st, accountGuard: func(owner string) error { + valid, err := check(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }}, owner: "alice", actor: "alice", pluginID: "rdm"} + if err := h.KVPut("before", "allowed"); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, "alice"); err != nil { + t.Fatal(err) + } + if err := h.KVPut("after", "denied"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host wrote KV: %v", err) + } + if _, _, err := h.KVGet("before"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host read KV: %v", err) + } + if _, err := h.TaskCreate("test", "denied", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host created task: %v", err) + } + if _, err := h.ProjectNameAvailable("denied", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host used project capability: %v", err) + } + if err := h.Audit("action", "target", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host wrote audit: %v", err) + } + if _, err := h.Complete(context.Background(), "denied"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host reached model completion: %v", err) + } + if _, err := h.KnowledgeSearch(context.Background(), "", "denied", 1); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host read knowledge: %v", err) + } +} + +func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/local.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + allowed, err := accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault) + if err != nil || !allowed { + t.Fatalf("implicit local denied: %v %v", allowed, err) + } + allowed, err = accountEffectiveAuthorization(st, true)(context.Background(), store.OwnerDefault) + if !errors.Is(err, store.ErrNotFound) || allowed { + t.Fatalf("auth-on missing account allowed: %v %v", allowed, err) + } + if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, store.OwnerDefault); err != nil { + t.Fatal(err) + } + allowed, err = accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault) + if err != nil || allowed { + t.Fatalf("disabled existing local account allowed: %v %v", allowed, err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 777f837..8f27dc8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,7 +7,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "errors" "fmt" "log" "net" @@ -452,16 +451,8 @@ func Run(opt Options) error { mockRunner := &runtime.MockRunner{} mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) authOn := resolved.AuthEnabled() - mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { - account, err := st.GetAccountByOwner(owner) - if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault { - return true, nil // legacy implicit local singleton has no users row - } - if err != nil { - return false, err - } - return account.Enabled, nil - }) + effectiveAccount := accountEffectiveAuthorization(st, authOn) + mgr.SetAccountEffectiveAuthorization(effectiveAccount) instanceID := opt.InstanceID if instanceID == "" { host, err := os.Hostname() @@ -613,7 +604,17 @@ func Run(opt Options) error { if err != nil { return err } - caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, + accountGuard: func(owner string) error { + valid, err := effectiveAccount(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }} // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { @@ -649,6 +650,7 @@ func Run(opt Options) error { } } hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error { + if err := caps.accountGuard(owner); err != nil { return err } resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner}) if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" { return fmt.Errorf("%s", resp.Receipt.Reason) @@ -753,31 +755,38 @@ func Run(opt Options) error { return nil } -// CreateUserAccount CLI 建号。 -// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。 -// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。 -func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { - var out string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if password != "" || generatePassword { - pw := password - if pw == "" { - pw = server.RandomPassword() - } - hash, err := store.HashPassword(pw) - if err != nil { - return err - } - if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil { - return err - } - out = pw - return nil +func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error { + if storageDSN != "" { + var isOwner bool + err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner) + if err != nil || !isOwner { + return store.ErrAccountForbidden } - if err := st.CreateAccount(owner, name, "", isAdmin); err != nil { + return nil + } + info, err := os.Stat(filepath.Join(dataDir, "agent-system.db")) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || (os.Geteuid() != 0 && stat.Uid != uint32(os.Geteuid())) { + return store.ErrAccountForbidden + } + return nil +} + +// CreateUserAccount is the local owner-only atomic pending-account path. +// Password-bearing creation is rejected before opening or changing storage. +func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { + if password != "" || generatePassword { + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") + } + var out string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { return err } - plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0) + _, plain, err := st.CreateLocalPendingAccount(owner, name, isAdmin) if err != nil { return err } @@ -789,19 +798,26 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi // IssueUserLoginKey CLI 签发激活/恢复 Key。 func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) { - var plain string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if revokeAllCreds { - if err := st.RevokeAllAPIKeys(owner); err != nil { - return err - } - st.DeleteUserSessions(owner, "") + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") +} + +// InitializeAccount resumes a specific pending account. The database/file owner +// and the local OS UID are obtained from the live environment, not CLI flags. +func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) { + var plain, owner string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err } - p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl) - plain = p + account, err := st.GetAccount(userID) + if err != nil { + return err + } + plain, err = st.InitializePendingAccount(userID, expectedStateVersion, expectedGeneration) + owner = account.Owner return err }) - return plain, err + return plain, owner, err } // RevokeUserLoginKey CLI 撤销未使用登录 Key。 @@ -816,6 +832,9 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { var plain string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err + } p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap") plain = p return err @@ -826,19 +845,7 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin // ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、 // 作废未用登录 Key、吊销全部会话并审计。 func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) { - if password == "" { - password = server.RandomPassword() - } - hash, err := store.HashPassword(password) - if err != nil { - return "", err - } - if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - return st.AdminResetUserPassword(owner, hash, "cli", "cli") - }); err != nil { - return "", err - } - return password, nil + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password") } // ListUserAccounts CLI 用户列表。 diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 0fd237e..8d73d88 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,13 +21,14 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - projects *projectLifecycleSlot - directory *accountDirectorySlot - st *store.Store - pipe *rag.Pipeline - set *settings.Service - authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) - surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + projects *projectLifecycleSlot + directory *accountDirectorySlot + accountGuard func(string) error + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) + surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 @@ -110,6 +111,9 @@ type sharedRetriever interface { } func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { return nil, err } + } q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} if c.projects != nil && projectID != "" { id, err := c.projects.resolve(ctx, c, owner, projectID) @@ -144,6 +148,9 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core // complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。 func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) { + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { return "", err } + } route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, "")) if route.Model == "" || route.Base == "" { return "", fmt.Errorf("llm route not configured") @@ -177,6 +184,13 @@ type inprocHost struct { actor string // EXT-18:分发主体(空=非 dispatch 上下文) } +func (h inprocHost) requireEffective() error { + if h.caps.accountGuard != nil { + return h.caps.accountGuard(h.owner) + } + return nil +} + func (h inprocHost) PluginID() string { return h.pluginID } func (h inprocHost) Owner() string { return h.owner } @@ -195,6 +209,9 @@ func (h inprocHost) IsAdmin() bool { } func (h inprocHost) legacySystemDirectoryAuthorized() bool { + if h.requireEffective() != nil { + return false + } if h.actor == "" { return false } @@ -276,14 +293,23 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account } func (h inprocHost) KVPut(key, value string) error { + if err := h.requireEffective(); err != nil { + return err + } return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value) } func (h inprocHost) KVGet(key string) (string, bool, error) { + if err := h.requireEffective(); err != nil { + return "", false, err + } return h.caps.st.GetPluginKV(h.pluginID, h.owner, key) } func (h inprocHost) Audit(action, target, evidence string) error { + if err := h.requireEffective(); err != nil { + return err + } return h.caps.st.AppendAuditEvent(store.AuditEvent{ Owner: h.owner, Actor: "plugin:" + h.pluginID, Action: action, Target: target, Evidence: evidence, @@ -291,10 +317,16 @@ func (h inprocHost) Audit(action, target, evidence string) error { } func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) { + if err := h.requireEffective(); err != nil { + return "", err + } return h.caps.complete(ctx, h.owner, prompt) } func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } return h.caps.search(ctx, h.owner, projectID, text, topK) } @@ -316,6 +348,9 @@ func (h inprocHost) knowledgeSource(source string) error { // "plugin:";embed=false 只写全文行,embed=true 走 IndexChunks 建向量 // (embedding 失败降级纯全文,不阻断写入)。 func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { + if err := h.requireEffective(); err != nil { + return err + } if len(docs) == 0 { return nil } @@ -339,6 +374,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc // A registered lifecycle scopes replacement to the exact owner/project/plugin; // ordinary unregistered Hosts retain the legacy cross-source ref semantics. func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { + if err := h.requireEffective(); err != nil { + return err + } if len(docs) == 0 { return nil } @@ -375,6 +413,9 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD // KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual // 或他人来源拒绝(fail-closed);删行+向量,幂等。 func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error { + if err := h.requireEffective(); err != nil { + return err + } if sourcePrefix != "plugin:"+h.pluginID { return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID) } @@ -384,6 +425,9 @@ func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) er // TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。 func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) { + if err := h.requireEffective(); err != nil { + return "", err + } t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID) if err != nil { return "", err @@ -392,27 +436,42 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro } func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error { + if err := h.requireEffective(); err != nil { + return err + } return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText) } // ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。 func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) { + if err := h.requireEffective(); err != nil { + return core.Artifact{}, err + } return h.caps.st.GetArtifact(h.owner, artifactID) } func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } return h.caps.st.ArtifactContent(h.owner, artifactID) } // ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储, // owner 作用域由调用方经 ArtifactContent 取字节时保证。 func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) { + if err := h.requireEffective(); err != nil { + return core.ParsedDocument{}, err + } return h.caps.parse(ctx, filename, mime, content) } // ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ---- func (h inprocHost) surface() (*surfacesvc.Service, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } if h.caps.surfaces == nil { return nil, surfacesvc.ErrUnavailable } diff --git a/internal/app/local_account_test.go b/internal/app/local_account_test.go new file mode 100644 index 0000000..716c7c8 --- /dev/null +++ b/internal/app/local_account_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) { + dir := t.TempDir() + bootstrap, err := BootstrapAdmin("", dir, "root", false) + if err != nil || bootstrap == "" { + t.Fatalf("bootstrap: %v", err) + } + first, err := CreateUserAccount("", dir, "alice", "", "", false, true, false) + if err != nil || first == "" { + t.Fatalf("local pending create: %v", err) + } + st, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + st.Close() + second, owner, err := InitializeAccount("", dir, account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil || owner != "alice" || second == first { + t.Fatalf("owner reissue: owner=%s err=%v", owner, err) + } + st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + current, err := st.GetAccount(account.ID) + if err != nil || current.Owner != account.Owner || current.ID != account.ID || current.InitializationGeneration != account.InitializationGeneration+1 { + t.Fatalf("reissue changed account identity: %+v %v", current, err) + } + if _, _, err := st.RedeemLoginKey(first); err == nil { + t.Fatal("old activation grant survived reissue") + } + if _, _, err := st.RedeemLoginKey(second); err != nil { + t.Fatalf("new activation grant failed: %v", err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index 78ccf95..9055970 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -84,6 +84,9 @@ func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, ow var _ extension.ProjectHost = inprocHost{} func (h inprocHost) OwnedProjects() ([]core.Project, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } ps, err := h.caps.st.ListProjects(h.owner) for i := range ps { ps[i].Owner = h.owner @@ -91,9 +94,15 @@ func (h inprocHost) OwnedProjects() ([]core.Project, error) { return ps, err } func (h inprocHost) OwnedProject(id string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.GetProject(h.owner, id) } func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } p, err := h.caps.st.ProjectMetadata(id) if err != nil { return core.Project{}, err @@ -114,11 +123,20 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { return p, nil } func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.EnsureProject(h.owner, id, name) } func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.RenameOwnedProject(h.owner, id, name) } func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { + if err := h.requireEffective(); err != nil { + return false, err + } return h.caps.st.ProjectNameAvailable(name, exceptID) } diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index df0e76f..beaf59f 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -3,11 +3,73 @@ package server import ( "encoding/json" "net/http" + "net/url" "testing" "agent-system-backend/internal/store" ) +func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + alice, err := st.CreatePendingAccount("admin", "alice-search", store.AccountProfile{Name: "同名", Phone: "private-needle"}) + if err != nil { + t.Fatal(err) + } + bob, err := st.CreatePendingAccount("admin", "bob-search", store.AccountProfile{Name: "同名", Phone: "other"}) + if err != nil { + t.Fatal(err) + } + search := func(query url.Values) (int, struct { + Accounts []struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + } `json:"accounts"` + NextCursor string `json:"nextCursor"` + }) { + response := do(h, "GET", "/api/admin/users/search?"+query.Encode(), "", admin) + var page struct { + Accounts []struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + } `json:"accounts"` + NextCursor string `json:"nextCursor"` + } + _ = json.Unmarshal(response.Body.Bytes(), &page) + return response.Code, page + } + params := url.Values{"query": {"同名"}, "mode": {"fuzzy"}, "fields": {"name"}, "limit": {"1"}} + code, first := search(params) + if code != http.StatusOK || len(first.Accounts) != 1 || first.Accounts[0].UserID != alice.ID || first.NextCursor == "" { + t.Fatalf("first search page: %d %+v", code, first) + } + params.Set("cursor", first.NextCursor) + code, second := search(params) + if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" { + t.Fatalf("second search page: %d %+v", code, second) + } + params.Del("cursor") + params.Set("query", "private-needle") + params.Set("fields", "phone") + params.Set("mode", "exact") + code, contact := search(params) + if code != http.StatusOK || len(contact.Accounts) != 1 || contact.Accounts[0].UserID != alice.ID { + t.Fatalf("authorized contact search: %d %+v", code, contact) + } + params.Set("query", "同名") + params.Set("fields", "name") + params.Set("mode", "fuzzy") + params.Set("cursor", first.NextCursor) + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "changed-private"}); err != nil { + t.Fatal(err) + } + code, _ = search(params) + if code != http.StatusConflict { + t.Fatalf("private profile edit did not expire system cursor: %d", code) + } +} + func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() diff --git a/internal/server/account_search.go b/internal/server/account_search.go new file mode 100644 index 0000000..c6ce04c --- /dev/null +++ b/internal/server/account_search.go @@ -0,0 +1,205 @@ +package server + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "agent-system-backend/internal/store" +) + +func newAccountSearchSecret() [32]byte { + var key [32]byte + if _, err := rand.Read(key[:]); err != nil { + panic(err) + } + return key +} + +type adminSearchCursor struct { + Version string `json:"v"` + Owner string `json:"o"` + UserID string `json:"u"` +} + +func (s *Server) signAdminCursor(cursor adminSearchCursor) string { + data, _ := json.Marshal(cursor) + mac := hmac.New(sha256.New, s.accountSearchSecret[:]) + _, _ = mac.Write(data) + return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, bool) { + var cursor adminSearchCursor + payload, signature, found := strings.Cut(encoded, ".") + if !found { + return cursor, false + } + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil || len(data) > 1024 { + return cursor, false + } + provided, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return cursor, false + } + mac := hmac.New(sha256.New, s.accountSearchSecret[:]) + _, _ = mac.Write(data) + if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" { + return adminSearchCursor{}, false + } + return cursor, true +} + +func searchAccountField(account store.Account, field string) string { + switch field { + case "userId": + return account.ID + case "owner": + return account.Owner + case "name": + return account.Profile.Name + case "phone": + return account.Profile.Phone + case "email": + return account.Profile.Email + case "staffNo": + return account.Profile.StaffNo + case "department": + return account.Profile.Department + case "notes": + return account.Profile.Notes + } + return "" +} + +func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + params := r.URL.Query() + query := strings.TrimSpace(params.Get("query")) + if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 { + http.Error(w, "invalid account search query", http.StatusBadRequest) + return + } + for _, ch := range query { + if unicode.IsControl(ch) { + http.Error(w, "invalid account search query", http.StatusBadRequest) + return + } + } + mode := params.Get("mode") + if mode == "" { + mode = "fuzzy" + } + if mode != "exact" && mode != "fuzzy" { + http.Error(w, "unknown account search mode", http.StatusBadRequest) + return + } + status := params.Get("status") + if status == "" { + status = "all" + } + if status != "all" && status != "enabled" && status != "disabled" { + http.Error(w, "unknown account status filter", http.StatusBadRequest) + return + } + limit := 20 + if raw := params.Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 100 { + http.Error(w, "invalid account search limit", http.StatusBadRequest) + return + } + limit = parsed + } + fields := []string{"owner", "name", "userId"} + if raw := params.Get("fields"); raw != "" { + fields = strings.Split(raw, ",") + } + allowed := map[string]bool{"owner": true, "name": true, "userId": true, "phone": true, "email": true, + "staffNo": true, "department": true, "notes": true} + seen := map[string]bool{} + for _, field := range fields { + if !allowed[field] || seen[field] { + http.Error(w, "unknown or duplicate account search field", http.StatusBadRequest) + return + } + seen[field] = true + } + sort.Strings(fields) + accounts, revision, err := s.st.ListAdminAccountsForActor(actor) + if err != nil { + writeStoreErr(w, err) + return + } + visibleRevision := sha256.Sum256([]byte(revision.Private)) + directoryVersion := hex.EncodeToString(visibleRevision[:]) + fingerprint, _ := json.Marshal(struct { + Actor, Query, Mode, Status, Revision string + Fields []string + }{actor, query, mode, status, directoryVersion, fields}) + digest := sha256.Sum256(fingerprint) + version := hex.EncodeToString(digest[:]) + var after adminSearchCursor + if token := params.Get("cursor"); token != "" { + var valid bool + after, valid = s.parseAdminCursor(token, version) + if !valid { + http.Error(w, "account search cursor expired or invalid", http.StatusConflict) + return + } + } + page := make([]store.Account, 0, limit+1) + for _, account := range accounts { + if status == "enabled" && !account.Enabled || status == "disabled" && account.Enabled { + continue + } + if after.Owner != "" && (account.Owner < after.Owner || account.Owner == after.Owner && account.ID <= after.UserID) { + continue + } + if query != "" { + matched := false + for _, field := range fields { + value := searchAccountField(account, field) + if mode == "exact" && strings.EqualFold(value, query) || mode == "fuzzy" && strings.Contains(strings.ToLower(value), strings.ToLower(query)) { + matched = true + break + } + } + if !matched { + continue + } + } + page = append(page, account) + if len(page) > limit { + break + } + } + more := len(page) > limit + if more { + page = page[:limit] + } + items := make([]map[string]any, 0, len(page)) + for _, account := range page { + items = append(items, accountProfileJSON(account)) + } + next := "" + if more { + last := page[len(page)-1] + next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.ID}) + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion}) +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 1beae30..c1c17df 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -104,6 +104,7 @@ func authStateJSON(st store.UserAuthState) map[string]any { // redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。 // 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。 func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") if s.Auth == nil || !s.Auth.Enabled { http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed) return @@ -137,6 +138,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { st, _ := s.st.GetUserAuthState(owner) out := authStateJSON(st) out["sessionScope"] = store.SessionScopeSetPasswordOnly + out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup out["reloginRequired"] = false writeJSON(w, http.StatusOK, out) } @@ -192,6 +194,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { setSessionCookie(w, r, token, maxAge) out := authStateJSON(st) out["sessionScope"] = scope + out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired writeJSON(w, http.StatusOK, out) } @@ -219,6 +222,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, "session expired", http.StatusUnauthorized) return } + mode, err := s.st.SessionPasswordChangeMode(tok) + if err != nil { + writeStoreErr(w, err) + return + } var req struct { Current string `json:"current"` Next string `json:"next"` @@ -236,15 +244,27 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - st0, _ := s.st.GetUserAuthState(owner) - forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword - if forceSet { - if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil { - if errors.Is(err, store.ErrNotFound) { + if scope == store.SessionScopeSetPasswordOnly { + var completeErr error + switch mode { + case store.PasswordChangeModeGrantSetup: + _, completeErr = s.st.CompleteSetPasswordChecked(tok, hash) + case store.PasswordChangeModeCurrentRequired: + _, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash) + default: + http.Error(w, "unsupported password change mode", http.StatusForbidden) + return + } + if completeErr != nil { + if errors.Is(completeErr, store.ErrBadCredentials) { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } + if errors.Is(completeErr, store.ErrNotFound) { http.Error(w, "session expired", http.StatusUnauthorized) return } - writeStoreErr(w, err) + writeStoreErr(w, completeErr) return } setSessionCookie(w, r, "", -1) @@ -330,6 +350,7 @@ func accountProfileJSON(account store.Account) map[string]any { "profileVersion": account.ProfileVersion, "enabled": account.Enabled, "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, "passwordSet": account.HasPassword, "initializationState": account.InitializationState, + "initializationGeneration": account.InitializationGeneration, } } @@ -440,35 +461,9 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { // 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 // 登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return + if _, ok := s.requireAdmin(w, r); ok { + http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone) } - owner := r.PathValue("owner") - var req struct { - Password string `json:"password"` - } - if err := decodeJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := validPassword(req.Password); err != nil { - http.Error(w, "new password must be 8+ characters", http.StatusBadRequest) - return - } - hash, err := store.HashPassword(req.Password) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil { - writeStoreErr(w, err) - return - } - st, _ := s.st.GetUserAuthState(owner) - writeJSON(w, http.StatusOK, map[string]any{ - "owner": st.Owner, "mustChangePassword": st.MustChangePassword, - }) } // ---- 管理员:一次性登录 Key(恢复流程主入口)---- @@ -476,38 +471,9 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) // issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。 // 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。 func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return + if _, ok := s.requireAdmin(w, r); ok { + http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone) } - owner := strings.TrimSpace(r.PathValue("owner")) - var req struct { - Purpose string `json:"purpose"` - TTLMinutes int `json:"ttlMinutes"` - } - if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - purpose := req.Purpose - if purpose == "" { - purpose = store.LoginKeyPurposeRecover - } - if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate { - http.Error(w, "purpose must be recover or activate", http.StatusBadRequest) - return - } - ttl := time.Duration(req.TTLMinutes) * time.Minute - plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl) - if err != nil { - writeStoreErr(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose, - "status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy, - "key": plain, // 明文仅此一次;关闭弹层后不可再取 - }) } // listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。 @@ -525,7 +491,7 @@ func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) { if !ok { return } - if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil { + if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor, r.PathValue("owner")); err != nil { writeStoreErr(w, err) return } diff --git a/internal/server/auth.go b/internal/server/auth.go index f8ecd43..12bfa51 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -46,7 +46,7 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { } p := r.URL.Path // 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。 - if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" { + if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" || p == "/api/auth/recovery-grants/redeem" { next.ServeHTTP(w, r) return } diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index d5b5073..b9ea234 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -328,8 +328,8 @@ func TestAdminAccountFlow(t *testing.T) { if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden { t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String()) } - // 首登改密:不要求 current;成功后销毁会话,须重新密码登录。 - if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK || + // 历史初始密码必须提供 current;成功后销毁会话,须重新密码登录。 + if rec := session("POST", "/api/auth/password", `{"current":"boot-pass-00","next":"admin-final-9"}`); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String()) } @@ -387,11 +387,11 @@ func TestAdminAccountFlow(t *testing.T) { if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { t.Fatalf("must_change non-admin create must 403: %d", rec.Code) } - // must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。 + // must_change 改密要求 current;短密码拒绝;成功后 reloginRequired。 if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest { t.Fatalf("short password must 400: %d", rec.Code) } - if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK || + if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String()) } @@ -419,19 +419,19 @@ 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.StatusOK { - t.Fatalf("admin reset: %d %s", rec.Code, rec.Body.String()) + // 旧管理员重置入口关闭,不能动 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()) } - if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { - t.Fatalf("alice old session must be revoked after reset: %d", rec.Code) + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK { + t.Fatalf("closed reset must preserve alice session: %d", rec.Code) } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) + 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 !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("reset must force change on next login: %s", rec.Body.String()) + 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()) } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 @@ -454,7 +454,7 @@ func TestAdminAccountFlow(t *testing.T) { rec = httptest.NewRecorder() h.ServeHTTP(rec, req) } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) + 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) diff --git a/internal/server/login_key_test.go b/internal/server/login_key_test.go index 84f2503..c999e5c 100644 --- a/internal/server/login_key_test.go +++ b/internal/server/login_key_test.go @@ -13,10 +13,14 @@ import ( func TestRedeemLoginKeyRestrictedSession(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - if err := st.CreateAccount("carol", "", "", false); err != nil { + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { t.Fatal(err) } - plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0) + account, err := st.CreatePendingAccount("admin", "carol", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } diff --git a/internal/server/login_recovery_test.go b/internal/server/login_recovery_test.go index 0936fd5..c70f6ad 100644 --- a/internal/server/login_recovery_test.go +++ b/internal/server/login_recovery_test.go @@ -5,7 +5,6 @@ package server import ( "bytes" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -35,7 +34,7 @@ func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string { if tok == "" { t.Fatal("missing session cookie") } - req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`)) + req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"current":"boot-pass-00","next":"admin-final-9"}`)) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() @@ -89,145 +88,48 @@ func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRec func TestAdminLoginKeyRecoveryFlow(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - adminTok := bootAdmin(t, h, st) - if err := st.CreateAccount("mallory", "", "", false); err != nil { + admin := bootAdmin(t, h, st) + hash, err := store.HashPassword("victim-old-password") + if err != nil { t.Fatal(err) } - if err := st.CreateAccount("pleb", "", "", false); err != nil { + if err := st.CreateAccount("victim", "", hash, false); err != nil { t.Fatal(err) } - - // 匿名/无会话不能签发。 - if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized { - t.Fatalf("anonymous issue: %d", rec.Code) - } - - // 管理员签发:返回明文一次 + 元数据。 - rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok) - if rec.Code != http.StatusOK { - t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) - } - var issued struct { - ID string `json:"id"` - Key string `json:"key"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil { + legacy, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) + if err != nil { t.Fatal(err) } - if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" { - t.Fatalf("issue payload: %+v", issued) + if rec := do(h, "POST", "/api/admin/users/victim/login-keys", `{}`, admin); rec.Code != http.StatusGone { + t.Fatalf("administrator secret issuance must be closed: %d %s", rec.Code, rec.Body.String()) } - - // 列表:状态可见、无明文。 - rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok) - if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") { - t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String()) + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK { + t.Fatal("legacy administrator key redeemed after cutover") } - if !strings.Contains(rec.Body.String(), `"status":"unused"`) { - t.Fatalf("list should show unused status: %s", rec.Body.String()) - } - - // 用户兑换:只提交 Key,身份由服务端绑定决定。 - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) || - !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { - t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String()) - } - userTok := cookieOf(rec) - - // 重复兑换失败。 - req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("replay redeem: %d", rec.Code) - } - - // 受限会话:业务与管理员入口均拒绝。 - if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden { - t.Fatalf("business api: %d", rec.Code) - } - if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden { - t.Fatalf("restricted session must not issue keys: %d", rec.Code) - } - - // 设密(无 current)→ reloginRequired → 新密码登录。 - rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { - t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) - } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-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("login with new password: %d %s", rec.Code, rec.Body.String()) + if rec := do(h, "GET", "/api/admin/users/victim/login-keys", "", admin); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), legacy) { + t.Fatalf("metadata lookup leaked secret: %d %s", rec.Code, rec.Body.String()) } } -// 补发与撤销:旧 Key 作废、旧受限会话失效。 func TestAdminLoginKeyReissueAndRevoke(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - adminTok := bootAdmin(t, h, st) - if err := st.CreateAccount("nia", "", "", false); err != nil { + admin := bootAdmin(t, h, st) + if err := st.CreateAccount("victim", "", "", false); err != nil { t.Fatal(err) } - issue := func() (string, string) { - rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok) - if rec.Code != http.StatusOK { - t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) - } - var out struct { - ID string `json:"id"` - Key string `json:"key"` - } - json.Unmarshal(rec.Body.Bytes(), &out) - return out.ID, out.Key + legacy, meta, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) + if err != nil { + t.Fatal(err) } - oldID, oldKey := issue() - _ = oldID - - // 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。 - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - oldSess := cookieOf(rec) - if oldSess == "" { - t.Fatal("redeem old key failed") + if rec := do(h, "DELETE", "/api/admin/users/other/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusNotFound { + t.Fatalf("path owner mismatch revoked key: %d %s", rec.Code, rec.Body.String()) } - issue() - if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized { - t.Fatalf("restricted session must die after reissue: %d", rec.Code) + if rec := do(h, "DELETE", "/api/admin/users/victim/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusOK { + t.Fatalf("legacy metadata revoke: %d %s", rec.Code, rec.Body.String()) } - req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - t.Fatal("consumed old key must not redeem again") - } - - // 撤销端点:未用 Key 撤销后不可兑换。 - newID, newKey := issue() - if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK { - t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) - } - req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - t.Fatal("revoked key must not redeem") - } - // 不存在的编号 404。 - if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound { - t.Fatalf("unknown key revoke: %d", rec.Code) + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK { + t.Fatal("revoked legacy key redeemed") } } @@ -253,7 +155,7 @@ func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) { if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden { t.Fatalf("business api before set: %d", rec.Code) } - rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok) + rec = do(h, "POST", "/api/auth/password", `{"current":"temp-pass-00","next":"oscar-final-9"}`, tok) if rec.Code != http.StatusOK { t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) } @@ -271,10 +173,14 @@ func TestActiveChangePasswordRequiresCurrent(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() _ = srv - if err := st.CreateAccount("petra", "", "", false); err != nil { + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { t.Fatal(err) } - plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0) + account, err := st.CreatePendingAccount("admin", "petra", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } diff --git a/internal/server/recovery.go b/internal/server/recovery.go new file mode 100644 index 0000000..f3ba03e --- /dev/null +++ b/internal/server/recovery.go @@ -0,0 +1,120 @@ +package server + +import ( + "net/http" +) + +func (s *Server) createAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + var req struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + IdempotencyKey string `json:"idempotencyKey"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery proposal", http.StatusBadRequest) + return + } + proposal, err := s.st.CreateRecoveryProposal(actor, r.PathValue("userId"), req.ExpectedStateVersion, req.IdempotencyKey) + if err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, proposal) +} + +func (s *Server) getAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + proposal, err := s.st.GetRecoveryProposalForAdmin(actor, r.PathValue("userId"), r.PathValue("id")) + writeJSONOr(w, proposal, err) +} + +func (s *Server) listAdminRecoveryProposals(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + proposals, err := s.st.ListRecoveryProposalsForAdmin(actor, r.PathValue("userId")) + writeJSONOr(w, map[string]any{"proposals": proposals}, err) +} + +func (s *Server) revokeAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + var req struct { + ExpectedVersion int64 `json:"expectedVersion"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery revocation", http.StatusBadRequest) + return + } + proposal, err := s.st.RevokeRecoveryProposal(actor, r.PathValue("userId"), r.PathValue("id"), req.ExpectedVersion) + writeJSONOr(w, proposal, err) +} + +func (s *Server) listMyRecoveryProposals(w http.ResponseWriter, r *http.Request) { + owner, ok := s.requireFullPasswordSession(w, r) + if !ok { + return + } + proposals, err := s.st.ListOwnerRecoveryProposals(owner) + writeJSONOr(w, map[string]any{"proposals": proposals}, err) +} + +func (s *Server) getMyRecoveryProposal(w http.ResponseWriter, r *http.Request) { + owner, ok := s.requireFullPasswordSession(w, r) + if !ok { + return + } + proposal, err := s.st.GetOwnerRecoveryProposal(owner, r.PathValue("id")) + writeJSONOr(w, proposal, err) +} + +func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireFullPasswordSession(w, r); !ok { + return + } + var req struct { + ExpectedVersion int64 `json:"expectedVersion"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery confirmation", http.StatusBadRequest) + return + } + plain, proposal, err := s.st.ConfirmRecoveryProposal(sessionToken(r), r.PathValue("id"), req.ExpectedVersion) + if err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) +} + +func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireFullPasswordSession(w, r); !ok { + return + } + var req struct { + ExpectedVersion int64 `json:"expectedVersion"` + ExpectedGeneration int64 `json:"expectedGeneration"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery grant rotation", http.StatusBadRequest) + return + } + plain, proposal, err := s.st.RotateRecoveryGrant(sessionToken(r), r.PathValue("id"), req.ExpectedVersion, req.ExpectedGeneration) + if err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) +} diff --git a/internal/server/recovery_contract_test.go b/internal/server/recovery_contract_test.go new file mode 100644 index 0000000..aa5aadb --- /dev/null +++ b/internal/server/recovery_contract_test.go @@ -0,0 +1,161 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + pending, err := st.CreatePendingAccount("admin", "recovery-user", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + activation, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + setup := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "") + if setup.Code != http.StatusOK || !strings.Contains(setup.Body.String(), `"passwordChangeMode":"grant_setup"`) { + t.Fatalf("activation redeem: %d %s", setup.Code, setup.Body.String()) + } + if rec := do(h, "POST", "/api/auth/password", `{"next":"alice-initial-password"}`, cookieOf(setup)); rec.Code != http.StatusOK { + t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) + } + ownerLogin := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-initial-password"}`, "") + if ownerLogin.Code != http.StatusOK { + t.Fatalf("trusted owner login: %d %s", ownerLogin.Code, ownerLogin.Body.String()) + } + ownerCookie := cookieOf(ownerLogin) + account, err := st.GetAccount(pending.ID) + if err != nil { + t.Fatal(err) + } + proposalResponse := do(h, "POST", "/api/admin/users/"+pending.ID+"/recovery-proposals", + `{"expectedStateVersion":`+jsonNumber(account.StateVersion)+`,"idempotencyKey":"http-request-1"}`, admin) + if proposalResponse.Code != http.StatusOK || strings.Contains(proposalResponse.Body.String(), "grant") { + t.Fatalf("administrator proposal leaked grant: %d %s", proposalResponse.Code, proposalResponse.Body.String()) + } + var proposal store.RecoveryProposal + if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil || proposal.ID == "" { + t.Fatalf("proposal metadata: %+v %v", proposal, err) + } + if got := do(h, "GET", "/api/admin/users/"+pending.ID+"/recovery-proposals/"+proposal.ID, "", admin); got.Code != http.StatusOK || strings.Contains(got.Body.String(), "grant") { + t.Fatalf("administrator status leaked grant: %d %s", got.Code, got.Body.String()) + } + confirmed := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie) + if confirmed.Code != http.StatusOK || confirmed.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("owner confirm: %d %s", confirmed.Code, confirmed.Body.String()) + } + var confirmation struct { + Proposal store.RecoveryProposal `json:"proposal"` + Grant string `json:"grant"` + } + if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" { + t.Fatalf("confirmation secret response: %v", err) + } + if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) { + t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String()) + } + rotated := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", + `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie) + if rotated.Code != http.StatusOK { + t.Fatalf("owner rotate: %d %s", rotated.Code, rotated.Body.String()) + } + var rotation struct { + Grant string `json:"grant"` + } + if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant { + t.Fatalf("rotation grant: %v", err) + } + if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK { + t.Fatal("old recovery grant remained usable") + } + redeemed := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+rotation.Grant+`"}`, "") + if redeemed.Code != http.StatusOK { + t.Fatalf("recovery redeem: %d %s", redeemed.Code, redeemed.Body.String()) + } + if done := do(h, "POST", "/api/auth/password", `{"next":"alice-recovered-password"}`, cookieOf(redeemed)); done.Code != http.StatusOK { + t.Fatalf("recovery finish: %d %s", done.Code, done.Body.String()) + } + if login := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-recovered-password"}`, ""); login.Code != http.StatusOK { + t.Fatalf("relogin: %d %s", login.Code, login.Body.String()) + } +} + +func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } + +func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + hash, err := store.HashPassword("old-victim-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("victim", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("victim"); err != nil { + 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"}`}, + } { + rec := do(h, "POST", route.path, route.body, admin) + if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "RECOVERY_CONTRACT_REPLACED") { + t.Fatalf("legacy route remained active: %s %d %s", route.path, rec.Code, rec.Body.String()) + } + } + if _, ok := st.VerifyLogin("victim", "old-victim-pass"); !ok { + t.Fatal("closed routes changed existing password") + } + if keys, err := st.ListLoginKeys("victim"); err != nil || len(keys) != 0 { + t.Fatalf("closed routes issued a grant: %+v %v", keys, err) + } +} + +func TestLegacyGrantAndRestrictedSessionCannotSkipCurrentPassword(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + hash, err := store.HashPassword("old-victim-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("victim", "", hash, false); err != nil { + t.Fatal(err) + } + grant, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) + if err != nil { + t.Fatal(err) + } + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+grant+`"}`, ""); rec.Code == http.StatusOK { + t.Fatal("legacy administrator grant created a restricted session") + } + if err := st.SetUserPassword("victim", hash); err != nil { + t.Fatal(err) + } + login := do(h, "POST", "/api/auth/login", `{"owner":"victim","password":"old-victim-pass"}`, "") + if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"current_required"`) { + t.Fatalf("legacy forced login mode: %d %s", login.Code, login.Body.String()) + } + cookie := cookieOf(login) + if rec := do(h, "POST", "/api/auth/password", `{"next":"victim-new-pass"}`, cookie); rec.Code == http.StatusOK { + t.Fatal("forced password session skipped current password") + } + if rec := do(h, "POST", "/api/auth/password", `{"current":"old-victim-pass","next":"victim-new-pass"}`, cookie); rec.Code != http.StatusOK { + t.Fatalf("legacy forced change with current failed: %d %s", rec.Code, rec.Body.String()) + } + if _, ok := st.VerifyLogin("victim", "victim-new-pass"); !ok { + t.Fatal("current-required password change did not complete") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 3e1ad8a..13abb06 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -42,26 +42,27 @@ import ( ) type Server struct { - projectLifecycle *extension.ProjectLifecycle - projectHost func(string) extension.Host - st *store.Store - Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) - Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) - Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) - Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) - LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) - mgr *runmgr.Manager - Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) - Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) - Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) - Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) - Workflows *workflow.Registry // 已装载工作流(nil = 不接) - Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) - reg *tools.Registry - prof *profiles.Service - set *settings.Service - plugins *plugins.Host - static fs.FS + accountSearchSecret [32]byte + projectLifecycle *extension.ProjectLifecycle + projectHost func(string) extension.Host + st *store.Store + Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) + Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) + Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) + Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) + LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) + mgr *runmgr.Manager + Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) + Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) + Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) + Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) + Workflows *workflow.Registry // 已装载工作流(nil = 不接) + Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) + reg *tools.Registry + prof *profiles.Service + set *settings.Service + plugins *plugins.Host + static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) // DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与 @@ -160,7 +161,8 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles. if err != nil { panic(err) // embed 布局错误属构建期问题 } - return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub} + return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub, + accountSearchSecret: newAccountSearchSecret()} } // NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。 @@ -282,15 +284,25 @@ func (s *Server) Handler() http.Handler { // ---- 认证与会话(管理员发放制,R2)---- mux.HandleFunc("POST /api/auth/login", s.login) mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey) + mux.HandleFunc("POST /api/auth/recovery-grants/redeem", s.redeemLoginKey) mux.HandleFunc("POST /api/auth/logout", s.logout) mux.HandleFunc("POST /api/auth/password", s.changePassword) mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) + mux.HandleFunc("GET /api/admin/users/search", s.searchAdminUsers) mux.HandleFunc("POST /api/admin/users", s.createAdminUser) mux.HandleFunc("GET /api/me/profile", s.meProfile) mux.HandleFunc("PATCH /api/me/profile", s.patchMeProfile) mux.HandleFunc("GET /api/admin/users/{userId}", s.adminAccount) mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile) mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState) + mux.HandleFunc("POST /api/admin/users/{userId}/recovery-proposals", s.createAdminRecoveryProposal) + mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals", s.listAdminRecoveryProposals) + mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals/{id}", s.getAdminRecoveryProposal) + mux.HandleFunc("DELETE /api/admin/users/{userId}/recovery-proposals/{id}", s.revokeAdminRecoveryProposal) + mux.HandleFunc("GET /api/me/recovery-proposals", s.listMyRecoveryProposals) + mux.HandleFunc("GET /api/me/recovery-proposals/{id}", s.getMyRecoveryProposal) + mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/confirm", s.confirmRecoveryProposal) + mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/rotate-grant", s.rotateRecoveryGrant) mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) @@ -845,6 +857,11 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { if sc := SessionScope(r); sc != "" { out["sessionScope"] = sc } + if AuthVia(r) == AuthViaSession { + if mode, err := s.st.SessionPasswordChangeMode(sessionToken(r)); err == nil { + out["passwordChangeMode"] = mode + } + } if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin out["mustChangePassword"] = st.MustChangePassword @@ -2166,6 +2183,10 @@ func writeOKOr(w http.ResponseWriter, err error) { } func writeStoreErr(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrOwnerProofUnavailable) { + http.Error(w, "OWNER_PROOF_UNAVAILABLE", http.StatusConflict) + return + } if errors.Is(err, store.ErrAccountForbidden) { http.Error(w, err.Error(), http.StatusForbidden) return diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go index e426043..bef0de9 100644 --- a/internal/store/account_directory.go +++ b/internal/store/account_directory.go @@ -1,5 +1,11 @@ package store +import ( + "context" + "database/sql" + "errors" +) + // DirectoryAccountRow is an internal candidate projection. It intentionally // excludes contact details, notes, credentials and administrator privileges. type DirectoryAccountRow struct { @@ -28,3 +34,58 @@ func (s *Store) ListDirectoryAccountRows() ([]DirectoryAccountRow, error) { } return results, rows.Err() } + +// ListAdminAccountsForActor takes a consistent snapshot for the protected +// system.users.view adapter. The actor's current admin/effective state is +// checked under lock before any profile fields are read. +func (s *Store) ListAdminAccountsForActor(actorOwner string) ([]Account, AccountDirectoryRevision, error) { + var tx *sql.Tx + var err error + if s.Dialect() == "postgres" { + tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + } else { + tx, err = s.db.Begin() + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + rows, err := tx.Query(s.q(`SELECT ` + accountColumns + ` FROM users ORDER BY owner,id`)) + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + accounts := []Account{} + for rows.Next() { + account, err := scanAccount(rows) + if err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + accounts = append(accounts, account) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + rows.Close() + var revision AccountDirectoryRevision + if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`). + Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil { + return nil, AccountDirectoryRevision{}, err + } + if err := tx.Commit(); err != nil { + return nil, AccountDirectoryRevision{}, err + } + return accounts, revision, nil +} diff --git a/internal/store/account_directory_test.go b/internal/store/account_directory_test.go new file mode 100644 index 0000000..593cdfb --- /dev/null +++ b/internal/store/account_directory_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "errors" + "testing" +) + +func TestAdminAccountSearchSnapshotRequiresCurrentSystemAuthority(t *testing.T) { + st := openTestStore(t) + t.Logf("admin directory dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ordinary", "", "hash", false); err != nil { + t.Fatal(err) + } + pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Email: "alice@example.test", Notes: "private"}) + if err != nil { + t.Fatal(err) + } + accounts, revision, err := st.ListAdminAccountsForActor("admin") + if err != nil || len(accounts) != 3 || revision.Private == "" { + t.Fatalf("authorized snapshot: accounts=%d rev=%+v err=%v", len(accounts), revision, err) + } + if accounts[1].ID != pending.ID || accounts[1].Profile.Email != "alice@example.test" { + t.Fatalf("profile snapshot or stable sort: %+v", accounts) + } + if _, _, err := st.ListAdminAccountsForActor("ordinary"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary account read protected profiles: %v", err) + } + if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "admin"); err != nil { + t.Fatal(err) + } + if _, _, err := st.ListAdminAccountsForActor("admin"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator read protected profiles: %v", err) + } +} diff --git a/internal/store/account_model.go b/internal/store/account_model.go index dc7c2cd..b2c47a7 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -80,7 +80,9 @@ type accountQuerier interface { QueryRow(string, ...any) *sql.Row } -func scanAccount(row *sql.Row) (Account, error) { +type accountScanner interface{ Scan(...any) error } + +func scanAccount(row accountScanner) (Account, error) { var a Account err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email, &a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion, diff --git a/internal/store/account_state.go b/internal/store/account_state.go index d1c18d8..3cb3cc2 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -66,6 +66,9 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, target.Owner, LoginKeyStatusUnused); err != nil { return Account{}, err } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), target.Owner); err != nil { + return Account{}, err + } } if err := s.bumpAccountDirectoryRevision(tx, true, false); err != nil { return Account{}, err diff --git a/internal/store/account_state_test.go b/internal/store/account_state_test.go index 9847577..6ea8bcf 100644 --- a/internal/store/account_state_test.go +++ b/internal/store/account_state_test.go @@ -2,6 +2,7 @@ package store import ( "errors" + "sync" "testing" ) @@ -71,6 +72,59 @@ func TestAccountStateDisablesCredentialsWithoutRestoringThem(t *testing.T) { } } +func TestAccountStateConcurrentLastTwoManagersAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("last managers dialect=%s two independent pools", st.Dialect()) + for _, owner := range []string{"admin-a", "admin-b"} { + if err := st.CreateAccount(owner, "", "existing-hash", true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } + } + a, _ := st.GetAccountByOwner("admin-a") + b, _ := st.GetAccountByOwner("admin-b") + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for i, item := range []struct { + owner string + account Account + }{{"admin-a", a}, {"admin-b", b}} { + conn := []*Store{st, other}[i] + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := conn.SetAccountEnabled(item.owner, item.account.ID, item.account.StateVersion, false) + results <- err + }() + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("last-admin race wins=%d conflicts=%d", wins, conflicts) + } + a, _ = st.GetAccount(a.ID) + b, _ = st.GetAccount(b.ID) + if a.Enabled == b.Enabled { + t.Fatalf("must retain exactly one operable manager: a=%v b=%v", a.Enabled, b.Enabled) + } +} + func TestAccountStateProtectsLastOperableSystemManager(t *testing.T) { st := openTestStore(t) hash, err := HashPassword("admin-password-1") diff --git a/internal/store/auth.go b/internal/store/auth.go index c451f7e..484e021 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -165,9 +165,14 @@ const sessionTTL = 7 * 24 * time.Hour // AdminUser 管理员用户列表的展示视图(不含哈希)。 type AdminUser struct { + ID string `json:"userId"` Owner string `json:"owner"` Name string `json:"name"` Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + ProfileVersion int64 `json:"profileVersion"` + InitializationState string `json:"initializationState"` + InitializationGeneration int64 `json:"initializationGeneration"` IsAdmin bool `json:"isAdmin"` MustChangePassword bool `json:"mustChangePassword"` HasPassword bool `json:"hasPassword"` @@ -249,15 +254,7 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er // SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。 func (s *Store) SetUserPassword(owner, passwordHash string) error { - res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), - passwordHash, nowISO(), true, owner) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound - } - return nil + return s.AdminResetUserPassword(owner, passwordHash, "legacy-store", "legacy-store") } // ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。 @@ -385,7 +382,11 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex var ver int var enabled bool var origin CredentialOrigin - err = tx.QueryRow(s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`), owner).Scan(&ver, &enabled, &origin) + 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 } @@ -448,7 +449,8 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) { // ListUsersAdmin 管理员用户列表(按创建序)。 func (s *Store) ListUsersAdmin() ([]AdminUser, error) { - rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),enabled,password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) + rows, err := s.db.Query(s.q(`SELECT id,owner,COALESCE(name,''),enabled,state_version,profile_version,initialization_state,initialization_generation, + password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) if err != nil { return nil, err } @@ -457,7 +459,8 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) { for rows.Next() { var u AdminUser var hash, psa, ca string - if err := rows.Scan(&u.Owner, &u.Name, &u.Enabled, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { + if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &u.Enabled, &u.StateVersion, &u.ProfileVersion, + &u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { return nil, err } u.HasPassword = hash != "" diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index 06ceced..5b76039 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -6,6 +6,8 @@ import ( "encoding/hex" "errors" "fmt" + "os" + "strings" "time" ) @@ -33,12 +35,66 @@ const ( SessionScopeFull = "full" SessionScopeSetPasswordOnly = "set_password_only" - defaultLoginKeyTTL = 30 * time.Minute - minLoginKeyTTL = 5 * time.Minute - maxLoginKeyTTL = 120 * time.Minute - restrictedSessionTTL = 10 * time.Minute + defaultLoginKeyTTL = 30 * time.Minute + minLoginKeyTTL = 5 * time.Minute + maxLoginKeyTTL = 120 * time.Minute + restrictedSessionTTL = 10 * time.Minute + PasswordChangeModeGrantSetup = "grant_setup" + PasswordChangeModeCurrentRequired = "current_required" ) +// SessionPasswordChangeMode is server-derived. A legacy restricted session has +// no mode and therefore cannot enter either password-writing branch. +func (s *Store) SessionPasswordChangeMode(token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + var method, origin, grantID, scope, exp string + var sessVersion, userVersion int + var enabled bool + err := s.db.QueryRow(s.q(`SELECT s.auth_method,s.credential_origin,s.grant_id,s.scope,s.expires_at,s.auth_version,u.auth_version,u.enabled + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&method, &origin, &grantID, &scope, &exp, &sessVersion, &userVersion, &enabled) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + expiry, err := time.Parse(time.RFC3339Nano, exp) + if err != nil || time.Now().After(expiry) || !enabled || sessVersion != userVersion { + return "", ErrNotFound + } + if method == AuthMethodPassword || (method == AuthMethodLegacyUnknown && scope == SessionScopeFull) { + return PasswordChangeModeCurrentRequired, nil + } + if method == AuthMethodGrant && scope == SessionScopeSetPasswordOnly && grantID != "" && + (CredentialOrigin(origin) == CredentialOriginOwnerInitialization || CredentialOrigin(origin) == CredentialOriginOwnerRecovery) { + var grantStatus, grantExpiry, grantOrigin, proposalID string + var issuedVersion int + if err := s.db.QueryRow(s.q(`SELECT status,expires_at,grant_origin,proposal_id,issued_auth_version FROM login_keys WHERE id=?`), grantID). + Scan(&grantStatus, &grantExpiry, &grantOrigin, &proposalID, &issuedVersion); err != nil { + return "", ErrAccountForbidden + } + expiry, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(expiry) || grantStatus != LoginKeyStatusConsumed || grantOrigin != origin || issuedVersion != userVersion { + return "", ErrAccountForbidden + } + if proposalID != "" { + var proposalStatus, proposalExpiry string + if err := s.db.QueryRow(s.q(`SELECT status,expires_at FROM recovery_proposals WHERE id=?`), proposalID).Scan(&proposalStatus, &proposalExpiry); err != nil { + return "", ErrAccountForbidden + } + expiry, err := time.Parse(time.RFC3339Nano, proposalExpiry) + if err != nil || time.Now().After(expiry) || proposalStatus != RecoveryProposalConfirmed { + return "", ErrAccountForbidden + } + } + return PasswordChangeModeGrantSetup, nil + } + return "", ErrAccountForbidden +} + // LoginKeyMeta 对外可见元数据(无明文)。 type LoginKeyMeta struct { ID string `json:"id"` @@ -50,6 +106,13 @@ type LoginKeyMeta struct { CreatedBy string `json:"createdBy,omitempty"` } +type grantBinding struct { + origin CredentialOrigin + generation int64 + version int + proposalID string +} + // AccountEvent 账户生命周期审计行(不含任何凭据材料)。 type AccountEvent struct { ID string `json:"id"` @@ -131,8 +194,12 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio // insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。 // (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。) -func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time) (LoginKeyMeta, error) { +func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time, bindings ...grantBinding) (LoginKeyMeta, error) { meta := LoginKeyMeta{} + binding := grantBinding{origin: CredentialOriginLegacyUnknown} + if len(bindings) == 1 { + binding = bindings[0] + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil { return meta, err @@ -141,8 +208,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { return meta, err } - if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`), - id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,grant_origin,generation,issued_auth_version,proposal_id) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), + id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy, + binding.origin, binding.generation, binding.version, binding.proposalID); err != nil { return meta, err } if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { @@ -153,8 +222,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i // BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 → // 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。 +// createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。 // SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。 func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) { + createdBy = fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err @@ -182,9 +253,9 @@ func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string if n > 0 { return "", ErrConflict // 有数据环境拒绝接管(R11) } - if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) - VALUES(?,?,?,?,?,?,?,?)`), - newID("usr"), owner, name, nowISO(), "", "", false, true); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin,initialization_state,initialization_generation) + VALUES(?,?,?,?,?,?,?,?,?,?)`), + newID("usr"), owner, name, nowISO(), "", "", false, true, InitializationPending, 1); err != nil { if isUniqueViolation(err) { return "", ErrConflict } @@ -197,7 +268,8 @@ func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string if n != 1 { return "", ErrConflict } - if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil { + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp, + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { return "", err } if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil { @@ -239,6 +311,12 @@ func entranceOf(createdBy string) string { if createdBy == "" { return "api" } + if strings.HasPrefix(createdBy, "local-owner:") { + return "local-owner-cli" + } + if strings.HasPrefix(createdBy, "owner-proof:") { + return "owner-http" + } if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") { return "cli" } @@ -260,7 +338,7 @@ func (s *Store) RevokeLoginKey(id string) error { // RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员 // HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。 -func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) { +func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...string) (owner string, err error) { tx, err := s.db.Begin() if err != nil { return "", err @@ -273,6 +351,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error if err != nil { return "", err } + if len(expectedOwner) > 0 && expectedOwner[0] != owner { + return "", ErrNotFound + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { return "", err @@ -336,24 +417,75 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound } - tx, err := s.db.Begin() - if err != nil { - return "", "", err - } - defer tx.Rollback() - var id, o, status, exp string - err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)). - Scan(&id, &o, &status, &exp) + // Resolve immutable routing fields before acquiring locks. The transaction + // locks user → proposal → grant, then rechecks the exact grant row. + var routeOwner, routeProposal string + err = s.db.QueryRow(s.q(`SELECT owner,proposal_id FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&routeOwner, &routeProposal) if errors.Is(err, sql.ErrNoRows) { return "", "", ErrNotFound } if err != nil { return "", "", err } + tx, err := s.db.Begin() + if err != nil { + return "", "", err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, routeOwner) + if err != nil { + return "", "", err + } + account := locked[routeOwner] + if !account.Enabled { + return "", "", ErrAccountForbidden + } + var proposalStatus, proposalOwner, proposalExp string + var proposalGeneration int64 + var proposalAuthVersion int + if routeProposal != "" { + proposalQuery := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) + if s.Dialect() == "postgres" { + proposalQuery += ` FOR UPDATE` + } + if err := tx.QueryRow(proposalQuery, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExp, &proposalAuthVersion); err != nil { + return "", "", err + } + } + var id, o, status, exp, purpose, grantOrigin, proposalID string + var generation int64 + var issuedVersion int + grantQuery := s.q(`SELECT id,owner,status,expires_at,purpose,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE key_hash=?`) + if s.Dialect() == "postgres" { + grantQuery += ` FOR UPDATE` + } + err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion, &proposalID) + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } + if err != nil { + return "", "", err + } + if o != routeOwner || proposalID != routeProposal || issuedVersion != account.AuthVersion { + return "", "", ErrConflict + } + switch { + case purpose == LoginKeyPurposeActivate && grantOrigin == string(CredentialOriginOwnerInitialization): + if proposalID != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return "", "", ErrAccountForbidden + } + case purpose == LoginKeyPurposeRecover && grantOrigin == string(CredentialOriginOwnerRecovery): + proposalExpiry, perr := time.Parse(time.RFC3339Nano, proposalExp) + if proposalID == "" || proposalOwner != o || proposalStatus != "confirmed" || proposalGeneration != generation || proposalAuthVersion != account.AuthVersion || + perr != nil || time.Now().After(proposalExpiry) { + return "", "", ErrAccountForbidden + } + default: + // Migrated administrator-issued keys have legacy_unknown provenance. + return "", "", ErrAccountForbidden + } expT, err := time.Parse(time.RFC3339Nano, exp) if err != nil || time.Now().After(expT) { - _, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused) - _ = tx.Commit() return "", "", ErrNotFound } if status != LoginKeyStatusUnused { @@ -367,23 +499,14 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if n, _ := res.RowsAffected(); n != 1 { return "", "", ErrConflict } - var authVersion int - var enabled bool - err = tx.QueryRow(s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`), o).Scan(&authVersion, &enabled) - if err != nil { - return "", "", err // 账户已不存在:兑换失败整体回滚 - } - if !enabled { - return "", "", ErrAccountForbidden - } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", err } token = "sess_" + hex.EncodeToString(raw) sessExp := time.Now().Add(restrictedSessionTTL) - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), - hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil { + 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,grant_id) VALUES(?,?,?,?,?,?,?,?,?,?)`), + hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodGrant, grantOrigin, id); err != nil { return "", "", err } if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil { @@ -395,51 +518,102 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return token, o, nil } -// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话 -// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一 -// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、 -// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。 +// CompleteSetPasswordChecked accepts only a restricted session derived from a +// trusted owner initialization or owner-confirmed recovery grant. Historical +// key sessions and password-authenticated forced sessions cannot use it. func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) { - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - var scope, sessExp string - var sessVer, userVer int - var mustChange, enabled bool - var hash sql.NullString - err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash,u.enabled - FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). - Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash, &enabled) + var routeGrant string + err = s.db.QueryRow(s.q(`SELECT owner,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &routeGrant) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } - if !enabled { + if routeGrant == "" { return "", ErrAccountForbidden } - if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) { - return "", ErrNotFound + var routeProposal string + if err := s.db.QueryRow(s.q(`SELECT proposal_id FROM login_keys WHERE id=? AND owner=?`), routeGrant, owner).Scan(&routeProposal); err != nil { + return "", ErrAccountForbidden } - // 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。 - if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") { - return "", ErrConflict - } - // 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。 - if sessVer != userVer { - return "", ErrConflict - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), false, owner) + tx, err := s.db.Begin() if err != nil { return "", err } - if n, _ := res.RowsAffected(); n == 0 { + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return "", err + } + account := accounts[owner] + if !account.Enabled { + return "", ErrAccountForbidden + } + var proposalStatus, proposalOwner, proposalExpiry string + var proposalGeneration int64 + var proposalVersion int + if routeProposal != "" { + query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil { + return "", ErrAccountForbidden + } + } + grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + grantQuery += ` FOR UPDATE` + } + var grantOwner, purpose, status, grantExpiry, origin, proposalID string + var generation int64 + var issuedVersion int + if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil { + return "", ErrAccountForbidden + } + sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`) + if s.Dialect() == "postgres" { + sessionQuery += ` FOR UPDATE` + } + var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string + var sessionVersion int + if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil { return "", ErrNotFound } + grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(grantExpires) { + return "", ErrAccountForbidden + } + sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry) + if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant || + grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion || + grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal { + return "", ErrAccountForbidden + } + switch { + case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization): + if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return "", ErrAccountForbidden + } + case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery): + expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry) + if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation || + proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword { + return "", ErrAccountForbidden + } + default: + return "", ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1, + credential_origin=?,initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=?`), + passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true) + if err != nil { + return "", err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return "", ErrConflict + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return "", err @@ -447,11 +621,83 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return "", err } - entrance := "must-change" - if scope == SessionScopeSetPasswordOnly { - entrance = "recovery" + if routeProposal != "" { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil { + return "", err + } } - if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil { + return "", err + } + return owner, tx.Commit() +} + +// CompleteForcedPasswordChecked preserves the provenance of a historical +// password-authenticated account. It requires the current password even when +// the session is restricted by must_change_password. +func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) { + var owner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return "", err + } + account := accounts[owner] + if !account.Enabled || !account.MustChangePassword || !account.HasPassword { + return "", ErrAccountForbidden + } + var hash string + if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil { + return "", err + } + query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var scope, method, expiresAt string + var version int + if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil { + return "", ErrNotFound + } + expiry, err := time.Parse(time.RFC3339Nano, expiresAt) + if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword { + return "", ErrAccountForbidden + } + if !VerifyPassword(hash, current) { + return "", ErrBadCredentials + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1 + WHERE owner=? AND auth_version=? AND enabled=? AND must_change_password=?`), + passwordHash, nowISO(), false, owner, account.AuthVersion, true, true) + if err != nil { + return "", err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil { return "", err } return owner, tx.Commit() @@ -468,7 +714,11 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken var hash string var ver int var mustChange, enabled bool - err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`), owner). + accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + accountQuery += ` FOR UPDATE` + } + err = tx.QueryRow(accountQuery, owner). Scan(&hash, &ver, &mustChange, &enabled) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound @@ -510,6 +760,12 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return err + } if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { return err } @@ -524,18 +780,29 @@ func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance stri return err } defer tx.Rollback() - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), true, owner) + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + if !locked[owner].Enabled { + return ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1, + credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound + return ErrConflict } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return err } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return err + } if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } diff --git a/internal/store/login_keys_test.go b/internal/store/login_keys_test.go index 94f0974..406db21 100644 --- a/internal/store/login_keys_test.go +++ b/internal/store/login_keys_test.go @@ -15,18 +15,32 @@ func TestOpenExistingSQLiteMissing(t *testing.T) { } } +func pendingActivationFixture(t *testing.T, st *Store, owner string) (Account, string) { + t.Helper() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + account, err := st.CreatePendingAccount("admin", owner, AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + return account, plain +} + func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { st, err := OpenSQLite(filepath.Join(t.TempDir(), "t.db")) if err != nil { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("alice", "Alice", "", false); err != nil { - t.Fatal(err) - } - plain, meta, err := st.IssueLoginKey("alice", LoginKeyPurposeActivate, "test", 0) - if err != nil || plain == "" || meta.ID == "" { - t.Fatalf("issue: %v meta=%+v", err, meta) + _, plain := pendingActivationFixture(t, st, "alice") + keys, err := st.ListLoginKeys("alice") + if err != nil || plain == "" || len(keys) != 1 || keys[0].ID == "" { + t.Fatalf("issue: %v keys=%+v", err, keys) } tok, owner, err := st.RedeemLoginKey(plain) if err != nil || owner != "alice" || tok == "" { @@ -72,13 +86,7 @@ func TestLoginKeyConcurrentRedeem(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("bob", "", "", false); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("bob", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + _, plain := pendingActivationFixture(t, st, "bob") var okCount atomic.Int32 var wg sync.WaitGroup for i := 0; i < 100; i++ { @@ -103,18 +111,16 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("carol", "", "", false); err != nil { - t.Fatal(err) - } - old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + account, old1 := pendingActivationFixture(t, st, "carol") oldTok, _, err := st.RedeemLoginKey(old1) if err != nil { t.Fatal(err) } - old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + old2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -123,7 +129,11 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { t.Fatal("restricted session from superseded key should be revoked") } // 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。 - new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + new1, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -150,19 +160,17 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("dave", "", "", false); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + account, plain := pendingActivationFixture(t, st, "dave") tok, _, err := st.RedeemLoginKey(plain) if err != nil { t.Fatal(err) } // 会话被签发补发作废后,设密必须失败。 - if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil { + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration); err != nil { t.Fatal(err) } hash, err := HashPassword("dave-final-99") @@ -177,7 +185,11 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { t.Fatal("password must be unchanged after rejected set") } // 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。 - plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + plain2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -338,18 +350,20 @@ func TestRevokeLoginKeyCascade(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("hana", "", "", false); err != nil { - t.Fatal(err) - } - plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0) - if err != nil { + _, plain := pendingActivationFixture(t, st, "hana") + issued, err := st.ListLoginKeys("hana") + if err != nil || len(issued) != 1 { t.Fatal(err) } + meta := issued[0] tok, _, err := st.RedeemLoginKey(plain) if err != nil { t.Fatal(err) } - if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil { + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "other-owner"); err == nil { + t.Fatal("path owner mismatch revoked another user's grant") + } + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "hana"); err != nil { t.Fatal(err) } if _, _, ok := st.SessionInfo(tok); ok { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 46be44b..e0951c9 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -855,9 +855,42 @@ func init() { }, down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown}, }, + migration{version: 32, name: "owner_proof_recovery", + up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp}, + down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown}, + }, ) } +var recoveryFoundationUp = []string{ + `ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE login_keys ADD COLUMN issued_auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE login_keys ADD COLUMN proposal_id TEXT NOT NULL DEFAULT ''`, + `CREATE TABLE recovery_proposals ( + id TEXT PRIMARY KEY,user_id TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL, + version BIGINT NOT NULL DEFAULT 1,generation BIGINT NOT NULL DEFAULT 0, + created_by TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL, + issued_auth_version INTEGER NOT NULL,idempotency_key TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX idx_recovery_proposals_owner_status ON recovery_proposals(owner,status)`, + `CREATE UNIQUE INDEX uq_recovery_proposals_actor_idempotency ON recovery_proposals(created_by,idempotency_key) WHERE idempotency_key<>''`, + `CREATE TABLE recovery_proposal_proofs ( + proposal_id TEXT NOT NULL,session_hash TEXT NOT NULL,PRIMARY KEY(proposal_id,session_hash) + )`, +} + +var recoveryFoundationDown = []string{ + `DROP TABLE recovery_proposal_proofs`, + `DROP INDEX uq_recovery_proposals_actor_idempotency`, + `DROP INDEX idx_recovery_proposals_owner_status`, + `DROP TABLE recovery_proposals`, + `ALTER TABLE login_keys DROP COLUMN proposal_id`, + `ALTER TABLE login_keys DROP COLUMN issued_auth_version`, + `ALTER TABLE login_keys DROP COLUMN generation`, + `ALTER TABLE login_keys DROP COLUMN grant_origin`, +} + // v31 preserves legacy identities and credentials. Only the explicit pending // account primitive sets pending initialization; migration cannot infer proof. func accountFoundationUp(enabledType, revisionExpression string) []string { diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 545ff6b..c1cf23f 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -36,6 +36,7 @@ var migrationBreaksOldReaders = map[int]bool{ 29: false, // redeem_attempts:新表,旧读者不受影响 30: false, // project_event_publication_proofs:新表,旧读者不受影响 31: true, // 账号有效态/凭据来源是安全协议边界;即使旧 SQL 可读,也不得恢复旧认证写者 + 32: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/owner_initialization.go b/internal/store/owner_initialization.go new file mode 100644 index 0000000..8f09eca --- /dev/null +++ b/internal/store/owner_initialization.go @@ -0,0 +1,137 @@ +package store + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "time" +) + +// InitializePendingAccount is reserved for the local machine/DB owner CLI. It +// accepts a stable user ID and expected versions, never an actor string from a +// request. The audit actor is the actual local OS UID. The caller must use the +// protected delivery channel; a failed delivery is retried with the same ID. +func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, expectedGeneration int64) (string, error) { + if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 { + return "", ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return "", err + } + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + plain := "alk_" + hex.EncodeToString(raw[:]) + operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, pre.Owner) + if err != nil { + return "", err + } + account := locked[pre.Owner] + if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration { + return "", ErrConflict + } + if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { + return "", ErrAccountForbidden + } + var unsafeCreds int + err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`), account.Owner).Scan(&unsafeCreds) + if err != nil { + return "", err + } + if unsafeCreds != 0 { + return "", ErrAccountForbidden + } + err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope=?`), account.Owner, SessionScopeFull).Scan(&unsafeCreds) + if err != nil { + return "", err + } + if unsafeCreds != 0 { + return "", ErrAccountForbidden + } + err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>?`), account.Owner, CredentialOriginOwnerInitialization).Scan(&unsafeCreds) + if err != nil { + return "", err + } + if unsafeCreds != 0 { + return "", ErrAccountForbidden + } + nextGeneration := account.InitializationGeneration + 1 + nextAuthVersion := account.AuthVersion + 1 + result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1 + WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`), + nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true) + if err != nil { + return "", err + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + return "", ErrConflict + } + if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return plain, nil +} + +// CreateLocalPendingAccount is the local owner-only new-account path. Account +// and first activation grant commit together, including a local administrator +// flag when explicitly requested by the machine/DB owner CLI. +func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) { + operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return Account{}, "", err + } + plain := "alk_" + hex.EncodeToString(raw[:]) + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return Account{}, "", err + } + defer tx.Rollback() + if err := s.dialect.lockBootstrap(tx); err != nil { + return Account{}, "", err + } + var users int + if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil { + return Account{}, "", err + } + if users == 0 { + return Account{}, "", ErrConflict // first administrator requires BootstrapFirstAdmin + } + account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name}) + if err != nil { + return Account{}, "", err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil { + return Account{}, "", err + } + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { + return Account{}, "", err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil { + return Account{}, "", err + } + if err := tx.Commit(); err != nil { + return Account{}, "", err + } + account.IsAdmin, account.InitializationGeneration = isAdmin, 1 + return account, plain, nil +} diff --git a/internal/store/owner_initialization_test.go b/internal/store/owner_initialization_test.go new file mode 100644 index 0000000..8d7189d --- /dev/null +++ b/internal/store/owner_initialization_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "errors" + "testing" +) + +func TestOwnerInitializationReissuesSamePendingIdentityAndClosesAfterCompletion(t *testing.T) { + st := openTestStore(t) + t.Logf("owner initialization dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Name: "Alice"}) + if err != nil { + t.Fatal(err) + } + first, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) + if err != nil || first == "" { + t.Fatalf("initialize pending: %v", err) + } + firstSession, owner, err := st.RedeemLoginKey(first) + if err != nil || owner != "alice" { + t.Fatalf("redeem first activation: %v %q", err, owner) + } + stillPending, err := st.GetAccount(pending.ID) + if err != nil { + t.Fatal(err) + } + second, err := st.InitializePendingAccount(pending.ID, stillPending.StateVersion, stillPending.InitializationGeneration) + if err != nil || second == first { + t.Fatalf("reissue: %v", err) + } + if _, _, ok := st.SessionInfo(firstSession); ok { + t.Fatal("reissue left old restricted session active") + } + secondSession, owner, err := st.RedeemLoginKey(second) + if err != nil || owner != "alice" { + t.Fatalf("redeem second activation: %v %q", err, owner) + } + hash, err := HashPassword("alice-initialized-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(secondSession, hash); err != nil { + t.Fatalf("complete initial password: %v", err) + } + completed, err := st.GetAccount(pending.ID) + if err != nil || completed.ID != pending.ID || completed.Owner != pending.Owner || completed.InitializationState != InitializationInitialized || + completed.CredentialOrigin != CredentialOriginOwnerInitialization || !completed.HasPassword { + t.Fatalf("completion changed identity or lost trusted lineage: %+v %v", completed, err) + } + if _, err := st.InitializePendingAccount(pending.ID, completed.StateVersion, completed.InitializationGeneration); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("completed account could be initialized again: %v", err) + } +} diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go new file mode 100644 index 0000000..58efd41 --- /dev/null +++ b/internal/store/recovery_proposals.go @@ -0,0 +1,438 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +var ErrOwnerProofUnavailable = errors.New("store: no eligible preexisting owner proof") + +const ( + RecoveryProposalPending = "pending" + RecoveryProposalConfirmed = "confirmed" + RecoveryProposalCompleted = "completed" + RecoveryProposalRevoked = "revoked" + RecoveryProposalExpired = "expired" + recoveryProposalTTL = 24 * time.Hour + recoveryGrantTTL = 5 * time.Minute +) + +// RecoveryProposal is metadata only. No proof-session digest or grant material +// is included in the admin or owner read projection. +type RecoveryProposal struct { + ID string `json:"id"` + UserID string `json:"userId"` + Owner string `json:"owner"` + Status string `json:"status"` + Version int64 `json:"version"` + Generation int64 `json:"generation"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + AuthVersion int `json:"-"` +} + +func (s *Store) recoveryProposal(q accountQuerier, id string, lock bool) (RecoveryProposal, error) { + query := s.q(`SELECT id,user_id,owner,status,version,generation,created_by,created_at,expires_at,issued_auth_version + FROM recovery_proposals WHERE id=?`) + if lock && s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var p RecoveryProposal + var created, expires string + err := q.QueryRow(query, id).Scan(&p.ID, &p.UserID, &p.Owner, &p.Status, &p.Version, &p.Generation, + &p.CreatedBy, &created, &expires, &p.AuthVersion) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + if err != nil { + return p, err + } + p.CreatedAt, err = time.Parse(time.RFC3339Nano, created) + if err != nil { + return p, err + } + p.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) + if err != nil { + return p, err + } + return p, nil +} + +func newRecoveryGrant() (plain, id string, err error) { + var raw [32]byte + if _, err = rand.Read(raw[:]); err != nil { + return "", "", err + } + return "alk_" + hex.EncodeToString(raw[:]), newID("lk"), nil +} + +// CreateRecoveryProposal records precisely the currently eligible full +// password sessions. Later logins are not eligible proof for this proposal. +func (s *Store) CreateRecoveryProposal(actorOwner, userID string, expectedStateVersion int64, idempotencyKey string) (RecoveryProposal, error) { + if actorOwner == "" || userID == "" || expectedStateVersion < 1 || idempotencyKey == "" || len(idempotencyKey) > 128 || strings.TrimSpace(idempotencyKey) != idempotencyKey { + return RecoveryProposal{}, ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return RecoveryProposal{}, err + } + tx, err := s.db.Begin() + if err != nil { + return RecoveryProposal{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) + if errors.Is(err, ErrNotFound) { + return RecoveryProposal{}, ErrAccountForbidden + } + if err != nil { + return RecoveryProposal{}, err + } + actor, target := locked[actorOwner], locked[pre.Owner] + if !actor.Enabled || !actor.IsAdmin || !target.Enabled { + return RecoveryProposal{}, ErrAccountForbidden + } + if target.ID != userID || target.StateVersion != expectedStateVersion { + return RecoveryProposal{}, ErrConflict + } + var existingID string + err = tx.QueryRow(s.q(`SELECT id FROM recovery_proposals WHERE created_by=? AND idempotency_key=?`), actorOwner, idempotencyKey).Scan(&existingID) + if err == nil { + existing, err := s.recoveryProposal(tx, existingID, false) + if err != nil || existing.UserID != userID { + return RecoveryProposal{}, ErrConflict + } + return existing, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return RecoveryProposal{}, err + } + now := time.Now().UTC() + active, err := tx.Query(s.q(`SELECT id,expires_at FROM recovery_proposals WHERE owner=? AND status IN (?,?)`), target.Owner, RecoveryProposalPending, RecoveryProposalConfirmed) + if err != nil { + return RecoveryProposal{}, err + } + var expiredIDs []string + for active.Next() { + var id, expires string + if err := active.Scan(&id, &expires); err != nil { + active.Close() + return RecoveryProposal{}, err + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || now.Before(expiry) { + active.Close() + return RecoveryProposal{}, ErrConflict + } + expiredIDs = append(expiredIDs, id) + } + if err := active.Err(); err != nil { + active.Close() + return RecoveryProposal{}, err + } + active.Close() + for _, id := range expiredIDs { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1 WHERE id=?`), RecoveryProposalExpired, id); err != nil { + return RecoveryProposal{}, err + } + } + if !target.CredentialOrigin.TrustedForOwnerProof() || !target.HasPassword || target.MustChangePassword { + return RecoveryProposal{}, ErrOwnerProofUnavailable + } + rows, err := tx.Query(s.q(`SELECT token_hash,expires_at,credential_origin FROM sessions WHERE owner=? AND scope=? AND auth_method=? AND auth_version=?`), + target.Owner, SessionScopeFull, AuthMethodPassword, target.AuthVersion) + if err != nil { + return RecoveryProposal{}, err + } + var proofs []string + for rows.Next() { + var digest, expires, origin string + if err := rows.Scan(&digest, &expires, &origin); err != nil { + rows.Close() + return RecoveryProposal{}, err + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err == nil && now.Before(expiry) && CredentialOrigin(origin) == target.CredentialOrigin { + proofs = append(proofs, digest) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return RecoveryProposal{}, err + } + rows.Close() + if len(proofs) == 0 { + return RecoveryProposal{}, ErrOwnerProofUnavailable + } + p := RecoveryProposal{ID: newID("rp"), UserID: target.ID, Owner: target.Owner, Status: RecoveryProposalPending, + Version: 1, Generation: 0, CreatedBy: actorOwner, CreatedAt: now, ExpiresAt: now.Add(recoveryProposalTTL), AuthVersion: target.AuthVersion} + if _, err := tx.Exec(s.q(`INSERT INTO recovery_proposals(id,user_id,owner,status,version,generation,created_by,created_at,expires_at,issued_auth_version,idempotency_key) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), p.ID, p.UserID, p.Owner, p.Status, p.Version, p.Generation, p.CreatedBy, + p.CreatedAt.Format(time.RFC3339Nano), p.ExpiresAt.Format(time.RFC3339Nano), p.AuthVersion, idempotencyKey); err != nil { + if isUniqueViolation(err) { + return RecoveryProposal{}, ErrConflict + } + return RecoveryProposal{}, err + } + for _, digest := range proofs { + if _, err := tx.Exec(s.q(`INSERT INTO recovery_proposal_proofs(proposal_id,session_hash) VALUES(?,?)`), p.ID, digest); err != nil { + return RecoveryProposal{}, err + } + } + if err := s.insertAccountEvent(tx, actorOwner, "admin-http", target.Owner, "recovery_proposed", p.ID); err != nil { + return RecoveryProposal{}, err + } + return p, tx.Commit() +} + +func (s *Store) eligibleRecoveryProof(tx *sql.Tx, account Account, proposal RecoveryProposal, token string) error { + query := s.q(`SELECT owner,scope,auth_method,credential_origin,auth_version,expires_at FROM sessions WHERE token_hash=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner, scope, method, origin, expires string + var version int + err := tx.QueryRow(query, hashKey(token)).Scan(&owner, &scope, &method, &origin, &version, &expires) + if err != nil { + return ErrOwnerProofUnavailable + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || time.Now().After(expiry) || owner != account.Owner || scope != SessionScopeFull || method != AuthMethodPassword || + CredentialOrigin(origin) != account.CredentialOrigin || !CredentialOrigin(origin).TrustedForOwnerProof() || version != account.AuthVersion { + return ErrOwnerProofUnavailable + } + var bound int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM recovery_proposal_proofs WHERE proposal_id=? AND session_hash=?`), proposal.ID, hashKey(token)).Scan(&bound); err != nil || bound != 1 { + return ErrOwnerProofUnavailable + } + return nil +} + +func (s *Store) issueConfirmedRecoveryGrant(tx *sql.Tx, p RecoveryProposal, account Account, plain, id string, now time.Time) error { + _, err := s.insertLoginKeyTx(tx, p.Owner, LoginKeyPurposeRecover, "owner-proof:"+p.Owner, plain, id, now, now.Add(recoveryGrantTTL), + grantBinding{origin: CredentialOriginOwnerRecovery, generation: p.Generation, version: account.AuthVersion, proposalID: p.ID}) + return err +} + +func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersion, expectedGeneration int64, rotate bool) (string, RecoveryProposal, error) { + pre, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return "", RecoveryProposal{}, err + } + plain, id, err := newRecoveryGrant() + if err != nil { + return "", RecoveryProposal{}, err + } + tx, err := s.db.Begin() + if err != nil { + return "", RecoveryProposal{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, pre.Owner) + if err != nil { + return "", RecoveryProposal{}, err + } + account := locked[pre.Owner] + p, err := s.recoveryProposal(tx, proposalID, true) + if err != nil { + return "", RecoveryProposal{}, err + } + if !account.Enabled || account.ID != p.UserID || account.AuthVersion != p.AuthVersion || account.MustChangePassword || !account.CredentialOrigin.TrustedForOwnerProof() || + p.Version != expectedVersion || p.Generation != expectedGeneration || time.Now().After(p.ExpiresAt) { + return "", RecoveryProposal{}, ErrConflict + } + if (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) { + return "", RecoveryProposal{}, ErrConflict + } + if err := s.eligibleRecoveryProof(tx, account, p, token); err != nil { + return "", RecoveryProposal{}, err + } + now := time.Now().UTC() + newGeneration := p.Generation + 1 + newStatus := RecoveryProposalConfirmed + res, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1,generation=? WHERE id=? AND version=? AND generation=? AND status=?`), + newStatus, newGeneration, p.ID, p.Version, p.Generation, p.Status) + if err != nil { + return "", RecoveryProposal{}, err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return "", RecoveryProposal{}, ErrConflict + } + p.Version++ + p.Generation = newGeneration + p.Status = newStatus + if err := s.issueConfirmedRecoveryGrant(tx, p, account, plain, id, now); err != nil { + return "", RecoveryProposal{}, err + } + action := "recovery_confirmed" + if rotate { + action = "recovery_grant_rotated" + } + if err := s.insertAccountEvent(tx, p.Owner, "owner-http", p.Owner, action, fmt.Sprintf("proposal=%s generation=%d", p.ID, p.Generation)); err != nil { + return "", RecoveryProposal{}, err + } + if err := tx.Commit(); err != nil { + return "", RecoveryProposal{}, err + } + return plain, p, nil +} + +func (s *Store) ConfirmRecoveryProposal(token, proposalID string, expectedVersion int64) (string, RecoveryProposal, error) { + return s.confirmOrRotateRecovery(token, proposalID, expectedVersion, 0, false) +} + +func (s *Store) RotateRecoveryGrant(token, proposalID string, expectedVersion, expectedGeneration int64) (string, RecoveryProposal, error) { + return s.confirmOrRotateRecovery(token, proposalID, expectedVersion, expectedGeneration, true) +} + +func effectiveRecoveryProposal(p RecoveryProposal) RecoveryProposal { + if (p.Status == RecoveryProposalPending || p.Status == RecoveryProposalConfirmed) && time.Now().After(p.ExpiresAt) { + p.Status = RecoveryProposalExpired + } + return p +} + +// ListOwnerRecoveryProposals returns only non-secret metadata. The HTTP layer +// obtains owner from a live full password session, never a request parameter. +func (s *Store) ListOwnerRecoveryProposals(owner string) ([]RecoveryProposal, error) { + rows, err := s.db.Query(s.q(`SELECT id FROM recovery_proposals WHERE owner=? ORDER BY created_at DESC LIMIT 50`), owner) + if err != nil { + return nil, err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + proposals := make([]RecoveryProposal, 0, len(ids)) + for _, id := range ids { + p, err := s.recoveryProposal(s.db, id, false) + if err != nil { + return nil, err + } + proposals = append(proposals, effectiveRecoveryProposal(p)) + } + return proposals, nil +} + +func (s *Store) GetRecoveryProposalForAdmin(actorOwner, userID, proposalID string) (RecoveryProposal, error) { + actor, err := s.GetAccountByOwner(actorOwner) + if err != nil || !actor.Enabled || !actor.IsAdmin { + return RecoveryProposal{}, ErrAccountForbidden + } + p, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return RecoveryProposal{}, err + } + if p.UserID != userID { + return RecoveryProposal{}, ErrNotFound + } + return effectiveRecoveryProposal(p), nil +} + +func (s *Store) ListRecoveryProposalsForAdmin(actorOwner, userID string) ([]RecoveryProposal, error) { + actor, err := s.GetAccountByOwner(actorOwner) + if err != nil || !actor.Enabled || !actor.IsAdmin { + return nil, ErrAccountForbidden + } + target, err := s.GetAccount(userID) + if err != nil { + return nil, err + } + proposals, err := s.ListOwnerRecoveryProposals(target.Owner) + if err != nil { + return nil, err + } + filtered := proposals[:0] + for _, proposal := range proposals { + if proposal.UserID == userID { + filtered = append(filtered, proposal) + } + } + return filtered, nil +} + +func (s *Store) GetOwnerRecoveryProposal(owner, proposalID string) (RecoveryProposal, error) { + p, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return RecoveryProposal{}, err + } + if p.Owner != owner { + return RecoveryProposal{}, ErrNotFound + } + return effectiveRecoveryProposal(p), nil +} + +// RevokeRecoveryProposal checks the path target, actor and expected version in +// the same transaction as revoking grant handles and derived restricted sessions. +func (s *Store) RevokeRecoveryProposal(actorOwner, userID, proposalID string, expectedVersion int64) (RecoveryProposal, error) { + pre, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return RecoveryProposal{}, err + } + if pre.UserID != userID || expectedVersion < 1 { + return RecoveryProposal{}, ErrNotFound + } + tx, err := s.db.Begin() + if err != nil { + return RecoveryProposal{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) + if errors.Is(err, ErrNotFound) { + return RecoveryProposal{}, ErrAccountForbidden + } + if err != nil { + return RecoveryProposal{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return RecoveryProposal{}, ErrAccountForbidden + } + p, err := s.recoveryProposal(tx, proposalID, true) + if err != nil { + return RecoveryProposal{}, err + } + if p.UserID != userID { + return RecoveryProposal{}, ErrNotFound + } + if p.Version != expectedVersion || (p.Status != RecoveryProposalPending && p.Status != RecoveryProposalConfirmed) { + return RecoveryProposal{}, ErrConflict + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE grant_id IN (SELECT id FROM login_keys WHERE proposal_id=?)`), p.ID); err != nil { + return RecoveryProposal{}, err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE proposal_id=? AND status=?`), LoginKeyStatusRevoked, p.ID, LoginKeyStatusUnused); err != nil { + return RecoveryProposal{}, err + } + res, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1 WHERE id=? AND version=?`), RecoveryProposalRevoked, p.ID, expectedVersion) + if err != nil { + return RecoveryProposal{}, err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return RecoveryProposal{}, ErrConflict + } + if err := s.insertAccountEvent(tx, actorOwner, "admin-http", p.Owner, "recovery_revoked", p.ID); err != nil { + return RecoveryProposal{}, err + } + if err := tx.Commit(); err != nil { + return RecoveryProposal{}, err + } + p.Status, p.Version = RecoveryProposalRevoked, p.Version+1 + return p, nil +} diff --git a/internal/store/recovery_proposals_test.go b/internal/store/recovery_proposals_test.go new file mode 100644 index 0000000..767ed3a --- /dev/null +++ b/internal/store/recovery_proposals_test.go @@ -0,0 +1,220 @@ +package store + +import ( + "errors" + "sync" + "testing" + "time" +) + +func trustedRecoveryFixture(t *testing.T, st *Store) (Account, string) { + t.Helper() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + account, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + activation, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + setup, _, err := st.RedeemLoginKey(activation) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("initial-owner-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(setup, hash); err != nil { + t.Fatal(err) + } + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + _, version, valid := st.VerifyLoginWithVersion("alice", "initial-owner-password") + if !valid { + t.Fatal("trusted owner could not log in") + } + session, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version) + if err != nil { + t.Fatal(err) + } + return account, session +} + +func TestOwnerRecoveryConcurrentConfirmAndDisableAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("owner recovery concurrency dialect=%s two independent pools", st.Dialect()) + account, proof := trustedRecoveryFixture(t, st) + proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-1") + if err != nil { + t.Fatal(err) + } + type result struct { + grant string + err error + } + start := make(chan struct{}) + confirmed := make(chan result, 1) + disabled := make(chan error, 1) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + grant, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + confirmed <- result{grant: grant, err: err} + }() + go func() { + defer wg.Done() + <-start + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disabled <- err + }() + close(start) + wg.Wait() + if err := <-disabled; err != nil { + t.Fatalf("disable lost race: %v", err) + } + out := <-confirmed + if out.err == nil && out.grant == "" { + t.Fatal("confirm succeeded without a grant") + } + current, err := st.GetAccount(account.ID) + if err != nil || current.Enabled { + t.Fatalf("target remained effective after disable: %+v %v", current, err) + } + if out.grant != "" { + if _, _, err := other.RedeemLoginKey(out.grant); err == nil { + t.Fatal("grant survived concurrent disable") + } + } +} + +func TestOwnerRecoveryConcurrentConfirmHasOneWinnerAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("owner confirmation dialect=%s two independent pools", st.Dialect()) + account, proof := trustedRecoveryFixture(t, st) + proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-2") + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for _, conn := range []*Store{st, other} { + wg.Add(1) + go func(conn *Store) { + defer wg.Done() + <-start + _, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + results <- err + }(conn) + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("concurrent confirmation wins=%d conflicts=%d", wins, conflicts) + } +} + +func TestOwnerProofRecoveryBindsPreexistingSessionAndRotatesGrant(t *testing.T) { + st := openTestStore(t) + t.Logf("owner recovery dialect=%s", st.Dialect()) + account, proof := trustedRecoveryFixture(t, st) + proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "request-1") + if err != nil || proposal.Status != RecoveryProposalPending || proposal.ID == "" { + t.Fatalf("proposal: %+v %v", proposal, err) + } + _, version, _ := st.VerifyLoginWithVersion("alice", "initial-owner-password") + lateSession, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.ConfirmRecoveryProposal(lateSession, proposal.ID, proposal.Version); !errors.Is(err, ErrOwnerProofUnavailable) { + t.Fatalf("post-proposal session became proof: %v", err) + } + first, confirmed, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + if err != nil || first == "" || confirmed.Status != RecoveryProposalConfirmed { + t.Fatalf("confirm: %+v %v", confirmed, err) + } + second, rotated, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation) + if err != nil || second == first || rotated.Generation != confirmed.Generation+1 { + t.Fatalf("rotate: %+v %v", rotated, err) + } + if _, _, err := st.RedeemLoginKey(first); err == nil { + t.Fatal("rotated grant remained redeemable") + } + restricted, owner, err := st.RedeemLoginKey(second) + if err != nil || owner != "alice" { + t.Fatalf("redeem owner recovery: %v %q", err, owner) + } + newHash, err := HashPassword("recovered-owner-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(restricted, newHash); err != nil { + t.Fatalf("finish recovery: %v", err) + } + current, err := st.GetAccount(account.ID) + if err != nil || current.ID != account.ID || current.CredentialOrigin != CredentialOriginOwnerRecovery { + t.Fatalf("recovery identity or lineage changed: %+v %v", current, err) + } + if _, ok := st.VerifyLogin("alice", "recovered-owner-password"); !ok { + t.Fatal("recovered password login failed") + } + if _, _, ok := st.SessionInfo(proof); ok { + t.Fatal("old proof session survived recovery") + } + if _, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, rotated.Version); err == nil { + t.Fatal("completed proposal confirmed twice") + } +} + +func TestLegacyLineageHasNoOnlineOwnerProof(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + legacy, err := HashPassword("legacy-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy", "", legacy, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("legacy"); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("legacy") + if err != nil { + t.Fatal(err) + } + _, version, ok := st.VerifyLoginWithVersion("legacy", "legacy-password-1") + if !ok { + t.Fatal("legacy password login should remain usable") + } + if _, err := st.CreateSessionVerified("legacy", SessionScopeFull, time.Hour, version); err != nil { + t.Fatal(err) + } + if _, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "legacy-attempt"); !errors.Is(err, ErrOwnerProofUnavailable) { + t.Fatalf("legacy password session became proof: %v", err) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 55edadf..01a265d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -549,6 +549,9 @@ func TestAdminAccountsAndSessions(t *testing.T) { if err := st.SetUserPassword("admin", hash2); err != nil { t.Fatal(err) } + if _, ok := st.SessionOwner(tok); ok { + t.Fatal("password reset must revoke prior session") + } if err := st.ClearMustChangePassword("admin"); err != nil { t.Fatal(err) } @@ -556,6 +559,10 @@ func TestAdminAccountsAndSessions(t *testing.T) { if st1.MustChangePassword { t.Fatal("must_change should clear after self-change") } + tok, err = st.CreateSession("admin") + if err != nil { + t.Fatal(err) + } st.DeleteUserSessions("admin", tok) if _, ok := st.SessionOwner(tok); !ok { t.Fatal("kept session (except-path) must survive") diff --git a/pkg/extension/account_directory.go b/pkg/extension/account_directory.go index 6be3a9d..5a5dfac 100644 --- a/pkg/extension/account_directory.go +++ b/pkg/extension/account_directory.go @@ -55,6 +55,9 @@ type AccountDirectoryLookup struct { type AccountDirectoryProvider interface { QueryAccountDirectory(context.Context, AccountDirectoryQuery) (AccountDirectoryPage, error) LookupAccountDirectory(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) + // LookupAccountDirectoryOwner is an explicit legacy-owner compatibility + // lookup; it resolves to the same stable user ID and applies the same scope. + LookupAccountDirectoryOwner(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) } // AccountDirectoryScopeRequest is constructed by the Backend from the trusted @@ -70,12 +73,14 @@ type AccountDirectoryScopeRequest struct { // AccountDirectoryScope is returned by the trusted in-process domain resolver. // AllCandidates is explicit: a nil candidate list never means global access. -// Candidates are stable user IDs and are applied before search and pagination. +// CandidateIDs or CandidateOwners are applied before search and pagination; +// domain membership stores may use owners while the provider returns stable IDs. type AccountDirectoryScope struct { AllowedFields []string MatchFields []string AllCandidates bool CandidateIDs []string + CandidateOwners []string ScopeVersion string } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 25f7d2b..8a682ad 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -5,6 +5,7 @@ AccountDirectoryModeFuzzy AccountDirectoryPage AccountDirectoryProvider AccountDirectoryProvider.LookupAccountDirectory +AccountDirectoryProvider.LookupAccountDirectoryOwner AccountDirectoryProvider.QueryAccountDirectory AccountDirectoryPurposeDocumentDuties AccountDirectoryPurposeProjectMembers