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 版本。