Files
lunar-mini/server/internal/service/email_auth.go
T
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c161d42b06
Build and Publish Server / build (push) Successful in 2m6s
feat(server): 接入 QQ 邮箱 SMTP 发送登录验证码
- 支持 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>
2026-09-27 23:24:46 +00:00

290 lines
9.5 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 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
}