修复公开密码重置查询泄露超级管理员身份的问题
This commit is contained in:
parent
df0be7f555
commit
51ab0a6d0c
44
backend/internal/httpapi/password_reset_guidance.go
Normal file
44
backend/internal/httpapi/password_reset_guidance.go
Normal file
@ -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",
|
||||
})
|
||||
}
|
||||
77
backend/internal/httpapi/password_reset_guidance_test.go
Normal file
77
backend/internal/httpapi/password_reset_guidance_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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") {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -61,3 +61,22 @@ test("本机指引不包含可执行命令", () => {
|
||||
const source = readFileSync(new URL("../components/LocalResetGuide/LocalResetGuide.vue", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /systemctl|scert-admin|<code/iu);
|
||||
});
|
||||
|
||||
test("公开密码重置接受不包含身份字段的通用响应", async (t) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user