This commit is contained in:
parent
2a3d3b420e
commit
c79f1035af
@ -718,6 +718,7 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re
|
||||
}
|
||||
challenge, err := runtime.verification.Issue(verification.IssueRequest{Purpose: verification.PurposeExternalEmailBind, UserID: ctx.UserID, Account: ctx.UserAccount, Email: input.Email, ResourceID: item.ID}, remoteIP(req))
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("issue external email verification challenge failed")
|
||||
if created {
|
||||
_ = runtime.external.AbandonPending(ctx.UserID, item.ID)
|
||||
}
|
||||
@ -837,7 +838,11 @@ func externalEmailVerificationIssueMessage(err error) string {
|
||||
case errors.Is(err, verification.ErrSMTPRelayUnavailable),
|
||||
errors.Is(err, verification.ErrInvalidSMTPConfig),
|
||||
errors.Is(err, verification.ErrUnsupportedSMTP):
|
||||
return "验证码发信服务尚未配置,请先完成 SMTP 发信配置"
|
||||
return "SMTP 中继配置不完整,请完善配置或清空中继后使用项目自身投递"
|
||||
case errors.Is(err, verification.ErrProjectMailUnavailable):
|
||||
return "项目邮件投递尚未就绪,请检查主域名和 DKIM 配置"
|
||||
case errors.Is(err, verification.ErrMailDeliveryFailed):
|
||||
return "验证码邮件投递失败,请确认收件地址和邮件服务状态"
|
||||
default:
|
||||
return "验证码发送失败,请检查发信服务后重试"
|
||||
}
|
||||
|
||||
@ -308,25 +308,7 @@ func startVerificationRuntime() error {
|
||||
runtime.Stop()
|
||||
return fmt.Errorf("start external email repository: %w", err)
|
||||
}
|
||||
delivery := verification.NewLazySMTPDelivery(func() (verification.SMTPSettings, error) {
|
||||
smtpSettings, err := config.CurrentSMTPRuntimeSettings()
|
||||
if err != nil {
|
||||
return verification.SMTPSettings{}, err
|
||||
}
|
||||
security, err := smtpSecurityMode(smtpSettings.Relay.Security)
|
||||
if err != nil {
|
||||
return verification.SMTPSettings{}, err
|
||||
}
|
||||
return verification.SMTPSettings{
|
||||
Host: smtpSettings.Relay.RelayHost,
|
||||
Port: smtpSettings.Relay.RelayPort,
|
||||
Username: smtpSettings.Relay.Username,
|
||||
Password: smtpSettings.Password,
|
||||
Security: security,
|
||||
From: smtpSettings.Relay.From,
|
||||
FromDomain: smtpSettings.Relay.FromDomain,
|
||||
}, nil
|
||||
})
|
||||
delivery := newVerificationDelivery()
|
||||
service, err := verification.NewService(runtime.Store, delivery, runtime.Events)
|
||||
if err != nil {
|
||||
runtime.Stop()
|
||||
@ -342,7 +324,7 @@ func startVerificationRuntime() error {
|
||||
controllers.ConfigureSecurityRuntime(service, external, recover, credential)
|
||||
credentialService = credential
|
||||
verificationRuntime = runtime
|
||||
log.Info("安全验证码服务已启动,外发邮件将在实际使用时检查 SMTP 中继配置")
|
||||
log.Info("安全验证码服务已启动,已配置中继时优先使用中继,否则使用项目自身邮件投递")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
119
backend/res_init/verification_delivery.go
Normal file
119
backend/res_init/verification_delivery.go
Normal file
@ -0,0 +1,119 @@
|
||||
package res_init
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/dto/parsemail"
|
||||
"git.pcux.cn/xuans/Smail/services/verification"
|
||||
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
"git.pcux.cn/xuans/Smail/utils/send"
|
||||
)
|
||||
|
||||
func newVerificationDelivery() verification.Delivery {
|
||||
return newVerificationDeliveryWithDependencies(
|
||||
config.CurrentSMTPRuntimeSettings,
|
||||
deliverVerificationWithSMTPRelay,
|
||||
deliverVerificationWithProjectMail,
|
||||
)
|
||||
}
|
||||
|
||||
func newVerificationDeliveryWithDependencies(
|
||||
loadSettings func() (config.SMTPRuntimeSettings, error),
|
||||
deliverRelay func(config.SMTPRuntimeSettings, verification.Challenge, string) error,
|
||||
deliverProject func(verification.Challenge, string) error,
|
||||
) verification.Delivery {
|
||||
return func(challenge verification.Challenge, code string) error {
|
||||
if strings.TrimSpace(challenge.Email) == "" || strings.TrimSpace(code) == "" {
|
||||
return verification.ErrInvalidChallenge
|
||||
}
|
||||
if loadSettings == nil || deliverRelay == nil || deliverProject == nil {
|
||||
return verification.ErrProjectMailUnavailable
|
||||
}
|
||||
|
||||
runtimeSettings, err := loadSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load verification mail settings: %w", err)
|
||||
}
|
||||
if smtpRelayRequested(runtimeSettings) {
|
||||
return deliverRelay(runtimeSettings, challenge, code)
|
||||
}
|
||||
|
||||
return deliverProject(challenge, code)
|
||||
}
|
||||
}
|
||||
|
||||
func smtpRelayRequested(settings config.SMTPRuntimeSettings) bool {
|
||||
relay := settings.Relay
|
||||
return strings.TrimSpace(relay.RelayHost) != "" ||
|
||||
strings.TrimSpace(relay.Username) != "" ||
|
||||
strings.TrimSpace(settings.Password) != "" ||
|
||||
strings.TrimSpace(relay.From) != ""
|
||||
}
|
||||
|
||||
func verificationSMTPSettings(settings config.SMTPRuntimeSettings) (verification.SMTPSettings, error) {
|
||||
security, err := smtpSecurityMode(settings.Relay.Security)
|
||||
if err != nil {
|
||||
return verification.SMTPSettings{}, err
|
||||
}
|
||||
return verification.SMTPSettings{
|
||||
Host: settings.Relay.RelayHost,
|
||||
Port: settings.Relay.RelayPort,
|
||||
Username: settings.Relay.Username,
|
||||
Password: settings.Password,
|
||||
Security: security,
|
||||
From: settings.Relay.From,
|
||||
FromDomain: settings.Relay.FromDomain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func deliverVerificationWithSMTPRelay(runtimeSettings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error {
|
||||
settings, err := verificationSMTPSettings(runtimeSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delivery, err := verification.NewSMTPDelivery(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP relay is unavailable: %w", err)
|
||||
}
|
||||
if err = delivery.Delivery()(challenge, code); err != nil {
|
||||
return fmt.Errorf("%w: SMTP relay rejected delivery", verification.ErrMailDeliveryFailed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deliverVerificationWithProjectMail(challenge verification.Challenge, code string) error {
|
||||
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
|
||||
if domain == "" {
|
||||
return fmt.Errorf("%w: primary mail domain is not configured", verification.ErrProjectMailUnavailable)
|
||||
}
|
||||
messageID, err := parsemail.GenerateMsgID(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: generate message id", verification.ErrProjectMailUnavailable)
|
||||
}
|
||||
subject, text := verification.CodeMessage(challenge.Purpose, code)
|
||||
sender := &parsemail.User{
|
||||
Name: "Smail 安全中心",
|
||||
EmailAddress: "no-reply@" + domain,
|
||||
}
|
||||
message := &parsemail.Email{
|
||||
From: sender,
|
||||
Sender: sender,
|
||||
To: []*parsemail.User{
|
||||
{EmailAddress: challenge.Email},
|
||||
},
|
||||
Subject: subject,
|
||||
Text: []byte(text),
|
||||
MsgID: messageID,
|
||||
}
|
||||
ctx := &appcontext.Context{
|
||||
Context: context.Background(),
|
||||
Values: make(map[string]any),
|
||||
}
|
||||
if deliveryErr, _ := send.Send(ctx, message); deliveryErr != nil {
|
||||
return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
76
backend/res_init/verification_delivery_test.go
Normal file
76
backend/res_init/verification_delivery_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package res_init
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/services/verification"
|
||||
)
|
||||
|
||||
func TestVerificationDeliveryUsesProjectMailWithoutRelay(t *testing.T) {
|
||||
settings := config.SMTPRuntimeSettings{
|
||||
Relay: config.SMTPConfig{
|
||||
RelayPort: 587,
|
||||
Security: "starttls",
|
||||
FromDomain: "example.com",
|
||||
},
|
||||
}
|
||||
relayCalled := false
|
||||
projectCalled := false
|
||||
delivery := newVerificationDeliveryWithDependencies(
|
||||
func() (config.SMTPRuntimeSettings, error) { return settings, nil },
|
||||
func(config.SMTPRuntimeSettings, verification.Challenge, string) error {
|
||||
relayCalled = true
|
||||
return nil
|
||||
},
|
||||
func(challenge verification.Challenge, code string) error {
|
||||
projectCalled = challenge.Email == "user@example.net" && code == "12345678"
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err := delivery(verification.Challenge{Email: "user@example.net"}, "12345678"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if relayCalled || !projectCalled {
|
||||
t.Fatalf("relayCalled=%v projectCalled=%v", relayCalled, projectCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDeliveryDoesNotHidePartialRelayConfiguration(t *testing.T) {
|
||||
expected := verification.ErrInvalidSMTPConfig
|
||||
projectCalled := false
|
||||
delivery := newVerificationDeliveryWithDependencies(
|
||||
func() (config.SMTPRuntimeSettings, error) {
|
||||
return config.SMTPRuntimeSettings{Relay: config.SMTPConfig{RelayHost: "smtp.example.com"}}, nil
|
||||
},
|
||||
func(config.SMTPRuntimeSettings, verification.Challenge, string) error { return expected },
|
||||
func(verification.Challenge, string) error {
|
||||
projectCalled = true
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
err := delivery(verification.Challenge{Email: "user@example.net"}, "12345678")
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("error = %v, want %v", err, expected)
|
||||
}
|
||||
if projectCalled {
|
||||
t.Fatal("partial relay configuration unexpectedly fell back to project mail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDeliveryPreservesSettingsError(t *testing.T) {
|
||||
expected := errors.New("settings unavailable")
|
||||
delivery := newVerificationDeliveryWithDependencies(
|
||||
func() (config.SMTPRuntimeSettings, error) { return config.SMTPRuntimeSettings{}, expected },
|
||||
func(config.SMTPRuntimeSettings, verification.Challenge, string) error { return nil },
|
||||
func(verification.Challenge, string) error { return nil },
|
||||
)
|
||||
|
||||
err := delivery(verification.Challenge{Email: "user@example.net"}, "12345678")
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("error = %v, want wrapped settings error", err)
|
||||
}
|
||||
}
|
||||
@ -22,9 +22,11 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration")
|
||||
ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode")
|
||||
ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable")
|
||||
ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration")
|
||||
ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode")
|
||||
ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable")
|
||||
ErrProjectMailUnavailable = errors.New("project mail delivery is unavailable")
|
||||
ErrMailDeliveryFailed = errors.New("verification mail delivery failed")
|
||||
)
|
||||
|
||||
type SMTPSettings struct {
|
||||
@ -178,6 +180,12 @@ func sendSMTPMessage(settings SMTPSettings, recipient string, message []byte) er
|
||||
}
|
||||
|
||||
func buildCodeMessage(from, recipient string, code string, purpose Purpose) []byte {
|
||||
title, text := CodeMessage(purpose, code)
|
||||
return []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), text))
|
||||
}
|
||||
|
||||
// CodeMessage 返回验证码邮件的统一标题和正文,供中继与项目自身投递复用。
|
||||
func CodeMessage(purpose Purpose, code string) (string, string) {
|
||||
title := "Smail 安全验证码"
|
||||
purposeText := "完成本次安全验证"
|
||||
switch purpose {
|
||||
@ -190,7 +198,8 @@ func buildCodeMessage(from, recipient string, code string, purpose Purpose) []by
|
||||
case PurposeExternalEmailChange:
|
||||
purposeText = "完成外部邮箱变更验证"
|
||||
}
|
||||
return []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n您好,您正在%s。验证码:%s\r\n验证码有效期为十分钟,请勿向他人泄露。\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), purposeText, code))
|
||||
text := fmt.Sprintf("您好,您正在%s。验证码:%s\r\n验证码有效期为十分钟,请勿向他人泄露。", purposeText, code)
|
||||
return title, text
|
||||
}
|
||||
|
||||
func clearBytes(data []byte) {
|
||||
|
||||
@ -40,6 +40,16 @@ func TestSMTPDeliveryKeepsCodeOutOfChallenge(t *testing.T) {
|
||||
clearBytes(message)
|
||||
}
|
||||
|
||||
func TestCodeMessageDescribesExternalEmailBinding(t *testing.T) {
|
||||
title, text := CodeMessage(PurposeExternalEmailBind, "12345678")
|
||||
if title != "Smail 安全验证码" {
|
||||
t.Fatalf("title = %q", title)
|
||||
}
|
||||
if !strings.Contains(text, "外部邮箱绑定验证") || !strings.Contains(text, "12345678") {
|
||||
t.Fatalf("unexpected verification message: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPModesAreValidated(t *testing.T) {
|
||||
base := SMTPSettings{Host: "localhost", Port: 25, Security: SecurityPlain, From: "no-reply@example.com"}
|
||||
if _, err := NewSMTPDelivery(base); err != nil {
|
||||
|
||||
@ -108,8 +108,8 @@ export function useExternalEmailBindingDialog(props, emit) {
|
||||
|
||||
const close = () => {
|
||||
if (busy.value) return
|
||||
clearMessages()
|
||||
visible.value = false
|
||||
resetAll()
|
||||
}
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user