182 lines
5.0 KiB
Go
182 lines
5.0 KiB
Go
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
|
||
}
|
||
}
|