Files
backend/internal/service/email_code_service.go
lafay b0f209fdf8
All checks were successful
Build Backend / build (push) Successful in 4m47s
Build Backend / build-docker (push) Successful in 2m22s
fix(security): security audit fixes for API vulnerabilities
Security fixes:
- Fix sensitive data exposure: UserResponse no longer contains email/phone
- Add UserSelfResponse for authenticated user's own data
- Protect Gorse endpoints with admin role requirement
- Add rate limiting (10 req/min) to auth endpoints
- Fix CORS configuration to use allowed origins list
- Add pagination parameter validation (max 100 per page)

Changes:
- dto/dto.go: Add UserSelfResponse type
- dto/user_converter.go: Add ConvertUserToSelfResponse
- handler/user_handler.go: Use secure response types
- middleware/cors.go: Implement origin whitelist
- middleware/ratelimit.go: Enhance rate limiter
- router/router.go: Add auth rate limit, protect Gorse
- service/admin_*.go: Use ConvertUserToResponse
2026-03-19 13:49:51 +08:00

260 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
verifyCodeIPRateLimitTTL = 60 * time.Second
verifyCodeIPRateLimitCount = 5
)
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, clientIP string) error
VerifyCode(purpose, email, code string) error
}
type emailCodeServiceImpl struct {
emailService EmailService
cache cache.Cache
}
func NewEmailCodeService(emailService EmailService, cacheBackend cache.Cache) EmailCodeService {
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 verificationCodeIPRateLimitKey(clientIP string) string {
return fmt.Sprintf("auth:verify_code_ip_rate_limit:%s", clientIP)
}
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, clientIP 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
}
if clientIP != "" {
ipRateLimitKey := verificationCodeIPRateLimitKey(clientIP)
if count, ok := s.cache.Get(ipRateLimitKey); ok {
if c, ok := count.(int); ok && c >= verifyCodeIPRateLimitCount {
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)
if clientIP != "" {
ipRateLimitKey := verificationCodeIPRateLimitKey(clientIP)
if count, ok := s.cache.Get(ipRateLimitKey); ok {
if c, ok := count.(int); ok {
s.cache.Set(ipRateLimitKey, c+1, verifyCodeIPRateLimitTTL)
} else {
s.cache.Set(ipRateLimitKey, 1, verifyCodeIPRateLimitTTL)
}
} else {
s.cache.Set(ipRateLimitKey, 1, verifyCodeIPRateLimitTTL)
}
}
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)
}