Smail/backend/services/verification/delivery.go

201 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package verification
import (
"errors"
"fmt"
"mime"
"net"
"net/mail"
"net/smtp"
"strconv"
"strings"
smailsmtp "git.pcux.cn/xuans/Smail/utils/smtp"
)
type SecurityMode string
const (
SecurityStartTLS SecurityMode = "starttls"
SecurityImplicitTLS SecurityMode = "tls"
SecurityPlain SecurityMode = "plain"
)
var (
ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration")
ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode")
ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable")
)
type SMTPSettings struct {
Host string
Port int
Username string
Password string
Security SecurityMode
From string
FromDomain string
}
type SMTPDelivery struct {
settings SMTPSettings
send func(SMTPSettings, string, []byte) error
}
func NewSMTPDelivery(settings SMTPSettings) (*SMTPDelivery, error) {
if err := validateSMTPSettings(settings); err != nil {
return nil, err
}
return &SMTPDelivery{settings: settings, send: sendSMTPMessage}, nil
}
func (d *SMTPDelivery) Delivery() Delivery {
return func(challenge Challenge, code string) error {
if d == nil || d.send == nil || challenge.Email == "" || code == "" {
return ErrInvalidChallenge
}
message := buildCodeMessage(d.settings.From, challenge.Email, code, challenge.Purpose)
defer clearBytes(message)
return d.send(d.settings, challenge.Email, message)
}
}
// NewLazySMTPDelivery 延迟读取 SMTP 中继配置,避免中继未配置时阻塞本地邮件服务启动。
func NewLazySMTPDelivery(load func() (SMTPSettings, error)) Delivery {
return func(challenge Challenge, code string) error {
if load == nil || challenge.Email == "" || code == "" {
return ErrSMTPRelayUnavailable
}
settings, err := load()
if err != nil {
return fmt.Errorf("load SMTP relay settings: %w", err)
}
delivery, err := NewSMTPDelivery(settings)
if err != nil {
return fmt.Errorf("SMTP relay is unavailable: %w", err)
}
return delivery.Delivery()(challenge, code)
}
}
func IssueWithSMTP(store *Store, request IssueRequest, settings SMTPSettings) (Challenge, error) {
if store == nil {
return Challenge{}, ErrInvalidChallenge
}
delivery, err := NewSMTPDelivery(settings)
if err != nil {
return Challenge{}, err
}
return store.Issue(request, delivery.Delivery())
}
func validateSMTPSettings(settings SMTPSettings) error {
host := strings.TrimSpace(settings.Host)
if !isValidSMTPHost(host) {
return ErrInvalidSMTPConfig
}
from := strings.TrimSpace(settings.From)
parsedFrom, err := mail.ParseAddress(from)
if err != nil || parsedFrom.Address != from || strings.ContainsAny(from, "\r\n") {
return ErrInvalidSMTPConfig
}
if settings.Port <= 0 || settings.Port > 65535 {
return ErrInvalidSMTPConfig
}
switch settings.Security {
case SecurityStartTLS, SecurityImplicitTLS:
case SecurityPlain:
if !isLocalSMTPHost(host) {
return ErrInvalidSMTPConfig
}
default:
return ErrUnsupportedSMTP
}
if settings.Username == "" && settings.Password != "" {
return ErrInvalidSMTPConfig
}
return nil
}
func isLocalSMTPHost(host string) bool {
normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if normalized == "localhost" {
return true
}
ip := net.ParseIP(normalized)
return ip != nil && ip.IsLoopback()
}
func isValidSMTPHost(host string) bool {
if host == "" || strings.ContainsAny(host, "@/\\\r\n\t ") {
return false
}
if net.ParseIP(host) != nil {
return true
}
if strings.Contains(host, ":") {
return false
}
if len(host) > 253 || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
return false
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
for _, r := range label {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' {
return false
}
}
}
return true
}
func sendSMTPMessage(settings SMTPSettings, recipient string, message []byte) error {
addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
var auth smtp.Auth
if settings.Username != "" {
auth = smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)
}
fromDomain := settings.FromDomain
if fromDomain == "" {
parts := strings.SplitN(settings.From, "@", 2)
if len(parts) == 2 {
fromDomain = parts[1]
}
}
switch settings.Security {
case SecurityStartTLS:
return smailsmtp.SendMail(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message)
case SecurityImplicitTLS:
return smailsmtp.SendMailWithTls(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message)
case SecurityPlain:
return smailsmtp.SendMailUnsafe(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message)
default:
return ErrUnsupportedSMTP
}
}
func buildCodeMessage(from, recipient string, code string, purpose Purpose) []byte {
title := "Smail 安全验证码"
purposeText := "完成本次安全验证"
switch purpose {
case PurposeLogin:
purposeText = "完成登录二次验证"
case PurposePasswordRecover:
purposeText = "完成密码恢复验证"
case PurposeExternalEmailBind:
purposeText = "完成外部邮箱绑定验证"
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))
}
func clearBytes(data []byte) {
for i := range data {
data[i] = 0
}
}