讨论内容部分为当初的一些短信验证码的需求细节讨论
这个短信验证码在并发量非常大的情况下有可能会失效,后续会进行整改升级,保证线程安全
<!-- more -->
短信验证码(要想着怎么把所有的项目都整改起来,不影响原有业务运行) 3天时间,全部替换掉
google kaptcha 这个可以试试SpringBoot之配置google kaptcha
SpringMvc项目中使用GoogleKaptcha 生成验证码
happy-captcha
也可以先把功能做出来,再让前端根据实际情况去调整样式
20200903 已实现
使用xml 配置手机+业务模块
直接在短信接口加入即可
PS: 目前的攻击手段可以用虚拟手机号码 + 肉鸡服务器 实现,手机号+业务的限制作用个人理解来看作用不明显
解决方案:
// 开启之后,才做进一步校验
if(PHONE_MODULE_CHECK_ENABLE){
// 添加 【手机+业务模块】校验 以及 【60秒重复调用校验】
boolean checkRequest = CheckSendMailHelper.checkContextMap(result, request, phone);
// 校验不通过的处理办法,可以自定
if (!checkRequest) {
return result;
}
}
//限制用户ip访问短信机获取验证码次数,默认10次
if (IP_CHECK_ENABLE && !CheckIpVisitHelper.check(request)) {
// 校验不通过的处理办法,可以自定
result.put("result", SmsRequestStatusEnum.RESULT_STATUS_6.getCode());
result.put("msg", SmsRequestStatusEnum.RESULT_STATUS_6.getName());
return result;
}PHONE_MODULE_CHECK_ENABLE:短信-业务模块校验开关 IP_CHECK_ENABLE:限制短信每天的获取次数,也是手机号+业务模块
public enum SmsRequestStatusEnum {
/**
* 返回状态码 表示发送正常
*/
RESULT_STATUS_1(1, "返回状态码 表示发送正常"),
/**
* 60s内只能获取不能重复获取验证码
*/
RESULT_STATUS_2(2, "60s内只能获取不能重复获取验证码"),
/**
* 手机号码长度不正确
*/
RESULT_STATUS_3(3, "手机号码长度不正确"),
// /**
// * 用户session已失效
// */
// RESULT_STATUS_4(4, "用户session已失效"),
/**
* 缺少必要的参数:手机号!
*/
RESULT_STATUS_4(4, "缺少必要的参数:手机号!"),
/**
* 手机号码长度不正确
*/
RESULT_STATUS_5(5, "手机号码长度不正确"),
/**
* 同一个ip请求短信机次数过于频繁
*/
RESULT_STATUS_6(6, "同一个ip请求短信机次数过于频繁!"),
/**
* 60秒内不允许重复请求短信接口
*/
RESULT_STATUS_7(7, "60秒内不允许重复请求短信接口!"),
/**
* 缺少必要的请求参数:短信业务模块名称:phoneModule !
*/
RESULT_STATUS_9(9, "缺少必要的请求参数:短信业务模块名称:phoneModule !"),
/**
* 当前手机号请求超出限制,请等待24小时之后重新请求短信接口
*/
RESULT_STATUS_10(10, "当前手机号请求次数超出限制,请等待24小时之后重新请求短信接口"),
/**
* 图形验证码已失效,请重新请求短信接口!
*/
RESULT_STATUS_8(8, "图形验证码已失效,请重新请求短信接口!");
private int code;
private String name;
SmsRequestStatusEnum(int code, String name) {
this.code = code;
this.name = name;
}
public int getCode() {
return code;
}
public String getName() {
return name;
}
public static String getName(int code) {
for (SmsRequestStatusEnum item : SmsRequestStatusEnum.values()) {
if (item.getCode() == code) {
return item.getName();
}
}
return "";
}
} /**
* 检测ip访问辅助类,
* 主要处理某个时间段类,
* ip访问次数,以及设置封禁时间、
* 解封等操作,
* 用于防止频繁调用短信机攻击等
* <p>
* <p>
* 重写原理:
* 1. 使用LRUMap key 存储 IP号码,value 存储 访问次数以及时间(使用map)
* 2. 使用servletContext 存储 LRUMap,LRUMap 存储 的 key 为 IP号码-业务模块 VALUE 为 map
* 3. LRUMap 对应的 key IP号码+业务。value 绑定了访问次数和时间
* 4. 如果没有配置模块,校验将会永久失败,IP的模块和短信的模块使用同一块配置
* 5. ServletContext 生命周期和web的生命周期相同
*
* 2020/09/08 重写工具类,
* 1. 不在暴露 map。
* 2. 使用servletContext 保存 Ip 的 map。Map<String,Object> 形式
* 3. 如果超过IP限制时间,自动进行解锁
*
* @author xd
*/
public class CheckIpVisitHelper {
/**
* 日志使用 短信的key
*/
private static final Logger logger = LoggerFactory.getLogger("phoneCode");
/**
* 手机访问限制初始化的值
*/
private static final int PHONE_REQUEST_INIT_SIZE = 1;
/**
* 封禁的时间(单位毫秒)
*/
private static final int FORBIDEN_TIME = 60 * 1000 * 60;
/**
* 超过访问时间重新计时(单位毫秒)
*/
private static final int MININTEVAL = 60 * 1000 * 60;
/**
* LRU Map 初始化大小
*/
private static final int LRU_MAP_INIT_SIZE = 100000;
/**
* IP 在指定时间内的限制次数
*/
private static final int IP_MAX_VISIST_TIME = Setter.getInt("sms.ip-size");
/**
* ip检测使用的 Map key
*/
private static final String IP_CHECK_MAP = "IP_CHECK_MAP";
/**
* 请求次数
*/
private static final String VISIT_COUNT_KEY = "visit_count";
/**
* 最后的请求时间
*/
private static final String VISIT_TIME_KEY = "visit_time";
/**
* IP号码-业务模块名称的格式
*/
private static final String IP_MOUDULE_FORMAT = "%s-%s";
/**
* ip检查工具,将map 放入 ServletContext
* 1. 检测基于 ServletContext
* 2. 请附带 phoneModule: 否则校验永远为false
* map 当中:
* key: IP号码-业务
* value:
* map -> {
* key: 请求次数:value: int
* key:请求的时间:value:date
* }
*
* @param request request请求域
* @return 如果校验没有超过限制 返回 true ,否则返回false
*/
public static boolean check(HttpServletRequest request) {
String remoteIp = RequestHelper.getRemoteIp(request);
ServletContext servletContext = request.getServletContext();
LRUMap attribute = (LRUMap) servletContext.getAttribute(IP_CHECK_MAP);
if (Objects.isNull(attribute)) {
attribute = new LRUMap(LRU_MAP_INIT_SIZE);
servletContext.setAttribute(IP_CHECK_MAP, attribute);
}
Date now = new Date();
// 根据 IP + 业务模块进行绑定
// 获取请求的模块名称 同时检查是否有配置模块
String phoneMouduleFlag = CheckSendMailHelper.checkExistsAndGetModule(request);
if (phoneMouduleFlag == null) {
return false;
}
// IP号码 -业务名称
String modulePhone = String.format(IP_MOUDULE_FORMAT, remoteIp, phoneMouduleFlag);
// 获取ip对应的的当前请求次数和请求时间
Map<String, Object> ipMap = (Map<String, Object>) attribute.get(modulePhone);
// 如果当前ip没有访问过
if (MapUtils.isEmpty(ipMap)) {
ipMap = new HashMap<>();
ipMap.put(VISIT_COUNT_KEY, PHONE_REQUEST_INIT_SIZE);
ipMap.put(VISIT_TIME_KEY, now);
attribute.putIfAbsent(modulePhone, ipMap);
return true;
}
int visitCount = (int) ipMap.get(VISIT_COUNT_KEY);
Date visitDate = (Date) ipMap.get(VISIT_TIME_KEY);
// 如果长时间没有访问,重新计算
if (now.getTime() - visitDate.getTime() > MININTEVAL) {
ipMap.put(VISIT_COUNT_KEY, PHONE_REQUEST_INIT_SIZE);
ipMap.put(VISIT_TIME_KEY, now);
return true;
}
// 如果访问的次数超过了限制的次数
if (visitCount > IP_MAX_VISIST_TIME) {
// 如果已经到达限制的次数,但是访问时间超过了限制的时间,重新计时,重新计算请求次数
if (now.getTime() - visitDate.getTime() > FORBIDEN_TIME) {
ipMap.put(VISIT_COUNT_KEY, PHONE_REQUEST_INIT_SIZE);
ipMap.put(VISIT_TIME_KEY, now);
return true;
}
logger.info("当前IP: {} 请求次数超过限制", remoteIp);
return false;
} else {
// IP访问次数 + 1
visitCount++;
// 更新访问次数
ipMap.put(VISIT_COUNT_KEY, visitCount);
// 更新访问时间
ipMap.put(VISIT_TIME_KEY, now);
}
return true;
}
} /**
* 短信发送校验工具类
* map 存储的 key 为手机号码-业务
* value 为 发送对象等其他信息
* 包含
* 1. 图形验证码(不开放不做校验)
* 2. 图形验证码有效时间
* 3. 【手机号-业务】 key-name 的配置
* 4. 【手机号-业务-锁定时间】 key-date
*
*
* @Author lazytimes
* @Date 2020/09/02 10:21
**/
public class CheckSendMailHelper {
/**
* 短信验证码配置
*/
private static final Logger logger = LoggerFactory.getLogger("phoneCode");
/**
* 60 秒内不允许重复请求
*/
private static final int PHONE_REQUEST_TIME = 60 * 1000;
/**
* 60 秒 内 图形验证码有效
*/
private static final int CAPTCHA_REQUEST_TIME = 60 * 1000;
/**
* 用户模块手机号的限制时间 24 小时
*/
private static final int PHONE_REQUEST_WAIT_TIME = 60 * 1000 * 24 * 60;
/**
* 手机访问限制初始化的值
*/
private static final int PHONE_REQUEST_INIT_SIZE = 1;
/**
* 请求上下文的map key
*/
private static final String CONTEXT_MAP = "CONTEXT_MAP";
/**
* 手机号-业务模块名称的格式
*/
private static final String PHONE_MOUDULE_FORMAT = "%s-%s";
/**
* 手机号-业务模块-请求key 的格式标注用户当前模块的请求 定时器
*/
private static final String PHONE_MOUDULE_TIMER_FORMAT = "%s-%s-timer";
/**
* 短信验证码模块的通用格式
*/
private static final String SMS_MODULE_TEMPLATE = "sms.modules.%s";
/**
* 手机号-业务-图形验证码 模块名称的格式
*/
private static final String CAPTCHA_MOUDULE_FORMAT = "%s-%s-captcha";
/**
* 手机号-业务模块-图形验证码-请求key 的格式标注用户当前模块的请求 图形验证码 每个手机号对应业务一份
*/
private static final String CAPTCHA_MOUDULE_TIMER_FORMAT = "%s-%s-captcha-timer";
/**
* 业务模块名称参数Key
*/
private static final String PHONE_MOUDULE_FLAG = "phoneModule";
/**
* 图形验证码key
*/
private static final String CAPCHACODE = "capchaCode";
// /**
// * 最后发送时间key
// */
// private static final String LAST_SEND_TIME = "lastSendTime";
/**
* 图形验证码开关
*/
private static final boolean CAPTCHA_ENABLE = Setter.getBoolean("captcha.enable");
/**
* 为当前的用户手机号码绑定 图形验证码
* 图形验证码用于短信接口请求使用,超过一定时间,图形验证码失效
* 【手机号-业务-图形验证码】:key
* 【手机号-业务-图形验证码-超时时间】:key
*
* @param phoneCode 手机号
* @param code 图形验证码
* @param request 请求
*/
public static void addCapcha(String phoneCode, String code, HttpServletRequest request) {
if (!CAPTCHA_ENABLE) {
logger.info("请开启图形验证码校验之后,再配合本工具类方法使用!");
return;
}
ServletContext servletContext = request.getServletContext();
Map<String, Map<String, Object>> attribute = initServletContextMap(servletContext);
Date now = new Date();
// 获取请求的模块名称 同时检查是否有配置模块
String phoneMouduleFlag = checkExistsAndGetModule(request);
if (StringUtils.isBlank(phoneMouduleFlag)) {
return;
}
// 手机号 -业务名称
String modulePhone = String.format(PHONE_MOUDULE_FORMAT, phoneCode, phoneMouduleFlag);
// 手机号- 业务名称 - 图形验证码
String capchaModule = String.format(CAPTCHA_MOUDULE_FORMAT, phoneCode, phoneMouduleFlag);
// 手机号 - 业务名称 -图形验证码 - 定时
String capchaModuleTimer = String.format(CAPTCHA_MOUDULE_TIMER_FORMAT, phoneCode, phoneMouduleFlag);
if (!attribute.containsKey(modulePhone)) {
HashMap<String, Object> stringObjectHashMap = new HashMap<>();
stringObjectHashMap.put(capchaModule, code);
// 图片的有效期
stringObjectHashMap.put(capchaModuleTimer, now);
attribute.put(modulePhone, stringObjectHashMap);
} else {
Map<String, Object> stringObjectMap = attribute.get(modulePhone);
// 更新验证码以及有效期
stringObjectMap.put(capchaModule, code);
// 图片的有效期
stringObjectMap.put(capchaModuleTimer, now);
}
}
/**
* 手机号限制发送处理
* 1. 增加对于用户请求短信接口的限制,60秒访问一次
* 2. 增加图形验证码和用户的手机号绑定匹配
* 1. 图形校验可以灵活开放和关闭
* 3. 【手机号-业务】的key配置,短信接口当中需要对于用户的请求做限制
*
* @param result 封装了返回的状态和信息的 result
* @param request 请求request
* @param phoneCode 手机号码
* @return
*/
public static boolean checkContextMap(Map<String, Object> result, HttpServletRequest request, String phoneCode) {
// 获取当前模块配置Map集合
ServletContext servletContext = request.getServletContext();
Map<String, Map<String, Object>> attribute = initServletContextMap(servletContext);
Date now = new Date();
// 获取请求的模块名称
String phoneMouduleFlag = checkExistsAndGetModule(request);
if (phoneMouduleFlag == null) {
result.put("result", SmsRequestStatusEnum.RESULT_STATUS_9.getCode());
result.put("msg", SmsRequestStatusEnum.RESULT_STATUS_9.getName());
return false;
}
// 当前短信业务模块【手机号-业务】
String modulePhone = String.format(PHONE_MOUDULE_FORMAT, phoneCode, phoneMouduleFlag);
// 当前模块【手机号-业务-请求限制时间】
String modulePhoneTimer = String.format(PHONE_MOUDULE_TIMER_FORMAT, phoneCode, phoneMouduleFlag);
// 当前模块每个用户每天最多请求次数
int moduleCount = Setter.getInt(String.format(SMS_MODULE_TEMPLATE, phoneMouduleFlag));
if (!attribute.containsKey(modulePhone)) {
// 需要自行初始化
HashMap<String, Object> stringObjectHashMap = new HashMap<>();
// 初始化短信接口调用次数
stringObjectHashMap.put(modulePhone, PHONE_REQUEST_INIT_SIZE);
// 初始化短信接口调用时间
stringObjectHashMap.put(modulePhoneTimer, now);
attribute.put(modulePhone, stringObjectHashMap);
return true;
} else {
Map<String, Object> objectMap = attribute.get(modulePhone);
// 开启图形验证码校验才做处理
if (CAPTCHA_ENABLE) {
if (!checkCatpchaCode(result, request, phoneCode, now, phoneMouduleFlag, objectMap)) {
return true;
}
}
// 获取当前【手机号+业务】的对应 访问次数,以及最后的访问时间
Object count = objectMap.get(modulePhone);
Object timer = objectMap.get(modulePhoneTimer);
// 初始化
if (Objects.isNull(count) || Objects.isNull(timer)) {
objectMap.put(modulePhone, PHONE_REQUEST_INIT_SIZE);
objectMap.put(modulePhoneTimer, now);
return true;
}
Integer integer = Integer.valueOf(objectMap.get(modulePhone).toString());
Date time = (Date) timer;
// 检查当前短信+业务是否在60秒内访问
if(!checkLastGetTime(result, now, time)){
return false;
}
//如果长时间未访问,重置
if ((now.getTime() - time.getTime()) > PHONE_REQUEST_WAIT_TIME) {
// 刷新时间
objectMap.put(modulePhone, PHONE_REQUEST_INIT_SIZE);
objectMap.put(modulePhoneTimer, now);
return true;
}
// 当前模块超过了请求限制
if (integer > moduleCount) {
// 超过了请求时间限制,解封
if (now.getTime() - time.getTime() > PHONE_REQUEST_WAIT_TIME) {
// 刷新时间
objectMap.put(modulePhone, PHONE_REQUEST_INIT_SIZE);
objectMap.put(modulePhoneTimer, now);
return true;
}
result.put("result", SmsRequestStatusEnum.RESULT_STATUS_10.getCode());
result.put("msg", SmsRequestStatusEnum.RESULT_STATUS_10.getName());
return false;
}
// 模块请求次数 + 1
objectMap.put(modulePhone, integer + PHONE_REQUEST_INIT_SIZE);
// 刷新时间
objectMap.put(modulePhoneTimer, now);
}
return true;
}
/**
* 校验图形验证码
*
* @param result 返回处理结果
* @param request 请求
* @param phoneCode 手机号
* @param now 当前时间
* @param phoneMouduleFlag 手机号 - 业务模块 标识
* @param objectMap servletContext 对象
* @return
*/
private static boolean checkCatpchaCode(Map<String, Object> result, HttpServletRequest request, String phoneCode, Date now, String phoneMouduleFlag, Map<String, Object> objectMap) {
// 手机号- 业务名称 - 图形验证码
String capchaModule = String.format(CAPTCHA_MOUDULE_FORMAT, phoneCode, phoneMouduleFlag);
// 手机号 - 业务名称 -图形验证码 - 定时
String capchaModuleTimer = String.format(CAPTCHA_MOUDULE_TIMER_FORMAT, phoneCode, phoneMouduleFlag);
// 图形验证码超过60秒失效
Date captchaCodeValidPeriod = (Date) objectMap.get(capchaModuleTimer);
// 获取请求参数的验证码
String requestCaptchaCode = RequestHelper.getString(CAPCHACODE, request);
// 拿到map中的图形验证码
Object requestCode = objectMap.get(capchaModule);
// 是否存在图形验证码的参数,同时比对是否和请求参数一致
if (StringUtils.isBlank(requestCaptchaCode) || Objects.isNull(requestCode)) {
result.put("result", SmsRequestStatusEnum.RESULT_STATUS_8.getCode());
result.put("msg", SmsRequestStatusEnum.RESULT_STATUS_8.getName());
return false;
}
// 如果超时或者图形验证码不匹配,需要重新请求图形验证码
if (!Objects.equals(requestCaptchaCode, requestCode.toString()) || (now.getTime() - captchaCodeValidPeriod.getTime() > (CAPTCHA_REQUEST_TIME))) {
result.put("result", SmsRequestStatusEnum.RESULT_STATUS_8.getCode());
result.put("msg", SmsRequestStatusEnum.RESULT_STATUS_8.getName());
return false;
} else {
// 清空用户的图形验证码
objectMap.put(capchaModule, null);
}
return true;
}
/**
* 检查最后的访问时间是否在指定时间内容
*
* @param result 返回对象结果
* @param now 当前时间
* @return
*/
private static boolean checkLastGetTime(Map<String, Object> result, Date now, Date lastSend) {
// 60 秒内不允许再次发送
if ((now.getTime() - lastSend.getTime()) <= PHONE_REQUEST_TIME) {
result.put("result", SmsRequestStatusEnum.RESULT_STATUS_7.getCode());
result.put("msg", SmsRequestStatusEnum.RESULT_STATUS_7.getName());
return false;
}
return true;
}
/**
* 初始化全局上下文的Map容器
*
* @param servletContext 上下文
* @return 初始化之后的map参数
*/
private static Map<String, Map<String, Object>> initServletContextMap(ServletContext servletContext) {
Map<String, Map<String, Object>> attribute = (Map<String, Map<String, Object>>) servletContext.getAttribute(CONTEXT_MAP);
if (Objects.isNull(attribute)) {
attribute = new HashMap<>();
servletContext.setAttribute(CONTEXT_MAP, attribute);
}
return attribute;
}
/**
* 检查请求参数中是否存在业务模块配置
*
* @param request 请求request
* @return
*/
static String checkExistsAndGetModule(HttpServletRequest request) {
String phoneMouduleFlag = RequestHelper.getString(PHONE_MOUDULE_FLAG, request);
String moduleNo = Setter.getString(String.format(SMS_MODULE_TEMPLATE, phoneMouduleFlag));
if (StringUtils.isBlank(moduleNo)) {
logger.info("未找到对应的短信模块,请在xml配置短信模块名称,并在请求参数中加入 phoneModule: 对应模块名称之后再进行请求");
return null;
}
return phoneMouduleFlag;
}
} <!-- =================================================================== -->
<!-- 核心:图形验证码的通用配置 -->
<!-- =================================================================== -->
<captcha description="图形验证码的通用配置">
<enable description="是否开放图形验证码" value="false" />
<length description="设置字符长度" value="5" />
<!-- 验证码图片的宽度 默认 160 -->
<width description="设置动画宽度" value="160" />
<!-- 验证码图片的高度 默认 50 -->
<height description="设置动画宽度" value="50" />
</captcha> <!-- =================================================================== -->
<!--系统发送短信配置 -->
<!-- =================================================================== -->
<sms description="webService短信机服务配置">
<isopen description="是否开启短信发送" value="true"/>
<!-- 模块配置:需要 name 模块名称,用于短信校验 和 value 表示每天最多的请求次数 -->
<modules>
<!-- 注册模块 -->
<registered description="注册模块" value="5"/>
<!-- 信箱请求短信验证码 -->
<mailbox description="信箱模块" value="10"/>
</modules>
<ip-size description="ip检测的限制次数" value="10"/>
<phoneMoudleCheck-enable description="手机号-业务模块校验是否开启" value="true"/>
<ip-enable description="IP检测开关" value="true"/>
</sms>本人学艺不精,代码写的比较烂,这篇文章算是给自己留坑以后填。
如果看文章费劲头,专门另写一篇说说独立使用。
小小工具类,仅供参考