diff --git a/app/main/api/internal/handler/auth/sendsmshandler.go b/app/main/api/internal/handler/auth/sendsmshandler.go index ccd5aa3..827674b 100644 --- a/app/main/api/internal/handler/auth/sendsmshandler.go +++ b/app/main/api/internal/handler/auth/sendsmshandler.go @@ -23,8 +23,25 @@ func SendSmsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { result.ParamValidateErrorResult(r, w, err) return } + // 获取客户端真实 IP + clientIP := getClientIP(r) l := auth.NewSendSmsLogic(r.Context(), svcCtx) - err := l.SendSms(&req) + err := l.SendSms(&req, clientIP) result.HttpResult(r, w, nil, err) } } + +// getClientIP 获取客户端真实 IP +func getClientIP(r *http.Request) string { + // 优先从 X-Forwarded-For 获取 + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // X-Forwarded-For 可能包含多个 IP,取第一个 + return xff + } + // 其次从 X-Real-IP 获取 + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + // 最后使用 RemoteAddr + return r.RemoteAddr +} diff --git a/app/main/api/internal/logic/auth/sendsmslogic.go b/app/main/api/internal/logic/auth/sendsmslogic.go index 5db40dc..3941b0f 100644 --- a/app/main/api/internal/logic/auth/sendsmslogic.go +++ b/app/main/api/internal/logic/auth/sendsmslogic.go @@ -2,11 +2,11 @@ package auth import ( "context" + "fmt" + "math/rand" "qnc-server/common/xerr" "qnc-server/pkg/captcha" "qnc-server/pkg/lzkit/crypto" - "fmt" - "math/rand" "time" "github.com/pkg/errors" @@ -35,24 +35,58 @@ func NewSendSmsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendSmsLo } } -func (l *SendSmsLogic) SendSms(req *types.SendSmsReq) error { - // 1. 图形验证码校验 - cfg := l.svcCtx.Config.Captcha - if err := captcha.Verify(captcha.Config{ - AccessKeyID: cfg.AccessKeyID, - AccessKeySecret: cfg.AccessKeySecret, - EndpointURL: cfg.EndpointURL, - SceneID: cfg.SceneID, - }, req.CaptchaVerifyParam); err != nil { - return err - } - +func (l *SendSmsLogic) SendSms(req *types.SendSmsReq, clientIP string) error { secretKey := l.svcCtx.Config.Encrypt.SecretKey encryptedMobile, err := crypto.EncryptMobile(req.Mobile, secretKey) if err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "短信发送, 加密手机号失败: %v", err) } - // 检查手机号是否在一分钟内已发送过验证码 + + // 1. 滑块验证码校验(可选) + cfg := l.svcCtx.Config.Captcha + captchaResult := captcha.VerifyOptional(captcha.Config{ + AccessKeyID: cfg.AccessKeyID, + AccessKeySecret: cfg.AccessKeySecret, + EndpointURL: cfg.EndpointURL, + SceneID: cfg.SceneID, + }, req.CaptchaVerifyParam) + + if captchaResult.VerifyErr != nil { + return captchaResult.VerifyErr + } + + // 2. 防刷策略 + if captchaResult.Skipped { + // 没有滑块验证码,使用更严格的限流策略 + // 2.1 IP 限流:同一 IP 每小时最多发送 10 次 + ipLimitKey := fmt.Sprintf("ip_limit:%s", clientIP) + ipCount, err := l.svcCtx.Redis.Incr(ipLimitKey) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "短信发送, 读取IP限流缓存失败: %v", err) + } + if ipCount == 1 { + // 第一次访问,设置 1 小时过期 + l.svcCtx.Redis.Expire(ipLimitKey, 3600) + } + if ipCount > 10 { + return errors.Wrapf(xerr.NewErrMsg("请求过于频繁,请稍后再试"), "短信发送, IP限流: %s, count: %d", clientIP, ipCount) + } + + // 2.2 手机号限流:同一手机号每小时最多发送 5 次(无滑块时更严格) + hourLimitKey := fmt.Sprintf("hour_limit:%s:%s", req.ActionType, encryptedMobile) + hourCount, err := l.svcCtx.Redis.Incr(hourLimitKey) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "短信发送, 读取小时限流缓存失败: %v", err) + } + if hourCount == 1 { + l.svcCtx.Redis.Expire(hourLimitKey, 3600) + } + if hourCount > 5 { + return errors.Wrapf(xerr.NewErrMsg("该手机号请求过于频繁,请稍后再试"), "短信发送, 手机号小时限流: %s, count: %d", encryptedMobile, hourCount) + } + } + + // 3. 检查手机号是否在一分钟内已发送过验证码(通用) limitCodeKey := fmt.Sprintf("limit:%s:%s", req.ActionType, encryptedMobile) exists, err := l.svcCtx.Redis.Exists(limitCodeKey) if err != nil { @@ -60,7 +94,6 @@ func (l *SendSmsLogic) SendSms(req *types.SendSmsReq) error { } if exists { - // 如果 Redis 中已经存在标记,说明在 1 分钟内请求过,返回错误 return errors.Wrapf(xerr.NewErrMsg("一分钟内不能重复发送验证码"), "短信发送, 手机号1分钟内重复请求发送验证码: %s", encryptedMobile) } diff --git a/pkg/captcha/aliyun.go b/pkg/captcha/aliyun.go index e14f7c2..92d8e5e 100644 --- a/pkg/captcha/aliyun.go +++ b/pkg/captcha/aliyun.go @@ -19,15 +19,24 @@ type Config struct { SceneID string } -// Verify 验证阿里云验证码 -func Verify(cfg Config, captchaVerifyParam string) error { +// VerifyResult 验证结果 +type VerifyResult struct { + Verified bool // 是否通过滑块验证 + Skipped bool // 是否跳过验证(无滑块参数) + VerifyErr error // 验证错误(如果有) +} + +// VerifyOptional 可选验证阿里云验证码 +// 当 captchaVerifyParam 为空时返回 Skipped=true,由调用方决定后续处理 +func VerifyOptional(cfg Config, captchaVerifyParam string) VerifyResult { // 开发环境可跳过验证 if os.Getenv("ENV") == "development" { - return nil + return VerifyResult{Verified: true, Skipped: false} } + // 没有滑块验证码参数,返回跳过状态 if captchaVerifyParam == "" { - return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "empty captchaVerifyParam") + return VerifyResult{Skipped: true} } clientCfg := &openapi.Config{ @@ -37,7 +46,7 @@ func Verify(cfg Config, captchaVerifyParam string) error { clientCfg.Endpoint = tea.String(cfg.EndpointURL) client, err := captcha20230305.NewClient(clientCfg) if err != nil { - return errors.Wrapf(err, "create aliyun captcha client error") + return VerifyResult{VerifyErr: errors.Wrapf(err, "create aliyun captcha client error")} } req := &captcha20230305.VerifyIntelligentCaptchaRequest{ @@ -47,13 +56,27 @@ func Verify(cfg Config, captchaVerifyParam string) error { resp, err := client.VerifyIntelligentCaptcha(req) if err != nil { - return errors.Wrapf(err, "verify aliyun captcha error") + return VerifyResult{VerifyErr: errors.Wrapf(err, "verify aliyun captcha error")} } if tea.BoolValue(resp.Body.Result.VerifyResult) { - return nil + return VerifyResult{Verified: true} } - return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "aliyun captcha verify failed: code=%s, msg=%s", - tea.StringValue(resp.Body.Code), tea.StringValue(resp.Body.Message)) + return VerifyResult{ + VerifyErr: errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "aliyun captcha verify failed: code=%s, msg=%s", + tea.StringValue(resp.Body.Code), tea.StringValue(resp.Body.Message)), + } +} + +// Verify 验证阿里云验证码(必须提供验证码参数) +func Verify(cfg Config, captchaVerifyParam string) error { + result := VerifyOptional(cfg, captchaVerifyParam) + if result.VerifyErr != nil { + return result.VerifyErr + } + if result.Skipped { + return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "empty captchaVerifyParam") + } + return nil }