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:
parent
905e263ca8
commit
5fe9d1117f
|
|
@ -264,3 +264,31 @@ warm-flow:
|
|||
node-tooltip: true
|
||||
# 默认Authorization,如果有多个token,用逗号分隔
|
||||
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: ''
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}
|
||||
|
|
@ -12,9 +12,11 @@ import lombok.extern.slf4j.Slf4j;
|
|||
import org.dromara.common.core.constant.Constants;
|
||||
import org.dromara.common.core.constant.GlobalConstants;
|
||||
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.redis.utils.RedisUtils;
|
||||
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.H5RegisterDto;
|
||||
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
|
||||
|
|
@ -34,6 +36,8 @@ import org.springframework.stereotype.Service;
|
|||
|
||||
import java.awt.*;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
|
|
@ -48,6 +52,7 @@ import java.util.LinkedHashMap;
|
|||
public class H5AuthServiceImpl implements H5AuthService {
|
||||
|
||||
private final PgMemberMapper memberMapper;
|
||||
private final H5SmsProperties smsProperties;
|
||||
|
||||
/**
|
||||
* H5会员登录设备标识
|
||||
|
|
@ -55,13 +60,14 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
private static final String H5_DEVICE = "h5";
|
||||
|
||||
/**
|
||||
* 短信验证码Redis前缀
|
||||
* Redis Key 前缀
|
||||
*/
|
||||
private static final String H5_SMS_CODE_KEY = "h5:sms:code:";
|
||||
|
||||
/**
|
||||
* RefreshToken Redis前缀
|
||||
*/
|
||||
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:";
|
||||
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:";
|
||||
|
||||
/**
|
||||
|
|
@ -97,7 +103,7 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
Expression exp = parser.parseExpression(StringUtils.remove(code, "="));
|
||||
code = exp.getValue(String.class);
|
||||
|
||||
// 存入Redis,5分钟有效
|
||||
// 存入Redis
|
||||
RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
|
||||
|
||||
H5CaptchaVo vo = new H5CaptchaVo();
|
||||
|
|
@ -113,12 +119,58 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
|
||||
String phone = dto.getPhone();
|
||||
String type = dto.getType();
|
||||
String clientIp = getClientIp();
|
||||
|
||||
// 校验类型
|
||||
if (!"login".equals(type) && !"register".equals(type)) {
|
||||
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)) {
|
||||
PgMember existMember = memberMapper.selectOne(
|
||||
|
|
@ -139,39 +191,46 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
}
|
||||
}
|
||||
|
||||
// 生成6位验证码
|
||||
String code = RandomUtil.randomNumbers(6);
|
||||
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
|
||||
// ========== 发送短信 ==========
|
||||
|
||||
// 检查是否在60秒内重复发送
|
||||
if (RedisUtils.hasKey(codeKey)) {
|
||||
Long ttl = RedisUtils.getTimeToLive(codeKey);
|
||||
if (ttl != null && ttl > 240) {
|
||||
throw new ServiceException("验证码发送过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
// 生成验证码
|
||||
String code = RandomUtil.randomNumbers(smsProperties.getCodeLength());
|
||||
|
||||
// 存入Redis,5分钟有效
|
||||
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(5));
|
||||
// 存入Redis
|
||||
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
|
||||
|
||||
// 调用阿里云短信发送
|
||||
try {
|
||||
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
||||
map.put("code", code);
|
||||
// 使用配置的阿里云短信
|
||||
SmsBlend smsBlend = SmsFactory.getSmsBlend("alibaba");
|
||||
// 模板ID需要在配置文件中设置
|
||||
SmsResponse smsResponse = smsBlend.sendMessage(phone, map);
|
||||
if (!smsResponse.isSuccess()) {
|
||||
log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
|
||||
// 更新计数器
|
||||
incrementCounter(dailyPhoneKey, Duration.ofDays(1));
|
||||
incrementCounter(minuteIpKey, Duration.ofMinutes(1));
|
||||
incrementCounter(dailyIpKey, Duration.ofDays(1));
|
||||
|
||||
// 发送短信
|
||||
if (smsProperties.isEnabled()) {
|
||||
try {
|
||||
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
||||
map.put("code", code);
|
||||
SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
|
||||
String templateId = "login".equals(type) ? smsProperties.getLoginTemplateId() : smsProperties.getRegisterTemplateId();
|
||||
SmsResponse smsResponse;
|
||||
if (StringUtils.isNotBlank(templateId)) {
|
||||
smsResponse = smsBlend.sendMessage(phone, templateId, map);
|
||||
} else {
|
||||
smsResponse = smsBlend.sendMessage(phone, map);
|
||||
}
|
||||
if (!smsResponse.isSuccess()) {
|
||||
log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
|
||||
throw new ServiceException("短信发送失败,请稍后重试");
|
||||
}
|
||||
log.info("短信发送成功: phone={}, type={}, ip={}", phone, type, clientIp);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
|
||||
throw new ServiceException("短信发送失败,请稍后重试");
|
||||
}
|
||||
log.info("短信发送成功: phone={}, type={}", phone, type);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
|
||||
throw new ServiceException("短信发送失败,请稍后重试");
|
||||
} else {
|
||||
// 测试模式,打印验证码到日志
|
||||
log.info("【测试模式】短信验证码: phone={}, code={}, type={}", phone, code, type);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,15 +416,86 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
*/
|
||||
private void validateSmsCode(String phone, String code, String type) {
|
||||
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
|
||||
String failKey = H5_SMS_FAIL_COUNT_KEY + phone;
|
||||
String smsCode = RedisUtils.getCacheObject(codeKey);
|
||||
|
||||
if (smsCode == null) {
|
||||
throw new ServiceException("短信验证码已过期");
|
||||
}
|
||||
|
||||
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("短信验证码错误");
|
||||
}
|
||||
|
||||
// 验证成功后删除
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue