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
|
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: ''
|
||||||
|
|
|
||||||
|
|
@ -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.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);
|
||||||
|
|
||||||
// 存入Redis,5分钟有效
|
// 存入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,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)) {
|
String code = RandomUtil.randomNumbers(smsProperties.getCodeLength());
|
||||||
Long ttl = RedisUtils.getTimeToLive(codeKey);
|
|
||||||
if (ttl != null && ttl > 240) {
|
|
||||||
throw new ServiceException("验证码发送过于频繁,请稍后再试");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存入Redis,5分钟有效
|
// 存入Redis
|
||||||
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(5));
|
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
|
||||||
|
|
||||||
// 调用阿里云短信发送
|
// 更新计数器
|
||||||
try {
|
incrementCounter(dailyPhoneKey, Duration.ofDays(1));
|
||||||
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
incrementCounter(minuteIpKey, Duration.ofMinutes(1));
|
||||||
map.put("code", code);
|
incrementCounter(dailyIpKey, Duration.ofDays(1));
|
||||||
// 使用配置的阿里云短信
|
|
||||||
SmsBlend smsBlend = SmsFactory.getSmsBlend("alibaba");
|
// 发送短信
|
||||||
// 模板ID需要在配置文件中设置
|
if (smsProperties.isEnabled()) {
|
||||||
SmsResponse smsResponse = smsBlend.sendMessage(phone, map);
|
try {
|
||||||
if (!smsResponse.isSuccess()) {
|
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
||||||
log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
|
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("短信发送失败,请稍后重试");
|
throw new ServiceException("短信发送失败,请稍后重试");
|
||||||
}
|
}
|
||||||
log.info("短信发送成功: phone={}, type={}", phone, type);
|
} else {
|
||||||
} catch (ServiceException e) {
|
// 测试模式,打印验证码到日志
|
||||||
throw e;
|
log.info("【测试模式】短信验证码: phone={}, code={}, type={}", phone, code, type);
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
|
|
||||||
throw new ServiceException("短信发送失败,请稍后重试");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue