From bf2640d8fd629ae0dd86b974821089600a898465 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:16:49 +0800 Subject: [PATCH] =?UTF-8?q?admin:=20/admin=20HTML=20=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E5=8F=AF=E8=AE=BF=E9=97=AE=EF=BC=8C=E8=AE=A9=20token=20?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E7=9C=9F=E6=AD=A3=E8=83=BD=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮 (90eba22) 把 admin 页改成 token 弹窗模式,但 webHandler 还 挂在带 AdminAuth 的 group 里。结果无 token 访问 /admin 拿到的不是 HTML,而是 {"error":"missing bearer token"} 的 401 JSON,弹窗 变成死代码。 修复: 拆出 gPublic group,只放 webHandler (GET /admin 和 /admin/)。 API 路由 (/clusters*, /audit) 和 OpenAPI docs (/docs, /docs/spec) 继续受 AdminAuth 保护 (后者暴露内部 API 结构,不该公网可见)。 HTML 自身的 token 输入流不变 — 弹窗仍然在客户端拦截 401、清旧 token、 记录 pendingRetry、保存新 token 后自动重试。 补一个 TestAdminWeb_NoTokenReturnsHTML 锁定新契约: - GET /admin 无 token -> 200 + text/html (含 token-modal 元素) - GET /admin/ 同上 - GET /admin/clusters 无 token -> 401 (回归保护) - GET /admin/clusters good-token -> 200 (回归保护) 旧 TestMount_RequiresAuth 测的全是 API 路径, 不受影响, 全绿。 验证: - go build / go vet / go test 全部通过 - 端到端 curl 复现用户报告场景: /admin 无 token -> 200 + 弹窗 HTML - API + docs 鉴权行为不变 Co-Authored-By: Claude --- internal/admin/router.go | 8 +++++-- internal/admin/router_test.go | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/admin/router.go b/internal/admin/router.go index 9447f73..0c683d4 100644 --- a/internal/admin/router.go +++ b/internal/admin/router.go @@ -19,9 +19,13 @@ import ( // Mount attaches the /admin sub-router to r, protecting every route with // bearer-token admin authentication. func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) { + // HTML page is public — its own modal prompts for the token. + // All other /admin/* endpoints (API + OpenAPI docs) still require auth. + gPublic := r.Group("/admin") + gPublic.GET("", webHandler) + gPublic.GET("/", webHandler) + g := r.Group("/admin", middleware.AdminAuth(adminTokens)) - g.GET("", webHandler) - g.GET("/", webHandler) g.GET("/clusters", listClusters(repo)) g.POST("/clusters", createCluster(repo, auditRepo)) g.GET("/clusters/:id", getCluster(repo)) diff --git a/internal/admin/router_test.go b/internal/admin/router_test.go index d58294d..e01b6d0 100644 --- a/internal/admin/router_test.go +++ b/internal/admin/router_test.go @@ -96,6 +96,45 @@ func TestMount_RequiresAuth(t *testing.T) { } } +func TestAdminWeb_NoTokenReturnsHTML(t *testing.T) { + r, _, _ := newTestAdmin(t) + + // 1) /admin without token: 200 + HTML + w := doReq(t, r, "GET", "/admin", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("GET /admin without token: got %d, want 200", w.Code) + } + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } + body := w.Body.String() + if !strings.Contains(body, "token-modal") { + t.Errorf("body missing token-modal element; body excerpt: %.200s", body) + } + if !strings.Contains(body, "Admin Token Required") { + t.Errorf("body missing modal title") + } + + // 2) /admin/ (trailing slash) same behavior + w2 := doReq(t, r, "GET", "/admin/", "", nil) + if w2.Code != http.StatusOK { + t.Fatalf("GET /admin/ without token: got %d, want 200", w2.Code) + } + + // 3) API routes still require auth (no token = 401) + w3 := doReq(t, r, "GET", "/admin/clusters", "", nil) + if w3.Code != http.StatusUnauthorized { + t.Errorf("GET /admin/clusters without token: got %d, want 401", w3.Code) + } + + // 4) API routes still require auth (valid token = 200) + w4 := doReq(t, r, "GET", "/admin/clusters", "good-token", nil) + if w4.Code != http.StatusOK { + t.Errorf("GET /admin/clusters with good-token: got %d, want 200", w4.Code) + } +} + func TestListClusters(t *testing.T) { r, _, _ := newTestAdmin(t)