From 8b4da3dc0516fe9415ddfa6d6190d3b71ad3dac8 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 10:43:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E5=91=98=20IP=20?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E4=B8=8D=E5=86=8D=E9=98=BB=E6=96=AD=E5=B7=B2?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=20Cookie=20=E4=B8=8E=20API=20=E4=BB=A4?= =?UTF-8?q?=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 9 +++++ internal/admin/admin_test.go | 76 ++++++++++++++++++++++++++++++++++++ internal/admin/auth.go | 8 +--- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 3ebcd47..d0ea67b 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1092,3 +1092,12 @@ - 原因:同一连接上 `group.create`/`group.add` 同步向本连接注入下行时,与 mochi InlineClient 互相等待,`resp` 回不去(`TestUplinkDMOfflineGroupRecall` 在清掉测试客户端 dial deadline 后稳定复现)。 - 备选方案:broker 层对 Inline 发布做无锁队列。 - 影响:`group_event` 可能略晚于 `resp` 到达;业务结果仍以 `resp` 为准。 + +### fix-issue-1 + +1. **管理员 IP 锁定不再阻断已认证会话** + - 原条款:PRD D18 / F02(密码锁只拦密码登录,不拦已有会话令牌);DEVELOPMENT 第 5/8 节(管理员登录锁定、错误令牌按 IP 计入锁定);issue #1。 + - 实际做法:去掉 `internal/admin/auth.go` 的 `auth()` 鉴权前 `Check(LockAdminIP)`;登录入口仍 `Check`/`Fail`,错误或停用 API 令牌仍经 `authFail` 计入锁定。有效 Cookie 与合法 Bearer 在锁定期可继续调管理接口。 + - 原因:先前把「防暴力登录」扩成「封整个管理面」,同 NAT 下刷错误 Bearer 即可锁死已登录管理员,与端侧 nst_ 重连语义不一致。 + - 备选方案:锁定期对 Cookie 与令牌也拒绝(否决,违背 D18 对齐)。 + - 影响:仅管理后台鉴权中间件;端侧登录锁定未改。 diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index d999114..9df00ed 100644 --- a/internal/admin/admin_test.go +++ b/internal/admin/admin_test.go @@ -280,6 +280,82 @@ func TestLoginLock(t *testing.T) { } } +// TestAdminLockDoesNotBlockAuthedSession:密码失败触发 IP 锁后, +// 已有 Cookie 会话与合法 API 令牌仍可调管理接口;未认证密码登录仍被拒。 +func TestAdminLockDoesNotBlockAuthedSession(t *testing.T) { + _, srv, cookieClient, _ := setup(t) + base := srv.URL + + login(t, cookieClient, base) + + res := postJSON(t, cookieClient, base+"/api/admin/tokens", + `{"name":"ops-lock"}`, + map[string]string{"X-Nixmsg-Request": "1"}) + env := decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("create token: %d %+v", res.StatusCode, env) + } + var created struct { + Token string `json:"token"` + } + if err := json.Unmarshal(env.Data, &created); err != nil { + t.Fatal(err) + } + + for i := 0; i < 10; i++ { + bad := &http.Client{} + res := postJSON(t, bad, base+"/api/admin/login", + `{"username":"admin","password":"wrong-password!!"}`, nil) + env := decodeEnv(t, res) + if i < 9 { + if res.StatusCode != 401 { + t.Fatalf("fail %d: want 401 got %d %+v", i, res.StatusCode, env) + } + continue + } + if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" { + t.Fatalf("10th fail want 429 rate_limited got %d %+v", res.StatusCode, env) + } + } + + res = doReq(t, cookieClient, http.MethodGet, base+"/api/admin/me", "", nil) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("cookie me after lock: want 200 got %d %+v", res.StatusCode, env) + } + var me map[string]any + _ = json.Unmarshal(env.Data, &me) + if me["auth"] != "cookie" { + t.Fatalf("cookie me auth=%v", me["auth"]) + } + + tokClient := &http.Client{} + hdr := map[string]string{"Authorization": "Bearer " + created.Token} + res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("token me after lock: want 200 got %d %+v", res.StatusCode, env) + } + _ = json.Unmarshal(env.Data, &me) + if me["auth"] != "token" { + t.Fatalf("token me auth=%v", me["auth"]) + } + + res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/overview", "", hdr) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("token overview after lock: want 200 got %d %+v", res.StatusCode, env) + } + + anon := &http.Client{} + res = postJSON(t, anon, base+"/api/admin/login", + `{"username":"admin","password":"`+testPassword+`"}`, nil) + env = decodeEnv(t, res) + if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" { + t.Fatalf("password login while locked want 429 got %d %+v", res.StatusCode, env) + } +} + func TestBadAPITokenCountsTowardLock(t *testing.T) { _, srv, _, _ := setup(t) base := srv.URL diff --git a/internal/admin/auth.go b/internal/admin/auth.go index c6e2e56..37c7c5b 100644 --- a/internal/admin/auth.go +++ b/internal/admin/auth.go @@ -43,12 +43,8 @@ func (h *Handler) auth(next http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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)) - httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") - return - } - + // 锁定只拦密码登录(login.go)与错误令牌试错累计; + // 已认证的 Cookie / 合法 API 令牌在锁定期仍可用(对齐 PRD D18)。 p, errCode, errMsg, status := h.authenticate(r, ip) if status != 0 { if status == http.StatusTooManyRequests {