f
This commit is contained in:
@@ -212,10 +212,10 @@ func (s *UserApplicationServiceImpl) LoginWithSMS(ctx context.Context, cmd *comm
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendSMS 发送短信验证码
|
// SendCode 发送短信验证码
|
||||||
// 业务流程:1. 发送短信验证码
|
// 业务流程:1. 安全检查与限流 2. 发送短信验证码
|
||||||
func (s *UserApplicationServiceImpl) SendSMS(ctx context.Context, cmd *commands.SendCodeCommand) error {
|
func (s *UserApplicationServiceImpl) SendCode(ctx context.Context, cmd *commands.SendCodeCommand, clientIP, userAgent string) error {
|
||||||
return s.smsCodeService.SendCode(ctx, cmd.Phone, entities.SMSScene(cmd.Scene), "", "")
|
return s.smsCodeService.SendCode(ctx, cmd.Phone, entities.SMSScene(cmd.Scene), clientIP, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword 修改密码
|
// ChangePassword 修改密码
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func NewSMSCodeService(
|
|||||||
|
|
||||||
// SendCode 发送验证码
|
// SendCode 发送验证码
|
||||||
func (s *SMSCodeService) SendCode(ctx context.Context, phone string, scene entities.SMSScene, clientIP, userAgent string) error {
|
func (s *SMSCodeService) SendCode(ctx context.Context, phone string, scene entities.SMSScene, clientIP, userAgent string) error {
|
||||||
|
// 0. 发送前安全限流检查
|
||||||
|
if err := s.CheckRateLimit(ctx, phone, scene, clientIP, userAgent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 生成验证码
|
// 1. 生成验证码
|
||||||
code := s.smsClient.GenerateCode(s.config.CodeLength)
|
code := s.smsClient.GenerateCode(s.config.CodeLength)
|
||||||
|
|
||||||
@@ -181,36 +186,89 @@ func (s *SMSCodeService) GetCodeStatus(ctx context.Context, phone string, scene
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkRateLimit 检查发送频率限制
|
// checkRateLimit 检查发送频率限制
|
||||||
func (s *SMSCodeService) CheckRateLimit(ctx context.Context, phone string, scene entities.SMSScene) error {
|
func (s *SMSCodeService) CheckRateLimit(ctx context.Context, phone string, scene entities.SMSScene, clientIP, userAgent string) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// 检查最小发送间隔
|
// 设备标识(这里使用 User-Agent + IP 的组合做近似设备ID,可根据实际情况调整)
|
||||||
lastSentKey := fmt.Sprintf("sms:last_sent:%s:%s", scene, phone)
|
deviceID := fmt.Sprintf("ua:%s|ip:%s", userAgent, clientIP)
|
||||||
var lastSent time.Time
|
|
||||||
if err := s.cache.Get(ctx, lastSentKey, &lastSent); err == nil {
|
// 1. 检查是否已被永久封禁(按手机号和设备双维度)
|
||||||
if now.Sub(lastSent) < s.config.RateLimit.MinInterval {
|
phoneBanKey := fmt.Sprintf("sms:ban:phone:%s", phone)
|
||||||
return fmt.Errorf("请等待 %v 后再试", s.config.RateLimit.MinInterval)
|
var phoneBanned bool
|
||||||
}
|
if err := s.cache.Get(ctx, phoneBanKey, &phoneBanned); err == nil && phoneBanned {
|
||||||
|
return fmt.Errorf("该手机号短信发送已被永久限制")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查每小时发送限制
|
deviceBanKey := fmt.Sprintf("sms:ban:device:%s", deviceID)
|
||||||
hourlyKey := fmt.Sprintf("sms:hourly:%s:%s", phone, now.Format("2006010215"))
|
var deviceBanned bool
|
||||||
var hourlyCount int
|
if err := s.cache.Get(ctx, deviceBanKey, &deviceBanned); err == nil && deviceBanned {
|
||||||
if err := s.cache.Get(ctx, hourlyKey, &hourlyCount); err == nil {
|
return fmt.Errorf("该设备短信发送已被永久限制")
|
||||||
if hourlyCount >= s.config.RateLimit.HourlyLimit {
|
|
||||||
return fmt.Errorf("每小时最多发送 %d 条短信", s.config.RateLimit.HourlyLimit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查每日发送限制
|
// 2. 按手机号的时间窗口限流
|
||||||
dailyKey := fmt.Sprintf("sms:daily:%s:%s", phone, now.Format("20060102"))
|
// 10分钟窗口
|
||||||
|
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:phone:%s:10m", phone), 10*time.Minute, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 30分钟窗口
|
||||||
|
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:phone:%s:30m", phone), 30*time.Minute, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 1小时窗口
|
||||||
|
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:phone:%s:1h", phone), time.Hour, 20); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 1天窗口:超过30次则永久封禁该手机号
|
||||||
|
dailyKey := fmt.Sprintf("sms:phone:%s:1d", phone)
|
||||||
var dailyCount int
|
var dailyCount int
|
||||||
if err := s.cache.Get(ctx, dailyKey, &dailyCount); err == nil {
|
if err := s.cache.Get(ctx, dailyKey, &dailyCount); err == nil {
|
||||||
if dailyCount >= s.config.RateLimit.DailyLimit {
|
if dailyCount >= 30 {
|
||||||
return fmt.Errorf("每日最多发送 %d 条短信", s.config.RateLimit.DailyLimit)
|
// 设置手机号永久封禁标记(不过期)
|
||||||
|
s.cache.Set(ctx, phoneBanKey, true, 0)
|
||||||
|
return fmt.Errorf("该手机号短信发送次数异常,已被永久限制")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 设备维度限流与多IP检测
|
||||||
|
if deviceID != "ua:|ip:" {
|
||||||
|
// 3.1 设备多窗口限流(与手机号一致的窗口参数)
|
||||||
|
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:device:%s:10m", deviceID), 10*time.Minute, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:device:%s:30m", deviceID), 30*time.Minute, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:device:%s:1h", deviceID), time.Hour, 20); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.2 检查同一设备在短时间内是否出现在多个IP
|
||||||
|
// 使用一个短期集合Key记录该设备最近使用过的IP数量
|
||||||
|
deviceIPKey := fmt.Sprintf("sms:device:%s:ips", userAgent)
|
||||||
|
var ipCount int
|
||||||
|
if err := s.cache.Get(ctx, deviceIPKey, &ipCount); err == nil {
|
||||||
|
// 如果一个设备短时间内出现多个不同IP(>3),认为存在异常,直接永久封禁设备
|
||||||
|
if ipCount >= 3 {
|
||||||
|
s.cache.Set(ctx, deviceBanKey, true, 0)
|
||||||
|
return fmt.Errorf("该设备存在异常行为,短信发送已被永久限制")
|
||||||
|
}
|
||||||
|
s.cache.Set(ctx, deviceIPKey, ipCount+1, 30*time.Minute)
|
||||||
|
} else {
|
||||||
|
s.cache.Set(ctx, deviceIPKey, 1, 30*time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWindowLimit 通用时间窗口计数检查
|
||||||
|
func (s *SMSCodeService) checkWindowLimit(ctx context.Context, key string, ttl time.Duration, limit int) error {
|
||||||
|
var count int
|
||||||
|
if err := s.cache.Get(ctx, key, &count); err == nil {
|
||||||
|
if count >= limit {
|
||||||
|
return fmt.Errorf("短信发送过于频繁,请稍后再试")
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,21 +276,8 @@ func (s *SMSCodeService) CheckRateLimit(ctx context.Context, phone string, scene
|
|||||||
func (s *SMSCodeService) updateSendRecord(ctx context.Context, phone string, scene entities.SMSScene) {
|
func (s *SMSCodeService) updateSendRecord(ctx context.Context, phone string, scene entities.SMSScene) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// 更新最后发送时间
|
// 更新每日计数(用于后续达到上限时永久封禁)
|
||||||
lastSentKey := fmt.Sprintf("sms:last_sent:%s:%s", scene, phone)
|
dailyKey := fmt.Sprintf("sms:phone:%s:1d", phone)
|
||||||
s.cache.Set(ctx, lastSentKey, now, s.config.RateLimit.MinInterval)
|
|
||||||
|
|
||||||
// 更新每小时计数
|
|
||||||
hourlyKey := fmt.Sprintf("sms:hourly:%s:%s", phone, now.Format("2006010215"))
|
|
||||||
var hourlyCount int
|
|
||||||
if err := s.cache.Get(ctx, hourlyKey, &hourlyCount); err == nil {
|
|
||||||
s.cache.Set(ctx, hourlyKey, hourlyCount+1, time.Hour)
|
|
||||||
} else {
|
|
||||||
s.cache.Set(ctx, hourlyKey, 1, time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新每日计数
|
|
||||||
dailyKey := fmt.Sprintf("sms:daily:%s:%s", phone, now.Format("20060102"))
|
|
||||||
var dailyCount int
|
var dailyCount int
|
||||||
if err := s.cache.Get(ctx, dailyKey, &dailyCount); err == nil {
|
if err := s.cache.Get(ctx, dailyKey, &dailyCount); err == nil {
|
||||||
s.cache.Set(ctx, dailyKey, dailyCount+1, 24*time.Hour)
|
s.cache.Set(ctx, dailyKey, dailyCount+1, 24*time.Hour)
|
||||||
|
|||||||
Reference in New Issue
Block a user