Build and Publish Server / build (push) Successful in 2m6s
- 支持 465 隐式 TLS 与 587 STARTTLS,SMTP 配置优先、Postmark 备用 - 发件人支持 QQ 域名邮箱地址(z@kepo.net),凭证仅走环境变量 - HTTP 测试覆盖发送成功与认证失败;真实发信测试需显式开启 RUN_QQ_SMTP_LIVE_TEST Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
290 lines
9.5 KiB
Go
290 lines
9.5 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"mime"
|
||
"net"
|
||
"net/http"
|
||
"net/mail"
|
||
"net/smtp"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gouki/lunar-server/internal/config"
|
||
"github.com/gouki/lunar-server/internal/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
var ErrInvalidEmail = errors.New("invalid email")
|
||
var ErrLoginCodeRateLimited = errors.New("please wait before requesting another code")
|
||
var ErrLoginCodeInvalid = errors.New("invalid or expired login code")
|
||
|
||
type EmailAuthService struct {
|
||
db *gorm.DB
|
||
config *config.Config
|
||
client *http.Client
|
||
}
|
||
|
||
func NewEmailAuthService() *EmailAuthService {
|
||
return &EmailAuthService{db: config.GetDB(), config: config.Load(), client: &http.Client{Timeout: 10 * time.Second}}
|
||
}
|
||
|
||
func NormalizeEmail(value string) (string, error) {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
address, err := mail.ParseAddress(value)
|
||
if err != nil || address.Address != value || len(value) > 254 {
|
||
return "", ErrInvalidEmail
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func (s *EmailAuthService) RequestCode(email string) error {
|
||
email, err := NormalizeEmail(email)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var latest model.EmailLoginCode
|
||
if err := s.db.Where("email = ?", email).Order("created_at DESC").First(&latest).Error; err == nil && time.Since(latest.CreatedAt) < time.Minute {
|
||
return ErrLoginCodeRateLimited
|
||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return err
|
||
}
|
||
code, err := randomSixDigitCode()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
record := model.EmailLoginCode{Email: email, CodeHash: s.hashCode(email, code), ExpiresAt: time.Now().Add(10 * time.Minute)}
|
||
if err := s.db.Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCode(email, code); err != nil {
|
||
now := time.Now()
|
||
s.db.Model(&record).Update("used_at", &now)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *EmailAuthService) VerifyCode(email, code string) (*model.User, string, error) {
|
||
email, err := NormalizeEmail(email)
|
||
if err != nil || len(code) != 6 {
|
||
return nil, "", ErrLoginCodeInvalid
|
||
}
|
||
var record model.EmailLoginCode
|
||
if err := s.db.Where("email = ? AND used_at IS NULL AND expires_at > ?", email, time.Now()).Order("created_at DESC").First(&record).Error; err != nil {
|
||
return nil, "", ErrLoginCodeInvalid
|
||
}
|
||
if record.Attempts >= 5 {
|
||
return nil, "", ErrLoginCodeInvalid
|
||
}
|
||
if !hmac.Equal([]byte(record.CodeHash), []byte(s.hashCode(email, code))) {
|
||
s.db.Model(&record).UpdateColumn("attempts", gorm.Expr("attempts + 1"))
|
||
return nil, "", ErrLoginCodeInvalid
|
||
}
|
||
|
||
var user model.User
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
now := time.Now()
|
||
result := tx.Model(&model.EmailLoginCode{}).Where("id = ? AND used_at IS NULL", record.ID).Update("used_at", &now)
|
||
if result.Error != nil || result.RowsAffected != 1 {
|
||
return ErrLoginCodeInvalid
|
||
}
|
||
if err := tx.Where("email = ?", email).First(&user).Error; err == nil {
|
||
return nil
|
||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return err
|
||
}
|
||
identity := sha256.Sum256([]byte(email))
|
||
user = model.User{OpenID: "email:" + hex.EncodeToString(identity[:])[:32], Email: email, Nickname: strings.Split(email, "@")[0], Status: 1}
|
||
return tx.Create(&user).Error
|
||
})
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if user.Status != 1 {
|
||
return nil, "", errors.New("account disabled")
|
||
}
|
||
token, err := NewUserService().GenerateToken(user.ID, s.config.JWT.Secret)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return &user, token, nil
|
||
}
|
||
|
||
func (s *EmailAuthService) hashCode(email, code string) string {
|
||
mac := hmac.New(sha256.New, []byte(s.config.JWT.Secret))
|
||
mac.Write([]byte(email + "|" + code))
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
func (s *EmailAuthService) sendCode(email, code string) error {
|
||
if s.config.SMTP.IsConfigured() {
|
||
return s.sendCodeWithSMTP(email, code)
|
||
}
|
||
if s.config.Postmark.ServerToken != "" && s.config.Postmark.FromEmail != "" {
|
||
return s.sendCodeWithPostmark(email, code)
|
||
}
|
||
if s.config.Server.Env != "production" {
|
||
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境需配置 SMTP 或 Postmark)", email, code)
|
||
return nil
|
||
}
|
||
return errors.New("email service is not configured")
|
||
}
|
||
|
||
func (s *EmailAuthService) sendCodeWithSMTP(email, code string) error {
|
||
settings := s.config.SMTP
|
||
port, err := strconv.Atoi(settings.Port)
|
||
if settings.Host == "" || settings.Username == "" || settings.Password == "" || settings.FromEmail == "" || err != nil || port < 1 || port > 65535 {
|
||
return errors.New("SMTP configuration is incomplete or invalid")
|
||
}
|
||
if settings.TLSMode != "implicit" && settings.TLSMode != "starttls" {
|
||
return errors.New("SMTP TLS mode must be implicit or starttls")
|
||
}
|
||
from, err := mail.ParseAddress(settings.FromEmail)
|
||
if err != nil || from.Address != settings.FromEmail {
|
||
return errors.New("SMTP sender address is invalid")
|
||
}
|
||
tlsConfig, err := s.smtpTLSConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||
address := net.JoinHostPort(settings.Host, settings.Port)
|
||
var connection net.Conn
|
||
if settings.TLSMode == "implicit" {
|
||
connection, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
|
||
} else {
|
||
connection, err = dialer.Dial("tcp", address)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("SMTP connection failed: %w", err)
|
||
}
|
||
defer connection.Close()
|
||
if err := connection.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||
return err
|
||
}
|
||
client, err := smtp.NewClient(connection, settings.Host)
|
||
if err != nil {
|
||
return fmt.Errorf("SMTP session setup failed: %w", err)
|
||
}
|
||
defer client.Close()
|
||
if settings.TLSMode == "starttls" {
|
||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||
return errors.New("SMTP server does not support STARTTLS")
|
||
}
|
||
if err := client.StartTLS(tlsConfig); err != nil {
|
||
return fmt.Errorf("SMTP TLS negotiation failed: %w", err)
|
||
}
|
||
}
|
||
if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil {
|
||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||
}
|
||
if err := client.Mail(from.Address); err != nil {
|
||
return fmt.Errorf("SMTP sender rejected: %w", err)
|
||
}
|
||
if err := client.Rcpt(email); err != nil {
|
||
return fmt.Errorf("SMTP recipient rejected: %w", err)
|
||
}
|
||
writer, err := client.Data()
|
||
if err != nil {
|
||
return fmt.Errorf("SMTP message rejected: %w", err)
|
||
}
|
||
fromHeader := (&mail.Address{Name: "知命网", Address: from.Address}).String()
|
||
toHeader := (&mail.Address{Address: email}).String()
|
||
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。\r\n",
|
||
fromHeader,
|
||
toHeader,
|
||
mime.QEncoding.Encode("UTF-8", "知命登录验证码"),
|
||
time.Now().Format(time.RFC1123Z),
|
||
code,
|
||
)
|
||
if _, err := io.WriteString(writer, message); err != nil {
|
||
closeErr := writer.Close()
|
||
if closeErr != nil {
|
||
return errors.Join(err, closeErr)
|
||
}
|
||
return err
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
return fmt.Errorf("SMTP server did not accept the message: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *EmailAuthService) smtpTLSConfig() (*tls.Config, error) {
|
||
settings := s.config.SMTP
|
||
config := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: settings.Host}
|
||
if settings.TLSCAFile == "" {
|
||
return config, nil
|
||
}
|
||
certificatePEM, err := os.ReadFile(settings.TLSCAFile)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read SMTP CA file: %w", err)
|
||
}
|
||
roots, err := x509.SystemCertPool()
|
||
if err != nil || roots == nil {
|
||
roots = x509.NewCertPool()
|
||
}
|
||
if !roots.AppendCertsFromPEM(certificatePEM) {
|
||
return nil, errors.New("SMTP CA file contains no valid certificates")
|
||
}
|
||
config.RootCAs = roots
|
||
return config, nil
|
||
}
|
||
|
||
func (s *EmailAuthService) sendCodeWithPostmark(email, code string) error {
|
||
payload := map[string]string{
|
||
"From": s.config.Postmark.FromEmail,
|
||
"To": email,
|
||
"Subject": "知命登录验证码",
|
||
"TextBody": fmt.Sprintf("你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。", code),
|
||
"HtmlBody": fmt.Sprintf("<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:auto;padding:32px\"><h2>知命登录验证码</h2><p>你的验证码是:</p><p style=\"font-size:32px;letter-spacing:8px;font-weight:bold\">%s</p><p>10 分钟内有效。若非本人操作,请忽略此邮件。</p></div>", code),
|
||
"MessageStream": "outbound",
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.config.Postmark.APIBaseURL, "/")+"/email", strings.NewReader(string(body)))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Accept", "application/json")
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("X-Postmark-Server-Token", s.config.Postmark.ServerToken)
|
||
resp, err := s.client.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("postmark send failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func randomSixDigitCode() (string, error) {
|
||
bytes := make([]byte, 4)
|
||
if _, err := rand.Read(bytes); err != nil {
|
||
return "", err
|
||
}
|
||
value := (uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3])) % 1000000
|
||
return fmt.Sprintf("%06d", value), nil
|
||
}
|