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)
|
|||
|
|
}
|