Close the account lifecycle leftovers: atomicity, bootstrap lock, redeem guard, credential delivery #192
+52
-11
@@ -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)
|
||||
|
||||
@@ -296,6 +296,7 @@ var copyOrder = []string{
|
||||
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
|
||||
"agent_system_identity",
|
||||
"account_events",
|
||||
"redeem_attempts",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
|
||||
+7
-12
@@ -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"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 失败链测试)
|
||||
|
||||
@@ -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
@@ -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 去重。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 版本。
|
||||
|
||||
Reference in New Issue
Block a user