feat: 增强H5短信接口防刷机制

新增配置类 H5SmsProperties,支持以下配置项:
- enabled: 是否启用短信发送(测试模式)
- code-length: 验证码长度
- code-expire-minutes: 验证码有效期
- send-interval-seconds: 同手机号发送间隔
- daily-limit-per-phone: 手机号每日上限
- minute-limit-per-ip: IP每分钟上限
- daily-limit-per-ip: IP每日上限
- blacklist-minutes: 黑名单封禁时长
- blacklist-trigger-count: 触发黑名单的失败次数
- sms-config-name: 短信配置名称
- login/register-template-id: 短信模板ID

防刷策略:
1. 图形验证码校验
2. 手机号黑名单检查
3. IP黑名单检查
4. 发送间隔限制
5. 手机号每日上限
6. IP每分钟上限
7. IP每日上限
8. 验证码连续失败自动封禁
This commit is contained in:
神码-方晓辉 2026-02-02 21:43:38 +08:00
parent 905e263ca8
commit 5fe9d1117f
3 changed files with 268 additions and 34 deletions

View File

@ -264,3 +264,31 @@ warm-flow:
node-tooltip: true node-tooltip: true
# 默认Authorization如果有多个token用逗号分隔 # 默认Authorization如果有多个token用逗号分隔
token-name: ${sa-token.token-name},clientid token-name: ${sa-token.token-name},clientid
--- # H5短信防刷配置
h5:
sms:
# 是否启用短信发送false时仅打印日志用于测试
enabled: false
# 验证码长度
code-length: 6
# 验证码有效期(分钟)
code-expire-minutes: 5
# 同一手机号发送间隔(秒)
send-interval-seconds: 60
# 同一手机号每日发送上限
daily-limit-per-phone: 10
# 同一IP每分钟发送上限
minute-limit-per-ip: 5
# 同一IP每日发送上限
daily-limit-per-ip: 50
# 黑名单封禁时长(分钟)
blacklist-minutes: 30
# 触发黑名单的验证失败次数
blacklist-trigger-count: 5
# 阿里云短信配置名称对应sms.blends下的配置
sms-config-name: alibaba
# 短信模板ID - 登录
login-template-id: ''
# 短信模板ID - 注册
register-template-id: ''

View File

@ -0,0 +1,76 @@
package org.dromara.pangu.h5.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* H5短信配置属性
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Component
@ConfigurationProperties(prefix = "h5.sms")
public class H5SmsProperties {
/**
* 是否启用短信发送测试时可关闭
*/
private boolean enabled = true;
/**
* 验证码长度
*/
private int codeLength = 6;
/**
* 验证码有效期分钟
*/
private int codeExpireMinutes = 5;
/**
* 同一手机号发送间隔
*/
private int sendIntervalSeconds = 60;
/**
* 同一手机号每日发送上限
*/
private int dailyLimitPerPhone = 10;
/**
* 同一IP每分钟发送上限
*/
private int minuteLimitPerIp = 5;
/**
* 同一IP每日发送上限
*/
private int dailyLimitPerIp = 50;
/**
* 黑名单封禁时长分钟
*/
private int blacklistMinutes = 30;
/**
* 触发黑名单的失败次数连续验证失败
*/
private int blacklistTriggerCount = 5;
/**
* 阿里云短信配置名称
*/
private String smsConfigName = "alibaba";
/**
* 短信模板ID登录
*/
private String loginTemplateId = "";
/**
* 短信模板ID注册
*/
private String registerTemplateId = "";
}

View File

@ -12,9 +12,11 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants; import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.core.WaveAndCircleCaptcha; import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.pangu.h5.config.H5SmsProperties;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto; import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto; import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto; import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
@ -34,6 +36,8 @@ import org.springframework.stereotype.Service;
import java.awt.*; import java.awt.*;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -48,6 +52,7 @@ import java.util.LinkedHashMap;
public class H5AuthServiceImpl implements H5AuthService { public class H5AuthServiceImpl implements H5AuthService {
private final PgMemberMapper memberMapper; private final PgMemberMapper memberMapper;
private final H5SmsProperties smsProperties;
/** /**
* H5会员登录设备标识 * H5会员登录设备标识
@ -55,13 +60,14 @@ public class H5AuthServiceImpl implements H5AuthService {
private static final String H5_DEVICE = "h5"; private static final String H5_DEVICE = "h5";
/** /**
* 短信验证码Redis前缀 * Redis Key 前缀
*/ */
private static final String H5_SMS_CODE_KEY = "h5:sms:code:"; private static final String H5_SMS_CODE_KEY = "h5:sms:code:";
private static final String H5_SMS_DAILY_PHONE_KEY = "h5:sms:daily:phone:";
/** private static final String H5_SMS_DAILY_IP_KEY = "h5:sms:daily:ip:";
* RefreshToken Redis前缀 private static final String H5_SMS_MINUTE_IP_KEY = "h5:sms:minute:ip:";
*/ private static final String H5_SMS_BLACKLIST_KEY = "h5:sms:blacklist:";
private static final String H5_SMS_FAIL_COUNT_KEY = "h5:sms:fail:";
private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:"; private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:";
/** /**
@ -97,7 +103,7 @@ public class H5AuthServiceImpl implements H5AuthService {
Expression exp = parser.parseExpression(StringUtils.remove(code, "=")); Expression exp = parser.parseExpression(StringUtils.remove(code, "="));
code = exp.getValue(String.class); code = exp.getValue(String.class);
// 存入Redis5分钟有效 // 存入Redis
RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
H5CaptchaVo vo = new H5CaptchaVo(); H5CaptchaVo vo = new H5CaptchaVo();
@ -113,12 +119,58 @@ public class H5AuthServiceImpl implements H5AuthService {
String phone = dto.getPhone(); String phone = dto.getPhone();
String type = dto.getType(); String type = dto.getType();
String clientIp = getClientIp();
// 校验类型 // 校验类型
if (!"login".equals(type) && !"register".equals(type)) { if (!"login".equals(type) && !"register".equals(type)) {
throw new ServiceException("验证码类型错误"); throw new ServiceException("验证码类型错误");
} }
// ========== 防刷检查 ==========
// 1. 检查手机号黑名单
if (isPhoneBlacklisted(phone)) {
throw new ServiceException("操作过于频繁,请" + smsProperties.getBlacklistMinutes() + "分钟后再试");
}
// 2. 检查IP黑名单
if (isIpBlacklisted(clientIp)) {
throw new ServiceException("操作过于频繁,请稍后再试");
}
// 3. 检查发送间隔
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
if (RedisUtils.hasKey(codeKey)) {
Long ttl = RedisUtils.getTimeToLive(codeKey);
long intervalCheck = (smsProperties.getCodeExpireMinutes() * 60L) - smsProperties.getSendIntervalSeconds();
if (ttl != null && ttl > intervalCheck) {
throw new ServiceException("验证码发送过于频繁,请" + smsProperties.getSendIntervalSeconds() + "秒后再试");
}
}
// 4. 检查手机号每日上限
String dailyPhoneKey = H5_SMS_DAILY_PHONE_KEY + getTodayStr() + ":" + phone;
Integer phoneCount = RedisUtils.getCacheObject(dailyPhoneKey);
if (phoneCount != null && phoneCount >= smsProperties.getDailyLimitPerPhone()) {
throw new ServiceException("今日发送次数已达上限,请明天再试");
}
// 5. 检查IP每分钟上限
String minuteIpKey = H5_SMS_MINUTE_IP_KEY + clientIp;
Integer minuteIpCount = RedisUtils.getCacheObject(minuteIpKey);
if (minuteIpCount != null && minuteIpCount >= smsProperties.getMinuteLimitPerIp()) {
throw new ServiceException("请求过于频繁,请稍后再试");
}
// 6. 检查IP每日上限
String dailyIpKey = H5_SMS_DAILY_IP_KEY + getTodayStr() + ":" + clientIp;
Integer dailyIpCount = RedisUtils.getCacheObject(dailyIpKey);
if (dailyIpCount != null && dailyIpCount >= smsProperties.getDailyLimitPerIp()) {
throw new ServiceException("今日请求次数已达上限");
}
// ========== 业务校验 ==========
// 注册时校验手机号是否已注册 // 注册时校验手机号是否已注册
if ("register".equals(type)) { if ("register".equals(type)) {
PgMember existMember = memberMapper.selectOne( PgMember existMember = memberMapper.selectOne(
@ -139,40 +191,47 @@ public class H5AuthServiceImpl implements H5AuthService {
} }
} }
// 生成6位验证码 // ========== 发送短信 ==========
String code = RandomUtil.randomNumbers(6);
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
// 检查是否在60秒内重复发送 // 生成验证码
if (RedisUtils.hasKey(codeKey)) { String code = RandomUtil.randomNumbers(smsProperties.getCodeLength());
Long ttl = RedisUtils.getTimeToLive(codeKey);
if (ttl != null && ttl > 240) {
throw new ServiceException("验证码发送过于频繁,请稍后再试");
}
}
// 存入Redis5分钟有效 // 存入Redis
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(5)); RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
// 调用阿里云短信发送 // 更新计数器
incrementCounter(dailyPhoneKey, Duration.ofDays(1));
incrementCounter(minuteIpKey, Duration.ofMinutes(1));
incrementCounter(dailyIpKey, Duration.ofDays(1));
// 发送短信
if (smsProperties.isEnabled()) {
try { try {
LinkedHashMap<String, String> map = new LinkedHashMap<>(1); LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code); map.put("code", code);
// 使用配置的阿里云短信 SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
SmsBlend smsBlend = SmsFactory.getSmsBlend("alibaba"); String templateId = "login".equals(type) ? smsProperties.getLoginTemplateId() : smsProperties.getRegisterTemplateId();
// 模板ID需要在配置文件中设置 SmsResponse smsResponse;
SmsResponse smsResponse = smsBlend.sendMessage(phone, map); if (StringUtils.isNotBlank(templateId)) {
smsResponse = smsBlend.sendMessage(phone, templateId, map);
} else {
smsResponse = smsBlend.sendMessage(phone, map);
}
if (!smsResponse.isSuccess()) { if (!smsResponse.isSuccess()) {
log.error("短信发送失败: phone={}, response={}", phone, smsResponse); log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
throw new ServiceException("短信发送失败,请稍后重试"); throw new ServiceException("短信发送失败,请稍后重试");
} }
log.info("短信发送成功: phone={}, type={}", phone, type); log.info("短信发送成功: phone={}, type={}, ip={}", phone, type, clientIp);
} catch (ServiceException e) { } catch (ServiceException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage()); log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
throw new ServiceException("短信发送失败,请稍后重试"); throw new ServiceException("短信发送失败,请稍后重试");
} }
} else {
// 测试模式打印验证码到日志
log.info("【测试模式】短信验证码: phone={}, code={}, type={}", phone, code, type);
}
} }
@Override @Override
@ -357,15 +416,86 @@ public class H5AuthServiceImpl implements H5AuthService {
*/ */
private void validateSmsCode(String phone, String code, String type) { private void validateSmsCode(String phone, String code, String type) {
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone; String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
String failKey = H5_SMS_FAIL_COUNT_KEY + phone;
String smsCode = RedisUtils.getCacheObject(codeKey); String smsCode = RedisUtils.getCacheObject(codeKey);
if (smsCode == null) { if (smsCode == null) {
throw new ServiceException("短信验证码已过期"); throw new ServiceException("短信验证码已过期");
} }
if (!smsCode.equals(code)) { if (!smsCode.equals(code)) {
// 记录失败次数
Integer failCount = incrementCounter(failKey, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
if (failCount >= smsProperties.getBlacklistTriggerCount()) {
// 加入黑名单
addToBlacklist(phone);
RedisUtils.deleteObject(failKey);
throw new ServiceException("验证码错误次数过多,请" + smsProperties.getBlacklistMinutes() + "分钟后再试");
}
throw new ServiceException("短信验证码错误"); throw new ServiceException("短信验证码错误");
} }
// 验证成功后删除 // 验证成功后删除
RedisUtils.deleteObject(codeKey); RedisUtils.deleteObject(codeKey);
RedisUtils.deleteObject(failKey);
}
/**
* 检查手机号是否在黑名单
*/
private boolean isPhoneBlacklisted(String phone) {
return RedisUtils.hasKey(H5_SMS_BLACKLIST_KEY + "phone:" + phone);
}
/**
* 检查IP是否在黑名单
*/
private boolean isIpBlacklisted(String ip) {
return RedisUtils.hasKey(H5_SMS_BLACKLIST_KEY + "ip:" + ip);
}
/**
* 将手机号加入黑名单
*/
private void addToBlacklist(String phone) {
RedisUtils.setCacheObject(
H5_SMS_BLACKLIST_KEY + "phone:" + phone,
"1",
Duration.ofMinutes(smsProperties.getBlacklistMinutes())
);
log.warn("手机号加入黑名单: phone={}, minutes={}", phone, smsProperties.getBlacklistMinutes());
}
/**
* 增加计数器
*/
private Integer incrementCounter(String key, Duration duration) {
Integer count = RedisUtils.getCacheObject(key);
if (count == null) {
count = 1;
} else {
count++;
}
RedisUtils.setCacheObject(key, count, duration);
return count;
}
/**
* 获取今日日期字符串
*/
private String getTodayStr() {
return LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
}
/**
* 获取客户端IP
*/
private String getClientIp() {
try {
return ServletUtils.getClientIP();
} catch (Exception e) {
return "unknown";
}
} }
/** /**