Files
backend/internal/service/email_code_service.go

235 lines
7.6 KiB
Go
Raw Normal View History

package service
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"strings"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/pkg/utils"
)
const (
verifyCodeTTL = 10 * time.Minute
verifyCodeRateLimitTTL = 60 * time.Second
)
const (
CodePurposeRegister = "register"
CodePurposePasswordReset = "password_reset"
CodePurposeEmailVerify = "email_verify"
CodePurposeChangePassword = "change_password"
)
type verificationCodePayload struct {
Code string `json:"code"`
Purpose string `json:"purpose"`
Email string `json:"email"`
ExpiresAt int64 `json:"expires_at"`
}
type EmailCodeService interface {
SendCode(ctx context.Context, purpose, email string) error
VerifyCode(purpose, email, code string) error
}
type emailCodeServiceImpl struct {
emailService EmailService
cache cache.Cache
}
func NewEmailCodeService(emailService EmailService, cacheBackend cache.Cache) EmailCodeService {
if cacheBackend == nil {
cacheBackend = cache.GetCache()
}
return &emailCodeServiceImpl{
emailService: emailService,
cache: cacheBackend,
}
}
func verificationCodeCacheKey(purpose, email string) string {
return fmt.Sprintf("auth:verify_code:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email)))
}
func verificationCodeRateLimitKey(purpose, email string) string {
return fmt.Sprintf("auth:verify_code_rate_limit:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email)))
}
func generateNumericCode(length int) (string, error) {
if length <= 0 {
return "", fmt.Errorf("invalid code length")
}
max := big.NewInt(10)
result := make([]byte, length)
for i := 0; i < length; i++ {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
result[i] = byte('0' + n.Int64())
}
return string(result), nil
}
func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email string) error {
if strings.TrimSpace(email) == "" || !utils.ValidateEmail(email) {
return ErrInvalidEmail
}
if s.emailService == nil || !s.emailService.IsEnabled() {
return ErrEmailServiceUnavailable
}
if s.cache == nil {
return ErrVerificationCodeUnavailable
}
rateLimitKey := verificationCodeRateLimitKey(purpose, email)
if s.cache.Exists(rateLimitKey) {
return ErrVerificationCodeTooFrequent
}
code, err := generateNumericCode(6)
if err != nil {
return fmt.Errorf("generate verification code failed: %w", err)
}
payload := verificationCodePayload{
Code: code,
Purpose: purpose,
Email: strings.ToLower(strings.TrimSpace(email)),
ExpiresAt: time.Now().Add(verifyCodeTTL).Unix(),
}
cacheKey := verificationCodeCacheKey(purpose, email)
s.cache.Set(cacheKey, payload, verifyCodeTTL)
s.cache.Set(rateLimitKey, "1", verifyCodeRateLimitTTL)
subject, sceneText := verificationEmailMeta(purpose)
textBody := fmt.Sprintf("【%s】验证码%s\n有效期10分钟\n请勿将验证码泄露给他人。", sceneText, code)
htmlBody := buildVerificationEmailHTML(sceneText, code)
if err := s.emailService.Send(ctx, SendEmailRequest{
To: []string{email},
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
}); err != nil {
s.cache.Delete(cacheKey)
return fmt.Errorf("send verification email failed: %w", err)
}
return nil
}
func (s *emailCodeServiceImpl) VerifyCode(purpose, email, code string) error {
if strings.TrimSpace(email) == "" || strings.TrimSpace(code) == "" {
return ErrVerificationCodeInvalid
}
if s.cache == nil {
return ErrVerificationCodeUnavailable
}
cacheKey := verificationCodeCacheKey(purpose, email)
raw, ok := s.cache.Get(cacheKey)
if !ok {
return ErrVerificationCodeExpired
}
var payload verificationCodePayload
switch v := raw.(type) {
case string:
if err := json.Unmarshal([]byte(v), &payload); err != nil {
return ErrVerificationCodeInvalid
}
case []byte:
if err := json.Unmarshal(v, &payload); err != nil {
return ErrVerificationCodeInvalid
}
case verificationCodePayload:
payload = v
default:
data, err := json.Marshal(v)
if err != nil {
return ErrVerificationCodeInvalid
}
if err := json.Unmarshal(data, &payload); err != nil {
return ErrVerificationCodeInvalid
}
}
if payload.Purpose != purpose || payload.Email != strings.ToLower(strings.TrimSpace(email)) {
return ErrVerificationCodeInvalid
}
if payload.ExpiresAt > 0 && time.Now().Unix() > payload.ExpiresAt {
s.cache.Delete(cacheKey)
return ErrVerificationCodeExpired
}
if payload.Code != strings.TrimSpace(code) {
return ErrVerificationCodeInvalid
}
s.cache.Delete(cacheKey)
return nil
}
func verificationEmailMeta(purpose string) (subject string, sceneText string) {
switch purpose {
case CodePurposeRegister:
return "Carrot BBS 注册验证码", "注册账号"
case CodePurposePasswordReset:
return "Carrot BBS 找回密码验证码", "找回密码"
case CodePurposeEmailVerify:
return "Carrot BBS 邮箱验证验证码", "验证邮箱"
case CodePurposeChangePassword:
return "Carrot BBS 修改密码验证码", "修改密码"
default:
return "Carrot BBS 验证码", "身份验证"
}
}
func buildVerificationEmailHTML(sceneText, code string) string {
return fmt.Sprintf(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Carrot BBS 验证码</title>
</head>
<body style="margin:0;padding:0;background:#f4f6fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;color:#1f2937;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="background:#f4f6fb;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 8px 30px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#ff6b35,#ff8f66);padding:24px 28px;color:#ffffff;">
<div style="font-size:22px;font-weight:700;line-height:1.2;">Carrot BBS</div>
<div style="margin-top:6px;font-size:14px;opacity:0.95;">%s 验证</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 14px;font-size:15px;line-height:1.75;">你好</p>
<p style="margin:0 0 20px;font-size:15px;line-height:1.75;">你正在进行 <strong>%s</strong> 操作请使用下方验证码完成验证</p>
<div style="margin:0 auto 18px;max-width:320px;border:1px dashed #ff8f66;background:#fff8f4;border-radius:12px;padding:14px 12px;text-align:center;">
<div style="font-size:13px;color:#9a3412;letter-spacing:0.5px;">验证码10分钟内有效</div>
<div style="margin-top:8px;font-size:34px;line-height:1;font-weight:800;letter-spacing:8px;color:#ea580c;">%s</div>
</div>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.7;">如果不是你本人操作请忽略此邮件并及时检查账号安全</p>
<p style="margin:0;font-size:13px;color:#6b7280;line-height:1.7;">请勿向任何人透露验证码平台不会以任何理由索取验证码</p>
</td>
</tr>
<tr>
<td style="padding:14px 28px;background:#f8fafc;border-top:1px solid #e5e7eb;color:#94a3b8;font-size:12px;line-height:1.7;">
此邮件由系统自动发送请勿直接回复<br/>
© Carrot BBS
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`, sceneText, sceneText, code)
}