diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 610dab4..3fbe7f1 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -926,6 +926,15 @@ - 备选方案:导入超限静默改成上限;未采用,改为行错误让管理员改表。 - 影响:超上限返回 400 或 CSV 行错误。 +### 复审修复 H-06 + +- 日期:2026-09-30 +- 原条款:PRD §8 管理员登录防暴力;admin-api 2.4 改密作废其它会话;审查 #49。 +- 实际做法:改管理员密码时旧密码校验计入 `LockAdminIP`,锁定返回 429,成功清零。登录写入会话的同一事务删除过期 `admin_sessions`。作废其它会话失败返回 500 并写审计。 +- 原因:持有 Cookie 可无限猜旧密码;过期会话从不清理;作废失败被忽略。 +- 备选方案:改密失败不返回 401(与契约不符);未采用。W-01 排除改密 401 自动登出。 +- 影响:连续 10 次旧密码错误后锁定;过期会话行在下次登录清除。 + ## 后台网页 W 1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`** diff --git a/internal/admin/h06_test.go b/internal/admin/h06_test.go new file mode 100644 index 0000000..2d16503 --- /dev/null +++ b/internal/admin/h06_test.go @@ -0,0 +1,75 @@ +package admin_test + +import ( + "context" + "database/sql" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "path/filepath" + "testing" + + "git.asio.asia/nixevol/NixMsg/internal/admin" + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/store" +) + +func TestPasswordWrongOldLocksAfterTen(t *testing.T) { + _, srv, client, _ := setup(t) + login(t, client, srv.URL) + base := srv.URL + var last *http.Response + var env envelope + for i := 0; i < 10; i++ { + last = postJSON(t, client, base+"/api/admin/password", + `{"old_password":"not-the-password","new_password":"new-password-12"}`, + map[string]string{"X-Nixmsg-Request": "1"}) + env = decodeEnv(t, last) + } + if last.StatusCode != http.StatusTooManyRequests { + t.Fatalf("want 429 after 10 wrong old passwords, got %d %+v", last.StatusCode, env) + } + if env.Error == nil || env.Error.Code != "rate_limited" { + t.Fatalf("want rate_limited got %+v", env.Error) + } +} + +func TestLoginDeletesExpiredAdminSessions(t *testing.T) { + dir := t.TempDir() + db, err := store.Open(filepath.Join(dir, "data"), "FULL") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + hash := auth.NewStubHashPool() + if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil { + t.Fatal(seedErr) + } + h := admin.New(admin.Deps{ + DB: db, + Hash: hash, + Tokens: admin.NewRandomAPITokens(), + Locks: admin.NewMemoryLoginLocks(), + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + if err := db.Queue.Do(context.Background(), func(tx *sql.Tx) error { + _, e := tx.Exec(`INSERT INTO admin_sessions(token_hash, created_at, expires_at) VALUES ('expired-hash', 1, 1)`) + return e + }); err != nil { + t.Fatal(err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + login(t, client, srv.URL) + var n int + if err := db.Read.QueryRow(`SELECT COUNT(*) FROM admin_sessions WHERE token_hash = 'expired-hash'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("expired session still present, count=%d", n) + } +} diff --git a/internal/admin/login.go b/internal/admin/login.go index 63897a0..972a732 100644 --- a/internal/admin/login.go +++ b/internal/admin/login.go @@ -111,6 +111,13 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { p, _ := principalFrom(r.Context()) ip := httpx.ClientIP(r, h.trusted) + if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked { + w.Header().Set("Retry-After", formatRetryAfter(retry)) + h.audit(actorString(p), "password_change", "", "rate_limited", ip) + httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") + return + } + var req struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` @@ -134,10 +141,18 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { } ok, err := h.hash.Verify(r.Context(), auth.PasswordAdmin, req.OldPassword, phc) if err != nil || !ok { + locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}) + if locked { + w.Header().Set("Retry-After", formatRetryAfter(retry)) + h.audit(actorString(p), "password_change", "", "rate_limited", ip) + httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") + return + } h.audit(actorString(p), "password_change", "", "unauthorized", ip) httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "旧密码错误") return } + h.locks.Clear(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}) newPHC, err := h.hash.Hash(r.Context(), auth.PasswordAdmin, req.NewPassword) if err != nil { h.audit(actorString(p), "password_change", "", "error", ip) @@ -149,9 +164,13 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } - // 保留当前会话,作废其它会话 + // 保留当前会话,作废其它会话;失败则返回 500,避免其它会话继续有效。 if p.Session != "" { - _ = h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session)) + if err := h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session)); err != nil { + h.audit(actorString(p), "password_change", "", "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } } h.audit(actorString(p), "password_change", "", "ok", ip) httpx.WriteOK(w, map[string]any{}) diff --git a/internal/admin/store.go b/internal/admin/store.go index d4e52b9..b736999 100644 --- a/internal/admin/store.go +++ b/internal/admin/store.go @@ -42,6 +42,9 @@ func (h *Handler) setAdminPasswordHash(ctx context.Context, phc string) error { func (h *Handler) createSession(ctx context.Context, hashHex string, ttl time.Duration) error { now := time.Now() return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + if _, err := tx.Exec(`DELETE FROM admin_sessions WHERE expires_at <= ?`, now.UnixMilli()); err != nil { + return err + } _, err := tx.Exec( `INSERT INTO admin_sessions(token_hash, created_at, expires_at) VALUES(?, ?, ?)`, hashHex, now.UnixMilli(), now.Add(ttl).UnixMilli(),