fix: 改密计入锁定并清理过期管理员会话

This commit is contained in:
Nixevol
2026-09-30 16:22:48 +08:00
parent b3787a0471
commit 1cb562fb71
4 changed files with 108 additions and 2 deletions
+9
View File
@@ -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`**
+75
View File
@@ -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)
}
}
+21 -2
View File
@@ -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{})
+3
View File
@@ -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(),