From bc1e0a36f7163abd65e90a007d6ef31b84e9861c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 06:05:45 +0000 Subject: [PATCH] Close the account lifecycle leftovers: atomicity, bootstrap lock, redeem guard, credential delivery Why: The login recovery contract leftovers and KTD12/KTD14 require: concurrent same-name account creation must not overwrite the winner, first-admin bootstrap must serialize across processes, the redeem endpoint backoff must be shared across instances, revoked API keys must close established SSE streams, and CLI credentials need an env-DSN path plus a non-interactive 0600 file delivery mode. Evidence: go test ./... green; new tests cover 16-way concurrent create (1 win/15 conflict, winner credentials intact), 8-way concurrent bootstrap (single admin, refuse takeover after), redeem guard shared across two store handles on one DB, SSE stream closed after key revocation within the recheck window, and HTTP 429 after the failure cap (SELF worktree run; CI is the GATE, PG via test-pg). Impact: Migration v29 (redeem_attempts); CreateAccount becomes a single-row INSERT with unique-violation conflict; BootstrapFirstAdmin advisory-locks (PG) / rechecks (SQLite) and commits admin+key+audit in one transaction; SSE revalidation covers API-key streams; redeem backoff moves to the shared DB; CLI gains AGENT_SYSTEM_DSN fallback and --out file delivery for create/reset-password/issue-login-key/ bootstrap-admin. Refs: docs/svc-deploy/2026-10-13-login-recovery-contract.md leftovers; proposal r2 KTD12/KTD14/R5/R11. --- cmd/agent-system/main.go | 63 ++++++++-- cmd/storage-migrate/main.go | 1 + internal/app/app.go | 19 ++- internal/genui/genui_test.go | 2 +- internal/runtime/llm_test.go | 2 +- internal/server/accounts.go | 38 ++---- internal/server/server.go | 32 ++++-- internal/server/sse_close_test.go | 91 +++++++++++++++ internal/store/account_lifecycle_test.go | 140 +++++++++++++++++++++++ internal/store/auth.go | 18 +-- internal/store/dialect.go | 9 ++ internal/store/login_keys.go | 121 +++++++++++++++++--- internal/store/migrate.go | 22 ++++ internal/store/migrate_compat.go | 1 + 14 files changed, 471 insertions(+), 88 deletions(-) create mode 100644 internal/store/account_lifecycle_test.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f529c67..f06c6f8 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -238,6 +238,28 @@ func runAPIKey(args []string) int { } } +// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报 +// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。 +func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error { + if outPath == "" { + fmt.Println(plaintext) + return nil + } + payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} + if expiresAt != nil { + payload["expiresAt"] = expiresAt + } + buf, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(outPath, buf, 0o600); 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 兼容旧随机密码路径。 func runUser(args []string) int { @@ -260,6 +282,7 @@ func runUser(args []string) int { ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)") revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话") keyID := fs.String("id", "", "revoke-login-key 凭据编号") + out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)") fs.Parse(args[1:]) switch sub { case "create": @@ -281,11 +304,16 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - fmt.Println(pw) - if *password == "" && !*generatePassword { - fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") - } else if *password == "" { - fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") + if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil { + 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 "reset-password": @@ -298,8 +326,11 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user reset-password:", err) return 1 } - fmt.Println(pw) - if *password == "" { + 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 @@ -326,8 +357,13 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user issue-login-key:", err) return 1 } - fmt.Println(plain) - fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") + 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 case "revoke-login-key": if *keyID == "" { @@ -350,8 +386,13 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) return 1 } - fmt.Println(plain) - fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") + if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { + 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/storage-migrate/main.go b/cmd/storage-migrate/main.go index 0bb6943..bdb1561 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -296,6 +296,7 @@ var copyOrder = []string{ // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) "agent_system_identity", "account_events", + "redeem_attempts", } const controlDDL = ` diff --git a/internal/app/app.go b/internal/app/app.go index 1658497..5955026 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -790,21 +790,12 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error }) } -// BootstrapAdmin 仅当库无任何用户时创建首位管理员并签发 activate Key。 +// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查), +// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。 func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { var plain string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - users, err := st.ListUsersAdmin() - if err != nil { - return err - } - if len(users) > 0 { - return fmt.Errorf("bootstrap-admin: users already exist (%d); refuse takeover", len(users)) - } - if err := st.CreateAccount(owner, owner, "", true); err != nil { - return err - } - p, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli-bootstrap", 0) + p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap") plain = p return err }) @@ -857,6 +848,10 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { var st *store.Store var err error + // DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。 + if storageDSN == "" { + storageDSN = os.Getenv("AGENT_SYSTEM_DSN") + } if storageDSN != "" { if openExisting { st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index b246db7..40a3dab 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -226,7 +226,7 @@ func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorize func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error { return nil } -func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } +func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil } func TestPresentRejectsUnknownActionIDs(t *testing.T) { diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 79f599c..067488e 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -45,7 +45,7 @@ type fakeRunCtx struct { memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用) usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用) bgCalls int - runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用) + runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用) attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用) spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error) execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试) diff --git a/internal/server/accounts.go b/internal/server/accounts.go index b1afaa5..68e7697 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -61,33 +61,12 @@ func loginClear(owner string) { delete(loginGuard.fails, owner) } -// 兑换入口退避(进程内,按来源 IP;Key 无属主可先行归因,只能按来源限流)。 -var redeemGuard = struct { - sync.Mutex - fails map[string][]time.Time -}{fails: map[string][]time.Time{}} - -const redeemMaxFails = 10 - -func redeemLocked(ip string) bool { - redeemGuard.Lock() - defer redeemGuard.Unlock() - cut := time.Now().Add(-loginLockWindow) - kept := redeemGuard.fails[ip][:0] - for _, t := range redeemGuard.fails[ip] { - if t.After(cut) { - kept = append(kept, t) - } - } - redeemGuard.fails[ip] = kept - return len(kept) >= redeemMaxFails -} - -func redeemFail(ip string) { - redeemGuard.Lock() - defer redeemGuard.Unlock() - redeemGuard.fails[ip] = append(redeemGuard.fails[ip], time.Now()) -} +// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流; +// Key 无属主可先行归因,只能按来源 IP 限流。 +const ( + redeemMaxFails = 10 + redeemWindow = loginLockWindow +) // requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。 func requestSecure(r *http.Request) bool { @@ -120,7 +99,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { if xf := r.Header.Get("X-Forwarded-For"); xf != "" { ip = strings.TrimSpace(strings.Split(xf, ",")[0]) } - if redeemLocked(ip) { + if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked { http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) return } @@ -133,12 +112,11 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { } token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key)) if err != nil { + _ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked if errors.Is(err, store.ErrConflict) { - redeemFail(ip) http.Error(w, "login key already used", http.StatusConflict) return } - redeemFail(ip) http.Error(w, "invalid or expired login key", http.StatusUnauthorized) return } diff --git a/internal/server/server.go b/internal/server/server.go index 135c019..c3f0596 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -476,18 +476,30 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { since, _ = strconv.ParseInt(q, 10, 64) } - // 会话凭证的长连接复验:密码写入会删除会话行;API key 凭证无会话可失效, - // 撤销 Key 的断流另行处理(login 恢复合同 §6.7)。 - sessionTok := "" - if AuthVia(r) == AuthViaSession { + // 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除); + // API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在 + // 复验周期内核验属主未变。 + sessionTok, keyCred := "", "" + switch AuthVia(r) { + case AuthViaSession: sessionTok = sessionToken(r) - } - stillAuthed := func() bool { - if sessionTok == "" { - return true + case AuthViaKey: + keyCred = bearerKey(r) + if keyCred == "" { + keyCred = r.URL.Query().Get("key") } - o, _, ok := s.st.SessionInfo(sessionTok) - return ok && o == s.owner(r) + } + wantOwner := s.owner(r) + stillAuthed := func() bool { + if sessionTok != "" { + o, _, ok := s.st.SessionInfo(sessionTok) + return ok && o == wantOwner + } + if keyCred != "" { + o, ok := s.st.AuthOwner(keyCred) + return ok && o == wantOwner + } + return true } // 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。 diff --git a/internal/server/sse_close_test.go b/internal/server/sse_close_test.go index df8fb0e..706604d 100644 --- a/internal/server/sse_close_test.go +++ b/internal/server/sse_close_test.go @@ -113,3 +113,94 @@ func TestSSESessionInvalidationClosesStream(t *testing.T) { t.Fatal("stream not closed within recheck window after session invalidation") } } + +// API key 凭证的流:撤销后同样在复验周期内断流(合同 §6.7 补齐)。 +func TestSSEAPIKeyRevocationClosesStream(t *testing.T) { + old := sseAuthRecheck + sseAuthRecheck = 300 * time.Millisecond + t.Cleanup(func() { sseAuthRecheck = old }) + + srv, st := newAuthTestServer(t) + h := srv.Handler() + + if err := st.EnsureUser("kia", ""); err != nil { + t.Fatal(err) + } + key, err := st.CreateAPIKey("kia", "sse") + if err != nil { + t.Fatal(err) + } + proj, err := st.CreateProject("kia", "sse-proj") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("kia", proj.ID, "", "") + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID+"&key="+key, nil) + if err != nil { + t.Fatal(err) + } + + resCh := make(chan string, 1) + go func() { + resp, err := ts.Client().Do(req) + if err != nil { + resCh <- "err:" + err.Error() + return + } + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + last := "" + for sc.Scan() { + if line := sc.Text(); line != "" { + last = line + } + } + resCh <- last + }() + + // 等首帧(retry 行)出现后撤销该 Key。 + time.Sleep(300 * time.Millisecond) + if _, err := st.RevokeAPIKeyByName("kia", "sse"); err != nil { + t.Fatal(err) + } + + select { + case got := <-resCh: + if !strings.Contains(got, "session-expired") { + t.Fatalf("stream should end with session-expired marker, got %q", got) + } + case <-time.After(3 * time.Second): + t.Fatal("stream not closed within recheck window after key revocation") + } +} + +// 兑换入口限流:同源连续失败达上限后返回 429(跨实例共担由 store 测试覆盖)。 +func TestRedeemRateLimited(t *testing.T) { + srv, _ := newAuthTestServer(t) + h := srv.Handler() + for i := 0; i < redeemMaxFails; i++ { + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_not_a_real_key_at_all"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) + } + } + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_other"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("want 429 after %d failures, got %d", redeemMaxFails, rec.Code) + } +} diff --git a/internal/store/account_lifecycle_test.go b/internal/store/account_lifecycle_test.go new file mode 100644 index 0000000..10a1722 --- /dev/null +++ b/internal/store/account_lifecycle_test.go @@ -0,0 +1,140 @@ +package store + +// 账户生命周期并发合同(R5/R11/KTD12,2026-10-13 收尾): +// 并发建号不覆盖、首次引导单胜、兑换退避跨实例共担。 + +import ( + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +// 并发建号同名:恰一胜;胜者角色/密码不被败者覆盖(旧实现 UPDATE 步会互相覆盖)。 +func TestConcurrentCreateAccountNoOverwrite(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "shared.db") + st, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + hash, err := HashPassword("winner-pass-1") + if err != nil { + t.Fatal(err) + } + var wins, losses atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + admin := i%2 == 0 // 一半以 admin 建同名账户 + wg.Add(1) + go func(admin bool) { + defer wg.Done() + if err := st.CreateAccount("dupe", "", hash, admin); err == nil { + wins.Add(1) + } else if err == ErrConflict { + losses.Add(1) + } + }(admin) + } + wg.Wait() + if wins.Load() != 1 || losses.Load() != 15 { + t.Fatalf("wins=%d losses=%d, want 1/15", wins.Load(), losses.Load()) + } + got, err := st.GetUserAuthState("dupe") + if err != nil { + t.Fatal(err) + } + // 胜者的 hash 原样保留(没有第二个写入者覆盖密码)。 + if !got.HasPassword { + t.Fatal("winner password must survive concurrent creates") + } + if _, ok := st.VerifyLogin("dupe", "winner-pass-1"); !ok { + t.Fatal("winner credentials must verify") + } +} + +// 首次管理员引导并发:两个并发引导恰一胜;已含用户时拒绝接管。 +func TestBootstrapFirstAdminSingleWinner(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "boot.db") + st, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + var wins, conflicts atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + owner := "admin-a" + if i%2 == 1 { + owner = "admin-b" + } + wg.Add(1) + go func(owner string) { + defer wg.Done() + if _, err := st.BootstrapFirstAdmin(owner, owner, "test"); err == nil { + wins.Add(1) + } else if err == ErrConflict { + conflicts.Add(1) + } + }(owner) + } + wg.Wait() + if wins.Load() != 1 || conflicts.Load() != 7 { + t.Fatalf("wins=%d conflicts=%d, want 1/7", wins.Load(), conflicts.Load()) + } + users, err := st.ListUsersAdmin() + if err != nil || len(users) != 1 { + t.Fatalf("exactly one admin must exist: %d %v", len(users), err) + } + if !users[0].IsAdmin { + t.Fatal("bootstrapped account must be admin") + } + // 已有用户后再次引导:拒绝接管。 + if _, err := st.BootstrapFirstAdmin("admin-c", "admin-c", "test"); err != ErrConflict { + t.Fatalf("second bootstrap after users exist: %v", err) + } +} + +// 兑换退避跨实例:store A 记录的失败,store B(同库另一连接)同样被限。 +func TestRedeemGuardCrossStore(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "guard.db") + stA, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stA.Close() }) + stB, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + defer stB.Close() + const max = 5 + for i := 0; i < max; i++ { + if err := stA.RecordRedeemFailure("10.0.0.9"); err != nil { + t.Fatal(err) + } + } + if blocked, err := stB.RedeemBlocked("10.0.0.9", max, time.Minute); err != nil || !blocked { + t.Fatalf("store B must see the shared limit: blocked=%v err=%v", blocked, err) + } + if blocked, err := stA.RedeemBlocked("10.0.0.8", max, time.Minute); err != nil || blocked { + t.Fatalf("other ip must pass: blocked=%v err=%v", blocked, err) + } + // 窗口外记录在下次判定时清理。 + if err := stA.drainRedeemWindow(); err != nil { + t.Fatal(err) + } + if blocked, _ := stA.RedeemBlocked("10.0.0.9", max, time.Minute); blocked { + t.Fatal("limit must clear after the window drains") + } +} + +// drainRedeemWindow 测试助手:把窗口内判定用的记录时间整体前移出窗。 +func (s *Store) drainRedeemWindow() error { + _, err := s.db.Exec(s.q(`UPDATE redeem_attempts SET at=?`), time.Now().Add(-2*time.Minute).UTC().Format(time.RFC3339Nano)) + return err +} diff --git a/internal/store/auth.go b/internal/store/auth.go index 0b99bfa..4d31272 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -218,15 +218,11 @@ func VerifyPassword(hash, password string) bool { return subtle.ConstantTimeCompare(got, want) == 1 } -// CreateAccount 管理员建号:owner 唯一。 +// CreateAccount 管理员建号:单事务整行 INSERT,owner 唯一冲突返回 ErrConflict。 // passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。 +// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖 +// 角色与密码——R5/KTD12 并发建号不覆盖。) func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error { - if _, err := s.GetUser(owner); err == nil { - return ErrConflict - } - if err := s.EnsureUser(owner, name); err != nil { - return err - } mustChange := passwordHash != "" setAt := "" if passwordHash != "" { @@ -234,8 +230,12 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er } // 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍, // PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。 - _, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=?, is_admin=? WHERE owner=?`), - name, passwordHash, setAt, mustChange, isAdmin, owner) + _, err := s.db.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(), passwordHash, setAt, mustChange, isAdmin) + if isUniqueViolation(err) { + return ErrConflict + } return err } diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 8e3abd8..9bf2c84 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -17,6 +17,9 @@ type dialect interface { lockScope(tx *sql.Tx, owner, scope string) error // lockAdmission 锁定执行许可账本的清点与登记(U3 admission)。 lockAdmission(tx *sql.Tx) error + // lockBootstrap 锁定首次管理员引导的「核验无用户→建号→激活 Key」串行段 + //(KTD12:防两个进程并发引导出双管理员)。 + lockBootstrap(tx *sql.Tx) error } // ---- SQLite(嵌入式档)---- @@ -28,6 +31,7 @@ func (sqliteDialect) rebind(q string) string { return q } func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil } func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil } func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil } +func (sqliteDialect) lockBootstrap(*sql.Tx) error { return nil } // ---- PostgreSQL(多用户/多实例档)---- @@ -63,3 +67,8 @@ func (postgresDialect) lockAdmission(tx *sql.Tx) error { _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "admission:exec-slots") return err } + +func (postgresDialect) lockBootstrap(tx *sql.Tx) error { + _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "bootstrap:first-admin") + return err +} diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index 2069a59..cea3683 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -112,28 +112,121 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return "", meta, err } defer tx.Rollback() - 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 - } - // 旧 Key 兑换出的受限会话一并作废:补发后只有最新 Key 能进入设密。 - 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 { - return "", meta, err - } - if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { + meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp) + if err != nil { return "", meta, err } if err := tx.Commit(); err != nil { return "", meta, err } - meta = LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy} return plain, meta, nil } +// 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) { + meta := LoginKeyMeta{} + 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 + } + // 旧 Key 兑换出的受限会话一并作废:补发后只有最新 Key 能进入设密。 + 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 { + return meta, err + } + if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { + return meta, err + } + return LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy}, nil +} + +// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 → +// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。 +// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。 +func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + plain = "alk_" + hex.EncodeToString(raw) + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return "", err + } + id := "lk_" + hex.EncodeToString(idRaw) + now := time.Now().UTC() + exp := now.Add(defaultLoginKeyTTL) + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockBootstrap(tx); err != nil { + return "", err + } + var n int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil { + return "", err + } + 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 isUniqueViolation(err) { + return "", ErrConflict + } + return "", err + } + // SQLite 并发兜底:本事务内用户数必须恰为 1(另一引导已提交则回滚)。 + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil { + return "", err + } + if n != 1 { + return "", ErrConflict + } + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return plain, nil +} + +// RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP +// 在窗口内的失败数是否已达上限(只读判定;失败由 RecordRedeemFailure 记录)。 +func (s *Store) RedeemBlocked(ip string, max int, window time.Duration) (bool, error) { + cut := time.Now().Add(-window).UTC().Format(time.RFC3339Nano) + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`DELETE FROM redeem_attempts WHERE at < ?`), cut); err != nil { + return false, err + } + var n int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM redeem_attempts WHERE ip=?`), ip).Scan(&n); err != nil { + return false, err + } + return n >= max, tx.Commit() +} + +// RecordRedeemFailure 记录一次兑换失败(限流窗口内累计)。 +func (s *Store) RecordRedeemFailure(ip string) error { + _, err := s.db.Exec(s.q(`INSERT INTO redeem_attempts(ip, at) VALUES(?,?)`), ip, nowISO()) + return err +} + // entranceOf 按签发者标注入口(审计可归因;cli 前缀归 CLI,其余归管理员 HTTP)。 func entranceOf(createdBy string) string { if createdBy == "" { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index f36444c..4428563 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -819,6 +819,28 @@ func init() { `ALTER TABLE sessions DROP COLUMN auth_version`, `ALTER TABLE users DROP COLUMN auth_version`, }}}, + // 登录恢复收尾(2026-10-13):兑换入口的跨实例失败退避(共享库窗口计数)。 + migration{version: 29, name: "redeem_attempts", + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS redeem_attempts ( + ip TEXT NOT NULL, + at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS redeem_attempts ( + ip TEXT NOT NULL, + at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`, + }}, + down: map[string][]string{"sqlite": { + `DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`, + `DROP TABLE IF EXISTS redeem_attempts`, + }, "postgres": { + `DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`, + `DROP TABLE IF EXISTS redeem_attempts`, + }}}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 5eb403a..ac0347d 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -33,6 +33,7 @@ var migrationBreaksOldReaders = map[int]bool{ 26: false, 27: false, 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 + 29: false, // redeem_attempts:新表,旧读者不受影响 } // SchemaTarget 是本二进制会迁移到的 schema 版本。