package common import ( "context" "crypto/rand" "fmt" "math/big" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" do "github.com/qianfree/team-api/internal/model/do" ) // VerifyPurpose defines the purpose of a verification code. type VerifyPurpose string const ( VerifyPurposeRegister VerifyPurpose = "register" VerifyPurposeResetPwd VerifyPurpose = "reset_password" VerifyPurposeChangeEmail VerifyPurpose = "change_email" ) // SendVerifyCode generates a 6-digit code, saves it to the database, and sends it via email. // Rate limits: 60s cooldown between sends, max 5 per hour. func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) error { email = strings.TrimSpace(strings.ToLower(email)) if !IsValidEmail(email) { return NewBadRequestError("邮箱格式无效") } // Rate limit: 60 second cooldown cooldownKey := fmt.Sprintf("verify:cooldown:%s:%s", email, purpose) cooldownVal, err := g.Redis().Do(ctx, "GET", cooldownKey) if err == nil && !cooldownVal.IsNil() { remaining, _ := g.Redis().Do(ctx, "TTL", cooldownKey) secs := remaining.Int() return NewBusinessError(consts.CodeRateLimitExceeded, fmt.Sprintf("请 %d 秒后再试", secs)) } // Rate limit: max 5 per hour (atomic Lua: INCR + first-time EXPIRE) hourlyKey := fmt.Sprintf("verify:hourly:%s:%s", email, purpose) hourlyCount, err := g.Redis().Do(ctx, "EVAL", `local count = redis.call("INCR", KEYS[1]) if count == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return count`, 1, hourlyKey, 3600) if err == nil && hourlyCount.Int() > 5 { return NewBusinessError(consts.CodeRateLimitExceeded, "每小时最多发送5次验证码") } // Generate 6-digit code code, err := generateCode(6) if err != nil { return fmt.Errorf("generate code: %w", err) } // Save to database _, err = dao.SysEmailVerifyCodes.Ctx(ctx).Data(do.SysEmailVerifyCodes{ Email: email, Code: code, Purpose: string(purpose), ExpiresAt: gtime.Now().Add(10 * time.Minute), }).Insert() if err != nil { return gerror.Wrapf(err, "save verify code") } // Send email (fire and forget, log error but don't fail) // Set cooldown before async send (prevents duplicate sends within 60s) if _, err := g.Redis().Do(ctx, "SETEX", cooldownKey, 60, "1"); err != nil { g.Log().Warningf(ctx, "failed to set verify code cooldown: %v", err) } // WithoutCancel:脱离请求取消(HTTP 响应先于邮件发出),但保留 request_id 等链路值, // 便于在日志里把「发码请求」和「实际投递结果」串起来。 bgCtx := context.WithoutCancel(ctx) go func() { defer func() { if r := recover(); r != nil { g.Log().Errorf(bgCtx, "send verify code email panic: %v", r) } }() emailCfg, err := EmailConfigFromOptions(bgCtx) if err != nil { g.Log().Errorf(bgCtx, "load email config: %v", err) return } sender := NewEmailSender(emailCfg) subject := fmt.Sprintf("验证码:%s", code) body := fmt.Sprintf( `
您的验证码是:%s
验证码 10 分钟内有效。如非本人操作,请忽略此邮件。
`, code, ) err = sender.Send(bgCtx, &EmailMessage{ To: email, Subject: subject, BodyHTML: body, TenantID: 0, UserID: 0, }) if err != nil { g.Log().Errorf(bgCtx, "send verify code email to %s: %v", email, err) } }() return nil } // maxVerifyAttempts 单个邮箱+用途在验证码有效期内允许的最大验证失败次数, // 超过后需重新获取验证码。用于防止 6 位数字验证码在 10 分钟窗口内被暴力枚举。 const maxVerifyAttempts = 5 // VerifyCode checks if a verification code is valid. func VerifyCode(ctx context.Context, email, code, purpose string) error { email = strings.TrimSpace(strings.ToLower(email)) code = strings.TrimSpace(code) if email == "" || code == "" || purpose == "" { return NewBadRequestError("参数不完整") } // 验证侧防暴力破解:先检查失败次数,超限直接拒绝 failKey := fmt.Sprintf("verify:fail:%s:%s", email, purpose) if cnt, err := g.Redis().Do(ctx, "GET", failKey); err == nil && !cnt.IsNil() && cnt.Int() >= maxVerifyAttempts { return NewBusinessError(consts.CodeRateLimitExceeded, "验证码错误次数过多,请重新获取验证码") } var record *struct { ID int64 `json:"id"` Code string `json:"code"` ExpiresAt *time.Time `json:"expires_at"` UsedAt *time.Time `json:"used_at"` } baseQuery := dao.SysEmailVerifyCodes.Ctx(ctx). Where("email", email). Where("purpose", purpose). Where("used_at IS NULL") err := baseQuery. Where("expires_at > NOW()"). OrderDesc("created_at"). Limit(1). Scan(&record) if err != nil { return gerror.Wrapf(err, "query verify code") } if record == nil { expiredCount, countErr := baseQuery.Where("expires_at <= NOW()").Count() if countErr != nil { return gerror.Wrapf(countErr, "query expired verify code") } if expiredCount > 0 { return NewBusinessError(consts.CodeEmailVerifyExpired, consts.MsgEmailVerifyExpired) } incrVerifyFail(ctx, failKey) return NewBusinessError(consts.CodeVerifyCodeInvalid, "验证码错误") } // Check expiration if record.ExpiresAt != nil && time.Now().After(*record.ExpiresAt) { return NewBusinessError(consts.CodeEmailVerifyExpired, consts.MsgEmailVerifyExpired) } // Check code match if record.Code != code { incrVerifyFail(ctx, failKey) return NewBusinessError(consts.CodeVerifyCodeInvalid, "验证码错误") } // Mark as used _, err = dao.SysEmailVerifyCodes.Ctx(ctx). Where("id", record.ID). Data(do.SysEmailVerifyCodes{ UsedAt: gtime.Now(), }). Update() if err != nil { return gerror.Wrapf(err, "mark code used") } // 验证成功,清除失败计数 if _, derr := g.Redis().Do(ctx, "DEL", failKey); derr != nil { g.Log().Warningf(ctx, "failed to clear verify fail counter: %v", derr) } return nil } // incrVerifyFail 原子递增验证码失败计数,并在首次失败时设置与验证码有效期一致的 TTL(10 分钟)。 func incrVerifyFail(ctx context.Context, failKey string) { _, err := g.Redis().Do(ctx, "EVAL", `local count = redis.call("INCR", KEYS[1]) if count == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return count`, 1, failKey, 600) if err != nil { g.Log().Warningf(ctx, "failed to incr verify fail counter: %v", err) } } // generateCode generates a random numeric code of the given length. func generateCode(length int) (string, error) { max := new(big.Int) max.Exp(big.NewInt(10), big.NewInt(int64(length)), nil) n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } format := fmt.Sprintf("%%0%dd", length) return fmt.Sprintf(format, n.Int64()), nil }