Set up project files and add .gitignore to exclude local build/runtime artifacts. Made-with: Cursor
235 lines
7.6 KiB
Go
235 lines
7.6 KiB
Go
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)
|
||
}
|