Smail/backend/services/verification/delivery.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

182 lines
5.0 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")
)
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)
}
}
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
}
}