Merge origin/main into feature/rdm-increment-account-search
ci / vet-and-test (pull_request) Successful in 47s

This commit is contained in:
2026-10-13 06:21:41 +00:00
14 changed files with 471 additions and 88 deletions
+52 -11
View File
@@ -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)
+1
View File
@@ -296,6 +296,7 @@ var copyOrder = []string{
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
"agent_system_identity",
"account_events",
"redeem_attempts",
}
const controlDDL = `
+7 -12
View File
@@ -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"))
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 失败链测试)
+8 -30
View File
@@ -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
}
+22 -10
View File
@@ -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 去重。
+91
View File
@@ -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)
}
}
+140
View File
@@ -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
}
+9 -9
View File
@@ -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
}
+9
View File
@@ -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
}
+107 -14
View File
@@ -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 == "" {
+22
View File
@@ -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`,
}}},
)
}
+1
View File
@@ -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 版本。