保存本机重置业务初稿与通道权限测试检查点

This commit is contained in:
xuans 2026-09-21 00:39:47 +08:00
parent ec800fc4b6
commit 5c327e2b48
5 changed files with 597 additions and 0 deletions

View File

@ -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("本机密码重置审计写入失败")
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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: "<pre>" + html.EscapeString(text) + "</pre>",
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("验证码邮件发送失败,请检查通知通道配置")
}

View File

@ -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)