email_service.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "crypto/subtle"
  6. "crypto/tls"
  7. "crypto/x509"
  8. "encoding/hex"
  9. "errors"
  10. "fmt"
  11. "html"
  12. "log/slog"
  13. "math/big"
  14. "net"
  15. "net/smtp"
  16. "net/url"
  17. "strconv"
  18. "strings"
  19. "time"
  20. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  21. )
  22. var (
  23. ErrEmailNotConfigured = infraerrors.ServiceUnavailable("EMAIL_NOT_CONFIGURED", "email service not configured")
  24. ErrInvalidVerifyCode = infraerrors.BadRequest("INVALID_VERIFY_CODE", "invalid or expired verification code")
  25. ErrVerifyCodeTooFrequent = infraerrors.TooManyRequests("VERIFY_CODE_TOO_FREQUENT", "please wait before requesting a new code")
  26. ErrVerifyCodeMaxAttempts = infraerrors.TooManyRequests("VERIFY_CODE_MAX_ATTEMPTS", "too many failed attempts, please request a new code")
  27. // Password reset errors
  28. ErrInvalidResetToken = infraerrors.BadRequest("INVALID_RESET_TOKEN", "invalid or expired password reset token")
  29. )
  30. // EmailCache defines cache operations for email service
  31. type EmailCache interface {
  32. GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error)
  33. SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
  34. DeleteVerificationCode(ctx context.Context, email string) error
  35. // Notify email verification code methods
  36. GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error)
  37. SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
  38. DeleteNotifyVerifyCode(ctx context.Context, email string) error
  39. // Password reset token methods
  40. GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error)
  41. SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error
  42. DeletePasswordResetToken(ctx context.Context, email string) error
  43. // Password reset email cooldown methods
  44. // Returns true if in cooldown period (email was sent recently)
  45. IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool
  46. SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error
  47. // Notify code rate limiting per user
  48. IncrNotifyCodeUserRate(ctx context.Context, userID int64, window time.Duration) (int64, error)
  49. GetNotifyCodeUserRate(ctx context.Context, userID int64) (int64, error)
  50. }
  51. // VerificationCodeData represents verification code data
  52. type VerificationCodeData struct {
  53. Code string
  54. Attempts int
  55. CreatedAt time.Time
  56. ExpiresAt time.Time // absolute expiry; used to preserve remaining TTL when updating attempts
  57. }
  58. // PasswordResetTokenData represents password reset token data
  59. type PasswordResetTokenData struct {
  60. Token string
  61. CreatedAt time.Time
  62. }
  63. const (
  64. verifyCodeTTL = 15 * time.Minute
  65. verifyCodeCooldown = 1 * time.Minute
  66. maxVerifyCodeAttempts = 5
  67. // Password reset token settings
  68. passwordResetTokenTTL = 30 * time.Minute
  69. // Password reset email cooldown (prevent email bombing)
  70. passwordResetEmailCooldown = 30 * time.Second
  71. )
  72. // SMTPConfig SMTP配置
  73. type SMTPConfig struct {
  74. Host string
  75. Port int
  76. Username string
  77. Password string
  78. From string
  79. FromName string
  80. UseTLS bool
  81. }
  82. // EmailService 邮件服务
  83. type EmailService struct {
  84. settingRepo SettingRepository
  85. cache EmailCache
  86. notificationEmailService *NotificationEmailService
  87. }
  88. // NewEmailService 创建邮件服务实例
  89. func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailService {
  90. return &EmailService{
  91. settingRepo: settingRepo,
  92. cache: cache,
  93. }
  94. }
  95. func (s *EmailService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
  96. s.notificationEmailService = notificationEmailService
  97. }
  98. func firstEmailLocale(locales []string) string {
  99. if len(locales) == 0 {
  100. return ""
  101. }
  102. return strings.TrimSpace(locales[0])
  103. }
  104. func emailRecipientName(email string) string {
  105. trimmed := strings.TrimSpace(email)
  106. if trimmed == "" {
  107. return ""
  108. }
  109. if at := strings.Index(trimmed, "@"); at > 0 {
  110. return trimmed[:at]
  111. }
  112. return trimmed
  113. }
  114. // GetSMTPConfig 从数据库获取SMTP配置
  115. func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
  116. keys := []string{
  117. SettingKeySMTPHost,
  118. SettingKeySMTPPort,
  119. SettingKeySMTPUsername,
  120. SettingKeySMTPPassword,
  121. SettingKeySMTPFrom,
  122. SettingKeySMTPFromName,
  123. SettingKeySMTPUseTLS,
  124. }
  125. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  126. if err != nil {
  127. return nil, fmt.Errorf("get smtp settings: %w", err)
  128. }
  129. host := strings.TrimSpace(settings[SettingKeySMTPHost])
  130. if host == "" {
  131. return nil, ErrEmailNotConfigured
  132. }
  133. port := 587 // 默认端口
  134. if portStr := settings[SettingKeySMTPPort]; portStr != "" {
  135. if p, err := strconv.Atoi(portStr); err == nil {
  136. port = p
  137. }
  138. }
  139. useTLS := settings[SettingKeySMTPUseTLS] == "true"
  140. return &SMTPConfig{
  141. Host: host,
  142. Port: port,
  143. Username: strings.TrimSpace(settings[SettingKeySMTPUsername]),
  144. Password: strings.TrimSpace(settings[SettingKeySMTPPassword]),
  145. From: strings.TrimSpace(settings[SettingKeySMTPFrom]),
  146. FromName: strings.TrimSpace(settings[SettingKeySMTPFromName]),
  147. UseTLS: useTLS,
  148. }, nil
  149. }
  150. // SendEmail 发送邮件(使用数据库中保存的配置)
  151. func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) error {
  152. config, err := s.GetSMTPConfig(ctx)
  153. if err != nil {
  154. return err
  155. }
  156. return s.SendEmailWithConfig(config, to, subject, body)
  157. }
  158. const smtpDialTimeout = 10 * time.Second
  159. const smtpIOTimeout = 20 * time.Second
  160. // SendEmailWithConfig 使用指定配置发送邮件
  161. func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error {
  162. message, err := buildSMTPMessage(config, to, subject, body)
  163. if err != nil {
  164. return err
  165. }
  166. client, err := s.connectSMTP(config)
  167. if err != nil {
  168. return err
  169. }
  170. defer func() { _ = client.Close() }()
  171. auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
  172. if err = client.Auth(auth); err != nil {
  173. return fmt.Errorf("smtp auth: %w", err)
  174. }
  175. if err = client.Mail(message.envelopeFrom); err != nil {
  176. return fmt.Errorf("smtp mail: %w", err)
  177. }
  178. if err = client.Rcpt(message.envelopeTo); err != nil {
  179. return fmt.Errorf("smtp rcpt: %w", err)
  180. }
  181. w, err := client.Data()
  182. if err != nil {
  183. return fmt.Errorf("smtp data: %w", err)
  184. }
  185. if _, err = w.Write(message.data); err != nil {
  186. return fmt.Errorf("write msg: %w", err)
  187. }
  188. if err = w.Close(); err != nil {
  189. return fmt.Errorf("close writer: %w", err)
  190. }
  191. // Email is sent successfully after w.Close(), ignore Quit errors
  192. // Some SMTP servers return non-standard responses on QUIT
  193. _ = client.Quit()
  194. return nil
  195. }
  196. // smtpTestRootCAs 仅供单元测试注入自签 CA,生产环境始终为 nil(走系统信任链)。
  197. var smtpTestRootCAs *x509.CertPool
  198. func smtpTLSConfig(host string) *tls.Config {
  199. return &tls.Config{
  200. ServerName: host,
  201. // 强制 TLS 1.2+,避免协议降级导致的弱加密风险。
  202. MinVersion: tls.VersionTLS12,
  203. RootCAs: smtpTestRootCAs,
  204. }
  205. }
  206. // connectSMTP 按配置建立 SMTP 会话,发送与测试连接共用此路径,
  207. // 保证"测试连接成功 ⇔ 实际发信可用":
  208. // - UseTLS=true:先尝试隐式 TLS(465 语义);若服务器以明文应答
  209. // (587/25 等提交端口的 STARTTLS 语义),自动改走"明文连接 + 强制 STARTTLS"。
  210. // 两种方式都无法建立加密连接时报错,绝不明文继续。
  211. // - UseTLS=false:明文连接后若服务器支持 STARTTLS 则机会式升级,
  212. // 与 smtp.SendMail 的默认行为一致。
  213. func (s *EmailService) connectSMTP(config *SMTPConfig) (*smtp.Client, error) {
  214. addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
  215. dialer := &net.Dialer{Timeout: smtpDialTimeout}
  216. tlsConfig := smtpTLSConfig(config.Host)
  217. if config.UseTLS {
  218. conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
  219. if err == nil {
  220. return newSMTPClient(conn, config.Host)
  221. }
  222. var recordErr tls.RecordHeaderError
  223. if !errors.As(err, &recordErr) {
  224. return nil, fmt.Errorf("tls dial: %w", err)
  225. }
  226. // SMTP 服务器先发问候语:明文问候会让 TLS 握手立刻返回
  227. // RecordHeaderError,据此可靠判定对端期望 STARTTLS。
  228. return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, true)
  229. }
  230. return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, false)
  231. }
  232. // connectSMTPStartTLS 建立明文连接并按需升级 STARTTLS。
  233. // mandatory 为 true 时服务器必须支持 STARTTLS,否则报错。
  234. func (s *EmailService) connectSMTPStartTLS(dialer *net.Dialer, addr, host string, tlsConfig *tls.Config, mandatory bool) (*smtp.Client, error) {
  235. conn, err := dialer.Dial("tcp", addr)
  236. if err != nil {
  237. return nil, fmt.Errorf("smtp dial: %w", err)
  238. }
  239. client, err := newSMTPClient(conn, host)
  240. if err != nil {
  241. return nil, err
  242. }
  243. if ok, _ := client.Extension("STARTTLS"); !ok {
  244. if mandatory {
  245. _ = client.Close()
  246. return nil, errors.New("smtp server does not support STARTTLS")
  247. }
  248. return client, nil
  249. }
  250. if err := client.StartTLS(tlsConfig); err != nil {
  251. _ = client.Close()
  252. return nil, fmt.Errorf("starttls: %w", err)
  253. }
  254. return client, nil
  255. }
  256. func newSMTPClient(conn net.Conn, host string) (*smtp.Client, error) {
  257. _ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
  258. client, err := smtp.NewClient(conn, host)
  259. if err != nil {
  260. _ = conn.Close()
  261. return nil, fmt.Errorf("new smtp client: %w", err)
  262. }
  263. return client, nil
  264. }
  265. // GenerateVerifyCode 生成6位数字验证码
  266. func (s *EmailService) GenerateVerifyCode() (string, error) {
  267. const digits = "0123456789"
  268. code := make([]byte, 6)
  269. for i := range code {
  270. num, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
  271. if err != nil {
  272. return "", err
  273. }
  274. code[i] = digits[num.Int64()]
  275. }
  276. return string(code), nil
  277. }
  278. // SendVerifyCode 发送验证码邮件
  279. func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string, locale ...string) error {
  280. // 检查是否在冷却期内
  281. existing, err := s.cache.GetVerificationCode(ctx, email)
  282. if err == nil && existing != nil {
  283. if time.Since(existing.CreatedAt) < verifyCodeCooldown {
  284. return ErrVerifyCodeTooFrequent
  285. }
  286. }
  287. // 生成验证码
  288. code, err := s.GenerateVerifyCode()
  289. if err != nil {
  290. return fmt.Errorf("generate code: %w", err)
  291. }
  292. // 保存验证码到 Redis
  293. data := &VerificationCodeData{
  294. Code: code,
  295. Attempts: 0,
  296. CreatedAt: time.Now(),
  297. ExpiresAt: time.Now().Add(verifyCodeTTL),
  298. }
  299. if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil {
  300. return fmt.Errorf("save verify code: %w", err)
  301. }
  302. if s.notificationEmailService != nil {
  303. err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  304. Event: NotificationEmailEventAuthVerifyCode,
  305. Locale: firstEmailLocale(locale),
  306. RecipientEmail: email,
  307. RecipientName: emailRecipientName(email),
  308. Variables: map[string]string{
  309. "verification_code": code,
  310. "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)),
  311. },
  312. })
  313. if err == nil {
  314. return nil
  315. }
  316. if !shouldFallbackNotificationEmail(err) {
  317. return err
  318. }
  319. slog.Warn("failed to send templated verification email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err)
  320. }
  321. // 构建邮件内容
  322. subject := fmt.Sprintf("[%s] Email Verification Code", siteName)
  323. body := s.buildVerifyCodeEmailBody(code, siteName)
  324. // 发送邮件
  325. if err := s.SendEmail(ctx, email, subject, body); err != nil {
  326. return fmt.Errorf("send email: %w", err)
  327. }
  328. return nil
  329. }
  330. // VerifyCode 验证验证码
  331. func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error {
  332. data, err := s.cache.GetVerificationCode(ctx, email)
  333. if err != nil || data == nil {
  334. return ErrInvalidVerifyCode
  335. }
  336. // 检查是否已达到最大尝试次数
  337. if data.Attempts >= maxVerifyCodeAttempts {
  338. return ErrVerifyCodeMaxAttempts
  339. }
  340. // 验证码不匹配 (constant-time comparison to prevent timing attacks)
  341. if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
  342. data.Attempts++
  343. remaining := time.Until(data.ExpiresAt)
  344. if remaining <= 0 {
  345. return ErrInvalidVerifyCode
  346. }
  347. if err := s.cache.SetVerificationCode(ctx, email, data, remaining); err != nil {
  348. slog.Error("failed to update verification attempt count", "email", email, "error", err)
  349. }
  350. if data.Attempts >= maxVerifyCodeAttempts {
  351. return ErrVerifyCodeMaxAttempts
  352. }
  353. return ErrInvalidVerifyCode
  354. }
  355. // 验证成功,删除验证码
  356. if err := s.cache.DeleteVerificationCode(ctx, email); err != nil {
  357. slog.Error("failed to delete verification code after success", "email", email, "error", err)
  358. }
  359. return nil
  360. }
  361. // buildVerifyCodeEmailBody 构建验证码邮件HTML内容
  362. func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string {
  363. return fmt.Sprintf(`
  364. <!DOCTYPE html>
  365. <html>
  366. <head>
  367. <meta charset="UTF-8">
  368. <style>
  369. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
  370. .container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
  371. .header { background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; padding: 30px; text-align: center; }
  372. .header h1 { margin: 0; font-size: 24px; }
  373. .content { padding: 40px 30px; text-align: center; }
  374. .code { font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #333; background-color: #f8f9fa; padding: 20px 30px; border-radius: 8px; display: inline-block; margin: 20px 0; font-family: monospace; }
  375. .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
  376. .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
  377. </style>
  378. </head>
  379. <body>
  380. <div class="container">
  381. <div class="header">
  382. <h1>%s</h1>
  383. </div>
  384. <div class="content">
  385. <p style="font-size: 18px; color: #333;">Your verification code is:</p>
  386. <div class="code">%s</div>
  387. <div class="info">
  388. <p>This code will expire in <strong>15 minutes</strong>.</p>
  389. <p>If you did not request this code, please ignore this email.</p>
  390. </div>
  391. </div>
  392. <div class="footer">
  393. <p>This is an automated message, please do not reply.</p>
  394. </div>
  395. </div>
  396. </body>
  397. </html>
  398. `, html.EscapeString(siteName), code)
  399. }
  400. // TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接。
  401. // 与 SendEmailWithConfig 共用 connectSMTP 建连(含 STARTTLS 升级逻辑),
  402. // 避免出现"测试连接失败但实际发信成功"的不一致。
  403. func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error {
  404. client, err := s.connectSMTP(config)
  405. if err != nil {
  406. return fmt.Errorf("smtp connection failed: %w", err)
  407. }
  408. defer func() { _ = client.Close() }()
  409. auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
  410. if err := client.Auth(auth); err != nil {
  411. return fmt.Errorf("smtp authentication failed: %w", err)
  412. }
  413. // 认证成功即视为连接可用;与发送路径一致,忽略 QUIT 的非标准响应。
  414. _ = client.Quit()
  415. return nil
  416. }
  417. // GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters)
  418. func (s *EmailService) GeneratePasswordResetToken() (string, error) {
  419. bytes := make([]byte, 32)
  420. if _, err := rand.Read(bytes); err != nil {
  421. return "", err
  422. }
  423. return hex.EncodeToString(bytes), nil
  424. }
  425. // SendPasswordResetEmail sends a password reset email with a reset link
  426. func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string, locale ...string) error {
  427. var token string
  428. var needSaveToken bool
  429. // Check if token already exists
  430. existing, err := s.cache.GetPasswordResetToken(ctx, email)
  431. if err == nil && existing != nil {
  432. // Token exists, reuse it (allows resending email without generating new token)
  433. token = existing.Token
  434. needSaveToken = false
  435. } else {
  436. // Generate new token
  437. token, err = s.GeneratePasswordResetToken()
  438. if err != nil {
  439. return fmt.Errorf("generate token: %w", err)
  440. }
  441. needSaveToken = true
  442. }
  443. // Save token to Redis (only if new token generated)
  444. if needSaveToken {
  445. data := &PasswordResetTokenData{
  446. Token: token,
  447. CreatedAt: time.Now(),
  448. }
  449. if err := s.cache.SetPasswordResetToken(ctx, email, data, passwordResetTokenTTL); err != nil {
  450. return fmt.Errorf("save reset token: %w", err)
  451. }
  452. }
  453. // Build full reset URL with URL-encoded token and email
  454. fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token))
  455. if s.notificationEmailService != nil {
  456. err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  457. Event: NotificationEmailEventAuthPasswordReset,
  458. Locale: firstEmailLocale(locale),
  459. RecipientEmail: email,
  460. RecipientName: emailRecipientName(email),
  461. Variables: map[string]string{
  462. "reset_url": fullResetURL,
  463. "expires_in_minutes": strconv.Itoa(int(passwordResetTokenTTL / time.Minute)),
  464. },
  465. })
  466. if err == nil {
  467. return nil
  468. }
  469. if !shouldFallbackNotificationEmail(err) {
  470. return err
  471. }
  472. slog.Warn("failed to send templated password reset email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err)
  473. }
  474. // Build email content
  475. subject := fmt.Sprintf("[%s] 密码重置请求", siteName)
  476. body := s.buildPasswordResetEmailBody(fullResetURL, siteName)
  477. // Send email
  478. if err := s.SendEmail(ctx, email, subject, body); err != nil {
  479. return fmt.Errorf("send email: %w", err)
  480. }
  481. return nil
  482. }
  483. // SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker)
  484. // This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing
  485. func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string, locale ...string) error {
  486. // Check email cooldown to prevent email bombing
  487. if s.cache.IsPasswordResetEmailInCooldown(ctx, email) {
  488. slog.Info("password reset email skipped due to cooldown", "email", email)
  489. return nil // Silent success to prevent revealing cooldown to attackers
  490. }
  491. // Send email using core method
  492. if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil {
  493. return err
  494. }
  495. // Set cooldown marker (Redis TTL handles expiration)
  496. if err := s.cache.SetPasswordResetEmailCooldown(ctx, email, passwordResetEmailCooldown); err != nil {
  497. slog.Error("failed to set password reset cooldown", "email", email, "error", err)
  498. }
  499. return nil
  500. }
  501. // VerifyPasswordResetToken verifies the password reset token without consuming it
  502. func (s *EmailService) VerifyPasswordResetToken(ctx context.Context, email, token string) error {
  503. data, err := s.cache.GetPasswordResetToken(ctx, email)
  504. if err != nil || data == nil {
  505. return ErrInvalidResetToken
  506. }
  507. // Use constant-time comparison to prevent timing attacks
  508. if subtle.ConstantTimeCompare([]byte(data.Token), []byte(token)) != 1 {
  509. return ErrInvalidResetToken
  510. }
  511. return nil
  512. }
  513. // ConsumePasswordResetToken verifies and deletes the token (one-time use)
  514. func (s *EmailService) ConsumePasswordResetToken(ctx context.Context, email, token string) error {
  515. // Verify first
  516. if err := s.VerifyPasswordResetToken(ctx, email, token); err != nil {
  517. return err
  518. }
  519. // Delete after verification (one-time use)
  520. if err := s.cache.DeletePasswordResetToken(ctx, email); err != nil {
  521. slog.Error("failed to delete password reset token after consumption", "email", email, "error", err)
  522. }
  523. return nil
  524. }
  525. // buildPasswordResetEmailBody builds the HTML content for password reset email
  526. func (s *EmailService) buildPasswordResetEmailBody(resetURL, siteName string) string {
  527. return fmt.Sprintf(`
  528. <!DOCTYPE html>
  529. <html>
  530. <head>
  531. <meta charset="UTF-8">
  532. <style>
  533. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
  534. .container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
  535. .header { background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; padding: 30px; text-align: center; }
  536. .header h1 { margin: 0; font-size: 24px; }
  537. .content { padding: 40px 30px; text-align: center; }
  538. .button { display: inline-block; background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 20px 0; }
  539. .button:hover { opacity: 0.9; }
  540. .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
  541. .link-fallback { color: #666; font-size: 12px; word-break: break-all; margin-top: 20px; padding: 15px; background-color: #f8f9fa; border-radius: 4px; }
  542. .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
  543. .warning { color: #e74c3c; font-weight: 500; }
  544. </style>
  545. </head>
  546. <body>
  547. <div class="container">
  548. <div class="header">
  549. <h1>%s</h1>
  550. </div>
  551. <div class="content">
  552. <p style="font-size: 18px; color: #333;">密码重置请求</p>
  553. <p style="color: #666;">您已请求重置密码。请点击下方按钮设置新密码:</p>
  554. <a href="%s" class="button">重置密码</a>
  555. <div class="info">
  556. <p>此链接将在 <strong>30 分钟</strong>后失效。</p>
  557. <p class="warning">如果您没有请求重置密码,请忽略此邮件。您的密码将保持不变。</p>
  558. </div>
  559. <div class="link-fallback">
  560. <p>如果按钮无法点击,请复制以下链接到浏览器中打开:</p>
  561. <p>%s</p>
  562. </div>
  563. </div>
  564. <div class="footer">
  565. <p>这是一封自动发送的邮件,请勿回复。</p>
  566. </div>
  567. </div>
  568. </body>
  569. </html>
  570. `, html.EscapeString(siteName), html.EscapeString(resetURL), html.EscapeString(resetURL))
  571. }