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("

知命登录验证码

你的验证码是:

%s

10 分钟内有效。若非本人操作,请忽略此邮件。

", 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 }