保存本机重置业务初稿与通道权限测试检查点
This commit is contained in:
parent
ec800fc4b6
commit
5c327e2b48
205
backend/internal/httpapi/local_reset.go
Normal file
205
backend/internal/httpapi/local_reset.go
Normal 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("本机密码重置审计写入失败")
|
||||
}
|
||||
}
|
||||
164
backend/internal/localreset/transport_linux_test.go
Normal file
164
backend/internal/localreset/transport_linux_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
170
backend/internal/localreset/transport_windows_test.go
Normal file
170
backend/internal/localreset/transport_windows_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
54
backend/internal/notification/security.go
Normal file
54
backend/internal/notification/security.go
Normal 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("验证码邮件发送失败,请检查通知通道配置")
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user