From 5c327e2b486a652e9b72c6b08ba59682ac3bf7a2 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 21 Sep 2026 00:39:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=9C=AC=E6=9C=BA=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=9A=E5=8A=A1=E5=88=9D=E7=A8=BF=E4=B8=8E=E9=80=9A?= =?UTF-8?q?=E9=81=93=E6=9D=83=E9=99=90=E6=B5=8B=E8=AF=95=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/httpapi/local_reset.go | 205 ++++++++++++++++++ .../localreset/transport_linux_test.go | 164 ++++++++++++++ .../localreset/transport_windows_test.go | 170 +++++++++++++++ backend/internal/notification/security.go | 54 +++++ backend/internal/notification/service.go | 4 + 5 files changed, 597 insertions(+) create mode 100644 backend/internal/httpapi/local_reset.go create mode 100644 backend/internal/localreset/transport_linux_test.go create mode 100644 backend/internal/localreset/transport_windows_test.go create mode 100644 backend/internal/notification/security.go diff --git a/backend/internal/httpapi/local_reset.go b/backend/internal/httpapi/local_reset.go new file mode 100644 index 0000000..849daba --- /dev/null +++ b/backend/internal/httpapi/local_reset.go @@ -0,0 +1,205 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "git.pcux.cn/xuans/Scert/backend/internal/notification" + "git.pcux.cn/xuans/Scert/backend/internal/passwordpolicy" + "git.pcux.cn/xuans/Scert/backend/internal/repository" + "golang.org/x/crypto/bcrypt" +) + +type resetCodeSender func(context.Context, string, string) error + +type localResetSession struct { + server *Server + send resetCodeSender + reserve func() bool + admin config.AdminConfig + expires time.Time + peer localreset.Peer + mu sync.Mutex + stage int + codeHash [32]byte +} + +// LocalResetFactory 只交给受保护的本机通道,不注册到普通 HTTP 路由。 +func (s *Server) LocalResetFactory() localreset.Factory { + return s.localResetFactory(func(ctx context.Context, recipient, code string) error { + s.configMu.RLock() + cfg := s.config.Notification + s.configMu.RUnlock() + reader, ok := s.secrets.(notification.CredentialReader) + if !ok { + return localreset.ErrUnavailable + } + return notification.New(cfg, reader).SendSecurityCode(ctx, recipient, code) + }) +} + +func (s *Server) localResetFactory(send resetCodeSender) localreset.Factory { + var mu sync.Mutex + var lastIssued time.Time + reserve := func() bool { + mu.Lock() + defer mu.Unlock() + now := s.now() + if !lastIssued.IsZero() && now.Before(lastIssued.Add(time.Minute)) { + return false + } + lastIssued = now + return true + } + return func(ctx context.Context, peer localreset.Peer) (localreset.Session, error) { + s.configMu.RLock() + cfg := s.config + s.configMu.RUnlock() + if ctx.Err() != nil || !cfg.SetupReady || cfg.Admin.Email == "" || + cfg.Admin.PasswordHash == "" || strings.TrimSpace(s.configPath) == "" || send == nil { + return nil, localreset.ErrUnavailable + } + return &localResetSession{server: s, send: send, reserve: reserve, admin: cfg.Admin, + expires: s.now().Add(localreset.SessionTimeout), peer: peer}, nil + } +} + +func (r *localResetSession) Methods() []localreset.Method { + return []localreset.Method{{ID: "email", Label: "绑定邮箱验证码"}} +} + +func (r *localResetSession) current(ctx context.Context) bool { + if ctx.Err() != nil || !r.server.now().Before(r.expires) { + return false + } + r.server.configMu.RLock() + defer r.server.configMu.RUnlock() + return r.server.config.SetupReady && r.server.config.Admin == r.admin +} + +func (r *localResetSession) Select(ctx context.Context, method string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.stage != 0 || method != "email" || !r.current(ctx) { + r.stage = -1 + return localreset.ErrProtocol + } + r.stage = -1 + if !r.reserve() { + return localreset.ErrUnavailable + } + value, err := rand.Int(rand.Reader, big.NewInt(100000000)) + if err != nil { + return localreset.ErrUnavailable + } + code := fmt.Sprintf("%08d", value.Int64()) + r.codeHash = sha256.Sum256([]byte(code)) + if err := r.send(ctx, r.admin.Email, code); err != nil { + r.audit("failed", "本机密码重置验证码发送失败") + return localreset.ErrUnavailable + } + if !r.current(ctx) { + return localreset.ErrProtocol + } + r.stage = 1 + return nil +} + +func (r *localResetSession) Verify(ctx context.Context, code string) error { + r.mu.Lock() + defer r.mu.Unlock() + validStage := r.stage == 1 + r.stage = -1 + hash := sha256.Sum256([]byte(code)) + if !validStage || !r.current(ctx) || subtle.ConstantTimeCompare(hash[:], r.codeHash[:]) != 1 { + r.codeHash = [32]byte{} + r.audit("failed", "本机密码重置验证码无效或已过期") + return localreset.ErrProtocol + } + r.codeHash = [32]byte{} + r.stage = 2 + return nil +} + +func (r *localResetSession) Reset(ctx context.Context, password string) error { + r.mu.Lock() + defer r.mu.Unlock() + validStage := r.stage == 2 + r.stage = -1 + if !validStage || !r.current(ctx) { + return localreset.ErrProtocol + } + if err := passwordpolicy.Validate(password, r.admin.Username); err != nil { + return err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return localreset.ErrUnavailable + } + s := r.server + if !s.tlsMu.TryLock() { + return localreset.ErrUnavailable + } + defer s.tlsMu.Unlock() + if !r.current(ctx) { + return localreset.ErrProtocol + } + persisted, err := config.Load(s.configPath) + if err != nil || persisted.Admin != r.admin { + return localreset.ErrUnavailable + } + previous := persisted + persisted.Admin.PasswordHash = string(hash) + if err := s.syncPrimarySuperAdmin(ctx, persisted); err != nil { + r.audit("failed", "本机密码重置同步账户失败") + return localreset.ErrUnavailable + } + if err := persisted.Save(s.configPath); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + rollbackErr := s.syncPrimarySuperAdmin(rollbackCtx, previous) + cancel() + r.audit("failed", "本机密码重置保存配置失败") + if rollbackErr != nil { + return errors.Join(localreset.ErrUnavailable, errors.New("账户回滚失败,需要管理员检查配置与数据库")) + } + return localreset.ErrUnavailable + } + s.configMu.Lock() + s.config.Admin = persisted.Admin + s.configMu.Unlock() + s.invalidateUserSessions("primary-super-admin") + r.audit("success", "超级管理员已通过本机通道重置密码") + return nil +} + +func (r *localResetSession) Close() { + r.mu.Lock() + defer r.mu.Unlock() + r.stage = -1 + r.codeHash = [32]byte{} +} + +func (r *localResetSession) audit(status, message string) { + if r.server.audit == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := r.server.audit.CreateAuditLog(ctx, repository.AuditLog{ + Action: "admin_local_password_reset", ResourceType: "super_admin", ResourceID: "primary-super-admin", + Status: status, Message: message, CreatedAt: r.server.now().UTC(), + }) + if err != nil && r.server.logger != nil { + r.server.logger.Warn("本机密码重置审计写入失败") + } +} diff --git a/backend/internal/localreset/transport_linux_test.go b/backend/internal/localreset/transport_linux_test.go new file mode 100644 index 0000000..e043b63 --- /dev/null +++ b/backend/internal/localreset/transport_linux_test.go @@ -0,0 +1,164 @@ +//go:build linux + +package localreset + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func TestUnixSocketRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "reset.sock") + server, err := Listen(path) + if err != nil { + t.Fatal(err) + } + defer server.Close() + info, err := os.Lstat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("unsafe socket permissions: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + session := &testSession{} + go func() { + done <- server.Serve(ctx, func(context.Context, Peer) (Session, error) { return session, nil }) + }() + conn, err := Dial(ctx, path, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + client, err := Begin(ctx, conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if err := client.Select("recovery"); err != nil { + t.Fatal(err) + } + if err := client.Verify("test-code"); err != nil { + t.Fatal(err) + } + if err := client.Reset("Example123!"); err != nil { + t.Fatal(err) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } + if session.resets.Load() != 1 || session.closed.Load() != 1 { + t.Fatal("session lifecycle incomplete") + } + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("owned socket not cleaned up") + } +} + +func TestUnixRejectsUnsafeParent(t *testing.T) { + for _, mode := range []os.FileMode{0777, 0770} { + t.Run(mode.String(), func(t *testing.T) { + parent := t.TempDir() + if err := os.Chmod(parent, mode); err != nil { + t.Fatal(err) + } + if server, err := Listen(filepath.Join(parent, "reset.sock")); err == nil { + _ = server.Close() + t.Fatal("writable parent accepted") + } + }) + } + parent := t.TempDir() + link := filepath.Join(parent, "link") + if err := os.Symlink(parent, link); err != nil { + t.Fatal(err) + } + if server, err := Listen(filepath.Join(link, "reset.sock")); err == nil { + _ = server.Close() + t.Fatal("symlink parent accepted") + } +} + +func TestUnixRejectsUnsafePathsWithoutOverwriting(t *testing.T) { + parent := t.TempDir() + path := filepath.Join(parent, "reset.sock") + if err := os.WriteFile(path, []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + if server, err := Listen(path); err == nil { + _ = server.Close() + t.Fatal("regular file replaced") + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "preserve" { + t.Fatal("existing file changed") + } + for _, path := range []string{"relative.sock", "tcp://127.0.0.1:80", parent + "/../reset.sock"} { + if server, err := Listen(path); err == nil { + _ = server.Close() + t.Fatalf("unsafe path accepted: %s", path) + } + } +} + +func TestUnixActiveSocketCannotBeReplaced(t *testing.T) { + path := filepath.Join(t.TempDir(), "reset.sock") + first, err := Listen(path) + if err != nil { + t.Fatal(err) + } + defer first.Close() + if second, err := Listen(path); err == nil { + _ = second.Close() + t.Fatal("active socket replaced") + } + if _, err := Dial(context.Background(), path, uint32(os.Geteuid())+1); err == nil { + t.Fatal("wrong service identity accepted") + } +} + +func TestUnixStaleOwnedSocketCanBeRecovered(t *testing.T) { + path := filepath.Join(t.TempDir(), "reset.sock") + old, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + old.SetUnlinkOnClose(false) + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + if err := old.Close(); err != nil { + t.Fatal(err) + } + server, err := Listen(path) + if err != nil { + t.Fatal(err) + } + defer server.Close() +} + +func TestUnixClosePreservesReplacedSocketPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "reset.sock") + server, err := Listen(path) + if err != nil { + t.Fatal(err) + } + if err := os.Rename(path, path+".old"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("replacement"), 0600); err != nil { + t.Fatal(err) + } + if err := server.Close(); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("replacement not detected: %v", err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "replacement" { + t.Fatal("replacement removed") + } +} diff --git a/backend/internal/localreset/transport_windows_test.go b/backend/internal/localreset/transport_windows_test.go new file mode 100644 index 0000000..387e28e --- /dev/null +++ b/backend/internal/localreset/transport_windows_test.go @@ -0,0 +1,170 @@ +//go:build windows + +package localreset + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +func testWindowsPath() string { + return fmt.Sprintf(`\\.\pipe\scert-reset-test-%d-%d`, os.Getpid(), time.Now().UnixNano()) +} + +func currentWindowsPeer() (Peer, error) { + token, err := windows.OpenCurrentProcessToken() + if err != nil { + return Peer{}, err + } + defer token.Close() + return tokenPeer(token) +} + +func startTestServer(t *testing.T, server *Server, factory Factory) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- server.Serve(ctx, factory) }() + t.Cleanup(func() { + cancel() + _ = server.Close() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + case <-time.After(time.Second): + t.Error("server failed to stop") + } + }) +} + +func TestWindowsNamedPipeRoundTrip(t *testing.T) { + if _, err := currentWindowsPeer(); err != nil { + t.Skip("requires elevated administrator or SYSTEM") + } + path := testWindowsPath() + server, err := Listen(path) + if err != nil { + t.Fatal(err) + } + session := &testSession{} + startTestServer(t, server, func(context.Context, Peer) (Session, error) { return session, nil }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + conn, err := Dial(ctx, path, 0) + if err != nil { + t.Fatal(err) + } + client, err := Begin(ctx, conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if err := client.Select("recovery"); err != nil { + t.Fatal(err) + } + if err := client.Verify("test-code"); err != nil { + t.Fatal(err) + } + if err := client.Reset("Example123!"); err != nil { + t.Fatal(err) + } + if session.resets.Load() != 1 { + t.Fatal("reset not applied") + } +} + +func TestWindowsRejectsNonElevatedPeer(t *testing.T) { + if _, err := currentWindowsPeer(); err == nil { + t.Skip("requires non-elevated process") + } + path := testWindowsPath() + server, err := listenPipe(path, "D:P(A;;GA;;;WD)") + if err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + startTestServer(t, server, func(context.Context, Peer) (Session, error) { + calls.Add(1) + return &testSession{}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + conn, err := winio.DialPipeContext(ctx, path) + if err != nil { + t.Fatal(err) + } + if _, err := Begin(ctx, conn); err == nil { + t.Fatal("non-elevated client accepted") + } + if calls.Load() != 0 { + t.Fatal("non-elevated peer reached reset factory") + } + if conn, err := Dial(ctx, path, 0); err == nil { + _ = conn.Close() + t.Fatal("non-elevated server impersonation accepted") + } +} + +func TestWindowsRejectsRemotePipePaths(t *testing.T) { + for _, path := range []string{`\\remote\pipe\scert-admin-control`, `tcp://127.0.0.1:80`, `\\.\pipe\`, `\\.\pipe\nested\pipe`} { + if _, err := pipePath(path); err == nil { + t.Fatalf("unsafe pipe accepted: %s", path) + } + } + if !strings.Contains(pipeSDDL, "(D;;GA;;;NU)") { + t.Fatal("network deny ACE missing") + } +} + +func TestWindowsActivePipeCannotBeReplaced(t *testing.T) { + path := testWindowsPath() + first, err := listenPipe(path, "D:P(A;;GA;;;WD)") + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := listenPipe(path, "D:P(A;;GA;;;WD)") + if err == nil { + _ = second.Close() + t.Fatal("active pipe replaced") + } +} + +func TestServerShutdownClosesIdleConnections(t *testing.T) { + path := testWindowsPath() + server, err := listenPipe(path, "D:P(A;;GA;;;WD)") + if err != nil { + t.Fatal(err) + } + server.authorize = func(net.Conn) (Peer, error) { return Peer{Identity: "test"}, nil } + session := &testSession{} + startTestServer(t, server, func(context.Context, Peer) (Session, error) { return session, nil }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + conn, err := winio.DialPipeContext(ctx, path) + if err != nil { + t.Fatal(err) + } + client, err := Begin(ctx, conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if err := server.Close(); err != nil { + t.Fatal(err) + } + if err := client.Select("recovery"); err == nil { + t.Fatal("closed server accepted request") + } +} diff --git a/backend/internal/notification/security.go b/backend/internal/notification/security.go new file mode 100644 index 0000000..9129159 --- /dev/null +++ b/backend/internal/notification/security.go @@ -0,0 +1,54 @@ +package notification + +import ( + "context" + "errors" + "html" + "net/mail" + "strings" +) + +// SendSecurityCode 仅向绑定邮箱发送验证码,不使用证书通知的收件人列表。 +func (s *Service) SendSecurityCode(ctx context.Context, recipient, code string) error { + if s == nil || (!s.config.HTTPEnabled && !s.config.SMTPEnabled) { + return errors.New("未配置可用的验证码邮件通道") + } + if err := ctx.Err(); err != nil { + return err + } + if len(code) != 8 { + return errors.New("验证码格式无效") + } + for _, value := range code { + if value < '0' || value > '9' { + return errors.New("验证码格式无效") + } + } + from := strings.TrimSpace(s.config.SenderEmail) + for _, value := range []string{from, recipient} { + parsed, err := mail.ParseAddress(value) + if err != nil || parsed.Address != value { + return errors.New("验证码邮件地址无效") + } + } + text := "有人正在部署服务器上重置 Scert 超级管理员密码。\n验证码:" + code + + "\n验证码仅在当前本机重置会话中有效,有效期不超过两分钟。\n如果不是您本人操作,请勿提供此验证码,并检查服务器访问权限。" + payload := mailPayload{ + From: address{Name: s.config.SenderName, Email: from}, + To: []address{{Email: recipient}}, + Subject: "[Scert] 超级管理员密码重置验证", + Text: text, HTML: "
" + html.EscapeString(text) + "
", + CC: []address{}, BCC: []address{}, Attrs: []any{}, + } + if s.config.HTTPEnabled { + if err := s.sendHTTPPayload(ctx, payload); err == nil { + return nil + } + } + if s.config.SMTPEnabled { + if err := s.sendSMTPWithRetry(ctx, payload); err == nil { + return nil + } + } + return errors.New("验证码邮件发送失败,请检查通知通道配置") +} diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index 92a8b00..dc50985 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -129,6 +129,10 @@ func (s *Service) sendHTTP(ctx context.Context, event Event) error { if err != nil { return err } + return s.sendHTTPPayload(ctx, payload) +} + +func (s *Service) sendHTTPPayload(ctx context.Context, payload mailPayload) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("编码邮件通知失败:%w", err)