From 51ab0a6d0c6853a68e36cbfb7918988d352ba8d9 Mon Sep 17 00:00:00 2001 From: xuans Date: Thu, 1 Oct 2026 22:39:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=AC=E5=BC=80=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=87=8D=E7=BD=AE=E6=9F=A5=E8=AF=A2=E6=B3=84=E9=9C=B2?= =?UTF-8?q?=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../httpapi/password_reset_guidance.go | 44 +++++++++++ .../httpapi/password_reset_guidance_test.go | 77 +++++++++++++++++++ .../httpapi/password_reset_local_test.go | 2 +- backend/internal/httpapi/server.go | 2 +- .../views/PasswordReset/PasswordReset.test.js | 19 +++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 backend/internal/httpapi/password_reset_guidance.go create mode 100644 backend/internal/httpapi/password_reset_guidance_test.go diff --git a/backend/internal/httpapi/password_reset_guidance.go b/backend/internal/httpapi/password_reset_guidance.go new file mode 100644 index 0000000..c598f8c --- /dev/null +++ b/backend/internal/httpapi/password_reset_guidance.go @@ -0,0 +1,44 @@ +package httpapi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/auth" +) + +type publicPasswordResetGuidance struct { + Title string `json:"title"` + Description string `json:"description"` + Action string `json:"action"` +} + +// showPasswordResetGuidance 仅验证输入格式,不查询账户,也不创建重置会话。 +// 公开路由必须使用此处理器,不能绑定历史的账户分类处理器。 +func (s *Server) showPasswordResetGuidance(c *gin.Context) { + c.Header("Cache-Control", "no-store") + var request passwordResetResolveRequest + if err := bindJSON(c, &request, passwordResetResolveBodyLimit); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "账户信息格式无效"}) + return + } + if _, err := auth.NormalizeIdentifier(request.Identifier); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请输入有效的用户名或绑定邮箱"}) + return + } + + s.configMu.RLock() + ready := s.config.SetupReady + s.configMu.RUnlock() + if !ready { + c.JSON(http.StatusConflict, gin.H{"error": "Scert 尚未完成初始化,暂不能处理密码重置"}) + return + } + + c.JSON(http.StatusOK, publicPasswordResetGuidance{ + Title: "密码重置", + Description: "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。", + Action: "contact_administrator", + }) +} diff --git a/backend/internal/httpapi/password_reset_guidance_test.go b/backend/internal/httpapi/password_reset_guidance_test.go new file mode 100644 index 0000000..c038b3b --- /dev/null +++ b/backend/internal/httpapi/password_reset_guidance_test.go @@ -0,0 +1,77 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPublicPasswordResetGuidanceDoesNotEnumerateAccounts(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "owner" + cfg.Admin.Email = "owner@example.test" + server := New(Dependencies{Config: cfg}) + var baseline string + for _, identifier := range []string{"owner", "owner@example.test", "OWNER", "OWNER@EXAMPLE.TEST", "not-an-account", "absent@example.test", " owner "} { + t.Run(identifier, func(t *testing.T) { + body, err := json.Marshal(map[string]string{"identifier": identifier}) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("公开查询应返回通用成功响应:%d %s", response.Code, response.Body.String()) + } + if baseline == "" { + baseline = response.Body.String() + } else if response.Body.String() != baseline { + t.Fatalf("不同合法标识不得产生可区分的响应:%s", response.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if len(payload) != 3 || payload["action"] != "contact_administrator" || payload["title"] != "密码重置" || payload["description"] != "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。" { + t.Fatalf("公开响应必须仅包含通用处理提示:%v", payload) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("密码处理提示不得被缓存") + } + if len(response.Result().Cookies()) != 0 || len(server.authSessions) != 0 || len(server.loginPending) != 0 { + t.Fatal("公开查询不得创建会话或登录状态") + } + }) + } +} + +func TestPublicPasswordResetGuidancePreservesInputBoundary(t *testing.T) { + for _, ready := range []bool{false, true} { + cfg := readyTestConfig(t.TempDir()) + cfg.SetupReady = ready + server := New(Dependencies{Config: cfg}) + for _, body := range []string{`{}`, `{"identifier":""}`, `{"identifier":17}`, `{"identifier":"owner","unexpected":true}`, `{"identifier":"owner"}{"identifier":"owner"}`, `{"identifier":"` + strings.Repeat("a", passwordResetResolveBodyLimit) + `"}`} { + request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("无效请求必须被拒绝:ready=%t status=%d", ready, response.Code) + } + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(`{"identifier":"owner"}`)) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + want := http.StatusOK + if !ready { + want = http.StatusConflict + } + if response.Code != want { + t.Fatalf("初始化边界不正确:ready=%t status=%d", ready, response.Code) + } + } +} diff --git a/backend/internal/httpapi/password_reset_local_test.go b/backend/internal/httpapi/password_reset_local_test.go index 2338b47..694a4a4 100644 --- a/backend/internal/httpapi/password_reset_local_test.go +++ b/backend/internal/httpapi/password_reset_local_test.go @@ -30,7 +30,7 @@ func TestPasswordResetResolutionOnlyOffersAdministratorContact(t *testing.T) { if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatal(err) } - if payload["account_type"] != "super_admin" || payload["action"] != "contact_administrator" { + if payload["account_type"] != nil || payload["action"] != "contact_administrator" { t.Fatalf("超级管理员没有返回受控本机处理标识:%v", payload) } if description := strings.ToLower(response.Body.String()); strings.Contains(description, "scert-admin") || strings.Contains(description, "systemctl") || strings.Contains(description, "local_admin_command") { diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index bc04c01..778d6d5 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -202,7 +202,7 @@ func (s *Server) registerRoutes() { s.router.POST("/api/v1/auth/login", s.requireSameOrigin, s.authLogin) s.router.POST("/api/v1/auth/logout", s.requireSameOrigin, s.authLogout) s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword) - s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount) + s.router.POST("/api/v1/password-reset/resolve", s.showPasswordResetGuidance) adminAPI := s.router.Group("/api/v1", s.requireAdmin) userAPI := s.router.Group("/api/v1/users", s.requireAuthenticated) userAPI.GET("", s.listUsers) diff --git a/frontend/src/views/PasswordReset/PasswordReset.test.js b/frontend/src/views/PasswordReset/PasswordReset.test.js index a168ca3..b2e9577 100644 --- a/frontend/src/views/PasswordReset/PasswordReset.test.js +++ b/frontend/src/views/PasswordReset/PasswordReset.test.js @@ -61,3 +61,22 @@ test("本机指引不包含可执行命令", () => { const source = readFileSync(new URL("../components/LocalResetGuide/LocalResetGuide.vue", import.meta.url), "utf8"); assert.doesNotMatch(source, /systemctl|scert-admin| { + const payload = { + title: "密码重置", + description: "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。", + action: "contact_administrator", + }; + const { state } = await mountComposable(t, usePasswordReset, async () => Response.json(payload)); + for (const identifier of ["owner", "owner@example.test", "absent", "absent@example.test"]) { + state.identifier.value = identifier; + await state.submit(); + assert.deepEqual(state.resolution.value, payload); + assert.equal(state.error.value, ""); + assert.equal(state.submitting.value, false); + for (const field of ["account_type", "account_label", "reset_path", "reset_url", "token", "nonce"]) { + assert.equal(field in state.resolution.value, false); + } + } +});