优雅的接口防刷处理方案
阅读本文大概需要 14 分钟。
来自:juejin.cn/post/7200366809407750181
-
前言 -
原理 -
工程 -
自我提问 -
接口自由 -
时间逻辑漏洞 -
路径参数问题 -
真实ip获取 -
总结
前言
原理
-
通过ip地址+uri拼接用以作为访问者访问接口区分 -
通过在Interceptor中拦截请求,从Redis中统计用户访问接口次数从而达到接口防刷目的
工程
https://github.com/Tonciy/interface-brush-protection
/**
* @author: Zero
* @time: 2023/2/14
* @description: 接口防刷拦截处理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 多长时间内
*/
@Value("${interfaceAccess.second}")
private Long second = 10L;
/**
* 访问次数
*/
@Value("${interfaceAccess.time}")
private Long time = 3L;
/**
* 禁用时长--单位/秒
*/
@Value("${interfaceAccess.lockTime}")
private Long lockTime = 60L;
/**
* 锁住时的key前缀
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 统计次数时的key前缀
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
String ip = request.getRemoteAddr(); // 这里忽略代理软件方式访问,默认直接访问,也就是获取得到的就是访问者真实ip地址
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
if(Objects.isNull(isLock)){
// 还未被禁用
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
if(Objects.isNull(count)){
// 首次访问
log.info("首次访问");
redisTemplate.opsForValue().set(countKey,1,second, TimeUnit.SECONDS);
}else{
// 此用户前一点时间就访问过该接口
if((Integer)count < time){
// 放行,访问次数 + 1
redisTemplate.opsForValue().increment(countKey);
}else{
log.info("{}禁用访问{}",ip, uri);
// 禁用
redisTemplate.opsForValue().set(lockKey, 1,lockTime, TimeUnit.SECONDS);
// 删除统计
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}else{
// 此用户访问此接口已被禁用
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
return true;
}
}
-
正常访问时
-
访问次数过于频繁时
自我提问
Controller
,如下所示
-
PassCotroller
和RefuseController
-
每个Controller分别有对应的get,post,put,delete类型的方法,其映射路径与方法名称一致
接口自由
-
对于上述实现,不知道你们有没有发现一个问题 -
就是现在我们的接口防刷处理,针对是所有的接口(项目案例中我只是写的接口比较少) -
而在实际开发中,说对于所有的接口都要做防刷处理,感觉上也不太可能(写此文时目前大四,实际工作经验较少,这里不敢肯定) -
那么问题有了,该如何解决呢?目前来说想到两个解决方案
拦截器映射规则
AccessInterfaceInterceptor
是专门用来进行防刷处理的,那么实际上我们可以通过设置它的映射规则去匹配需要进行【接口防刷】的接口即可
-
要知道就是要进行防刷处理的接口,其 x, y, z的值也是并不一定会统一的 -
某些防刷接口处理比较消耗性能的,我就把x, y, z设置的紧一点 -
而某些防刷接口处理相对来说比较快,我就把x, y, z 设置的松一点 -
这没问题吧 -
但是现在呢?x, y, z值全都一致了,这就不行了 -
这就是其中一个不足点 -
当然,其实针对当前这种情况也有解决方案 -
那就是弄多个拦截器 -
每个拦截器的【接口防刷】处理逻辑跟上述一致,并去映射对应要处理的防刷接口 -
唯一不同的就是在每个拦截器内部,去修改对应防刷接口需要的x, y, z值 -
这样就是感觉会比较麻烦
-
虽然说防刷接口的映射路径基本上定下来后就不会改变 -
但实际上前后端联调开发项目时,不会有那么严谨的Api文档给我们用(这个在实习中倒是碰到过,公司不是很大,开发起来也就不那么严谨,啥都要自己搞,功能能实现就好) -
也就是说还是会有那种要修改接口的映射路径需求 -
当防刷接口数量特别多,后面的接手人员就很痛苦了 -
就算是项目是自己从0到1实现的,其实有时候项目开发到后面,自己也会忘记自己前面是如何设计的 -
而使用当前这种方式的话,谁维护谁蛋疼
自定义注解 + 反射
-
就是通过自定义注解中定义 x 秒内 y 次访问次数,禁用时长为 z 秒 -
自定义注解 + 在需要进行防刷处理的各个接口方法上 -
在拦截器中通过反射获取到各个接口中的x, y, z值即可达到我们想要的接口自由目的
/**
* @author: Zero
* @time: 2023/2/14
* @description: 接口防刷拦截处理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 锁住时的key前缀
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 统计次数时的key前缀
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 自定义注解 + 反射 实现
// 判断访问的是否是接口方法
if(handler instanceof HandlerMethod){
// 访问的是接口方法,转化为待访问的目标方法对象
HandlerMethod targetMethod = (HandlerMethod) handler;
// 取出目标方法中的 AccessLimit 注解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
// 判断此方法接口是否要进行防刷处理(方法上没有对应注解就代表不需要,不需要的话进行放行)
if(!Objects.isNull(accessLimit)){
// 需要进行防刷处理,接下来是处理逻辑
String ip = request.getRemoteAddr();
String uri = request.getRequestURI();
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
// 判断此ip用户访问此接口是否已经被禁用
if (Objects.isNull(isLock)) {
// 还未被禁用
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
long second = accessLimit.second();
long maxTime = accessLimit.maxTime();
if (Objects.isNull(count)) {
// 首次访问
log.info("首次访问");
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
} else {
// 此用户前一点时间就访问过该接口,且频率没超过设置
if ((Integer) count < maxTime) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用访问{}", ip, uri);
long forbiddenTime = accessLimit.forbiddenTime();
// 禁用
redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
// 删除统计--已经禁用了就没必要存在了
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
} else {
// 此用户访问此接口已被禁用
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}
return true;
}
}
@ReqeustMapping
标记映射路径/pass
,这样所有的接口方法前缀都包含了/pass
,并且以致于后面要修改映射路径前缀时只需改这一块地方即可
AccessLimitInterceptor
的处理逻辑
AccessLimitInterceptor
中代码修改的有点多,主要逻辑如下
/**
* @author: Zero
* @time: 2023/2/14
* @description: 接口防刷拦截处理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 锁住时的key前缀
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 统计次数时的key前缀
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 自定义注解 + 反射 实现, 版本 2.0
if (handler instanceof HandlerMethod) {
// 访问的是接口方法,转化为待访问的目标方法对象
HandlerMethod targetMethod = (HandlerMethod) handler;
// 获取目标接口方法所在类的注解@AccessLimit
AccessLimit targetClassAnnotation = targetMethod.getMethod().getDeclaringClass().getAnnotation(AccessLimit.class);
// 特别注意不能采用下面这条语句来获取,因为 Spring 采用的代理方式来代理目标方法
// 也就是说targetMethod.getClass()获得是class org.springframework.web.method.HandlerMethod ,而不知我们真正想要的 Controller
// AccessLimit targetClassAnnotation = targetMethod.getClass().getAnnotation(AccessLimit.class);
// 定义标记位,标记此类是否加了@AccessLimit注解
boolean isBrushForAllInterface = false;
String ip = request.getRemoteAddr();
String uri = request.getRequestURI();
long second = 0L;
long maxTime = 0L;
long forbiddenTime = 0L;
if (!Objects.isNull(targetClassAnnotation)) {
log.info("目标接口方法所在类上有@AccessLimit注解");
isBrushForAllInterface = true;
second = targetClassAnnotation.second();
maxTime = targetClassAnnotation.maxTime();
forbiddenTime = targetClassAnnotation.forbiddenTime();
}
// 取出目标方法中的 AccessLimit 注解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
// 判断此方法接口是否要进行防刷处理
if (!Objects.isNull(accessLimit)) {
// 需要进行防刷处理,接下来是处理逻辑
second = accessLimit.second();
maxTime = accessLimit.maxTime();
forbiddenTime = accessLimit.forbiddenTime();
if (isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
} else {
// 目标接口方法处无@AccessLimit注解,但还要看看其类上是否加了(类上有加,代表针对此类下所有接口方法都要进行防刷处理)
if (isBrushForAllInterface && isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}
return true;
}
/**
* 判断某用户访问某接口是否已经被禁用/是否需要禁用
*
* @param second 多长时间 单位/秒
* @param maxTime 最大访问次数
* @param forbiddenTime 禁用时长 单位/秒
* @param ip 访问者ip地址
* @param uri 访问的uri
* @return ture为需要禁用
*/
private boolean isForbindden(long second, long maxTime, long forbiddenTime, String ip, String uri) {
String lockKey = LOCK_PREFIX + ip + uri; //如果此ip访问此uri被禁用时的存在Redis中的 key
Object isLock = redisTemplate.opsForValue().get(lockKey);
// 判断此ip用户访问此接口是否已经被禁用
if (Objects.isNull(isLock)) {
// 还未被禁用
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(count)) {
// 首次访问
log.info("首次访问");
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
} else {
// 此用户前一点时间就访问过该接口,且频率没超过设置
if ((Integer) count < maxTime) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用访问{}", ip, uri);
// 禁用
redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
// 删除统计--已经禁用了就没必要存在了
redisTemplate.delete(countKey);
return true;
}
}
} else {
// 此用户访问此接口已被禁用
return true;
}
return false;
}
}
时间逻辑漏洞
-
第2秒请求到,为首次访问,Redis中统计次数为1(过期时间为5秒) -
第7秒,此时有两个动作,一是请求到,二是刚刚第二秒Redis存的值现在过期 -
我们先假设这一刻,请求处理完后,Redis存的值才过期 -
按照这样的逻辑走 -
第七秒请求到,Redis存在对应key,且不大于3, 次数+1 -
接着这个key立马过期 -
再继续往后走,第8秒又当做新的一个起始,就不往下说了,反正就是不会出现禁用的情况
路径参数问题
PassController
中有如下接口方法
-
不要使用路径参数
-
替换uri
-
我们获取uri的目的,其实就是为了区别访问接口 -
而把uri替换成另一种可以区分访问接口方法的标识即可 -
最容易想到的就是通过反射获取到接口方法名称,使用接口方法名称替换成uri即可 -
当然,其实不同的Controller中,其接口方法名称也有可能是相同的 -
实际上可以再获取接口方法所在类类名,使用类名 + 方法名称替换uri即可 -
实际解决方案有很多,看个人需求吧
真实ip获取
request.getRemoteAddr()
获取的
总结
推荐阅读:
互联网初中高级大厂面试题(9个G) 内容包含Java基础、JavaWeb、MySQL性能优化、JVM、锁、百万并发、消息队列、高性能缓存、反射、Spring全家桶原理、微服务、Zookeeper......等技术栈!
⬇戳阅读原文领取! 朕已阅