Files
lunar-mini/server/internal/service/email_auth.go
T
gouki deb32f8fab
Build and Publish Server / build (push) Successful in 4m30s
feat(server): 商业化支付与订单系统 + Harness 改进
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理
- 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode
- 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/*
- 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件
- 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例
- 新增 AGENTS.md 项目代理指令文件
- 新增 REMINDER.md 商业化功能开发规范
- 修复 JWT 解析:添加 WithValidMethods 限制 HS256
- 修复 GetPayStatus:增加用户权限校验
- 更新 .gitignore 和 README.md
2026-09-19 18:57:23 +00:00

176 lines
5.8 KiB
Go

package service
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/mail"
"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.Postmark.ServerToken == "" || s.config.Postmark.FromEmail == "" {
// 开发模式:Postmark 未配置时,验证码打印到服务端控制台,方便本地联调
if s.config.Server.Env != "production" {
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境将自动通过 Postmark 发送)", email, code)
return nil
}
return errors.New("email service is not configured")
}
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
}