優(yōu)雅的接口防刷處理方案
閱讀本文大概需要 14 分鐘。
來自:juejin.cn/post/7200366809407750181
-
前言 -
原理 -
工程 -
自我提問 -
接口自由 -
時(shí)間邏輯漏洞 -
路徑參數(shù)問題 -
真實(shí)ip獲取 -
總結(jié)
前言
原理
-
通過ip地址+uri拼接用以作為訪問者訪問接口區(qū)分 -
通過在Interceptor中攔截請(qǐng)求,從Redis中統(tǒng)計(jì)用戶訪問接口次數(shù)從而達(dá)到接口防刷目的
工程
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;
/**
* 多長時(shí)間內(nèi)
*/
@Value("${interfaceAccess.second}")
private Long second = 10L;
/**
* 訪問次數(shù)
*/
@Value("${interfaceAccess.time}")
private Long time = 3L;
/**
* 禁用時(shí)長--單位/秒
*/
@Value("${interfaceAccess.lockTime}")
private Long lockTime = 60L;
/**
* 鎖住時(shí)的key前綴
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 統(tǒng)計(jì)次數(shù)時(shí)的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(); // 這里忽略代理軟件方式訪問,默認(rèn)直接訪問,也就是獲取得到的就是訪問者真實(shí)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{
// 此用戶前一點(diǎn)時(shí)間就訪問過該接口
if((Integer)count < time){
// 放行,訪問次數(shù) + 1
redisTemplate.opsForValue().increment(countKey);
}else{
log.info("{}禁用訪問{}",ip, uri);
// 禁用
redisTemplate.opsForValue().set(lockKey, 1,lockTime, TimeUnit.SECONDS);
// 刪除統(tǒng)計(jì)
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}else{
// 此用戶訪問此接口已被禁用
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
return true;
}
}
-
正常訪問時(shí)
-
訪問次數(shù)過于頻繁時(shí)
自我提問
Controller,如下所示
-
PassCotroller和RefuseController -
每個(gè)Controller分別有對(duì)應(yīng)的get,post,put,delete類型的方法,其映射路徑與方法名稱一致
接口自由
-
對(duì)于上述實(shí)現(xiàn),不知道你們有沒有發(fā)現(xiàn)一個(gè)問題 -
就是現(xiàn)在我們的接口防刷處理,針對(duì)是所有的接口(項(xiàng)目案例中我只是寫的接口比較少) -
而在實(shí)際開發(fā)中,說對(duì)于所有的接口都要做防刷處理,感覺上也不太可能(寫此文時(shí)目前大四,實(shí)際工作經(jīng)驗(yàn)較少,這里不敢肯定) -
那么問題有了,該如何解決呢?目前來說想到兩個(gè)解決方案
攔截器映射規(guī)則
AccessInterfaceInterceptor是專門用來進(jìn)行防刷處理的,那么實(shí)際上我們可以通過設(shè)置它的映射規(guī)則去匹配需要進(jìn)行【接口防刷】的接口即可
-
要知道就是要進(jìn)行防刷處理的接口,其 x, y, z的值也是并不一定會(huì)統(tǒng)一的 -
某些防刷接口處理比較消耗性能的,我就把x, y, z設(shè)置的緊一點(diǎn) -
而某些防刷接口處理相對(duì)來說比較快,我就把x, y, z 設(shè)置的松一點(diǎn) -
這沒問題吧 -
但是現(xiàn)在呢?x, y, z值全都一致了,這就不行了 -
這就是其中一個(gè)不足點(diǎn) -
當(dāng)然,其實(shí)針對(duì)當(dāng)前這種情況也有解決方案 -
那就是弄多個(gè)攔截器 -
每個(gè)攔截器的【接口防刷】處理邏輯跟上述一致,并去映射對(duì)應(yīng)要處理的防刷接口 -
唯一不同的就是在每個(gè)攔截器內(nèi)部,去修改對(duì)應(yīng)防刷接口需要的x, y, z值 -
這樣就是感覺會(huì)比較麻煩
-
雖然說防刷接口的映射路徑基本上定下來后就不會(huì)改變 -
但實(shí)際上前后端聯(lián)調(diào)開發(fā)項(xiàng)目時(shí),不會(huì)有那么嚴(yán)謹(jǐn)?shù)腁pi文檔給我們用(這個(gè)在實(shí)習(xí)中倒是碰到過,公司不是很大,開發(fā)起來也就不那么嚴(yán)謹(jǐn),啥都要自己搞,功能能實(shí)現(xiàn)就好) -
也就是說還是會(huì)有那種要修改接口的映射路徑需求 -
當(dāng)防刷接口數(shù)量特別多,后面的接手人員就很痛苦了 -
就算是項(xiàng)目是自己從0到1實(shí)現(xiàn)的,其實(shí)有時(shí)候項(xiàng)目開發(fā)到后面,自己也會(huì)忘記自己前面是如何設(shè)計(jì)的 -
而使用當(dāng)前這種方式的話,誰維護(hù)誰蛋疼
自定義注解 + 反射
-
就是通過自定義注解中定義 x 秒內(nèi) y 次訪問次數(shù),禁用時(shí)長為 z 秒 -
自定義注解 + 在需要進(jìn)行防刷處理的各個(gè)接口方法上 -
在攔截器中通過反射獲取到各個(gè)接口中的x, y, z值即可達(dá)到我們想要的接口自由目的
/**
* @author: Zero
* @time: 2023/2/14
* @description: 接口防刷攔截處理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 鎖住時(shí)的key前綴
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 統(tǒng)計(jì)次數(shù)時(shí)的key前綴
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 自定義注解 + 反射 實(shí)現(xiàn)
// 判斷訪問的是否是接口方法
if(handler instanceof HandlerMethod){
// 訪問的是接口方法,轉(zhuǎn)化為待訪問的目標(biāo)方法對(duì)象
HandlerMethod targetMethod = (HandlerMethod) handler;
// 取出目標(biāo)方法中的 AccessLimit 注解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
// 判斷此方法接口是否要進(jìn)行防刷處理(方法上沒有對(duì)應(yīng)注解就代表不需要,不需要的話進(jìn)行放行)
if(!Objects.isNull(accessLimit)){
// 需要進(jìn)行防刷處理,接下來是處理邏輯
String ip = request.getRemoteAddr();
String uri = request.getRequestURI();
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
// 判斷此ip用戶訪問此接口是否已經(jīng)被禁用
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 {
// 此用戶前一點(diǎn)時(shí)間就訪問過該接口,且頻率沒超過設(shè)置
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);
// 刪除統(tǒng)計(jì)--已經(jīng)禁用了就沒必要存在了
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
} else {
// 此用戶訪問此接口已被禁用
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}
return true;
}
}
@ReqeustMapping標(biāo)記映射路徑/pass,這樣所有的接口方法前綴都包含了/pass,并且以致于后面要修改映射路徑前綴時(shí)只需改這一塊地方即可
AccessLimitInterceptor的處理邏輯
AccessLimitInterceptor中代碼修改的有點(diǎn)多,主要邏輯如下
/**
* @author: Zero
* @time: 2023/2/14
* @description: 接口防刷攔截處理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 鎖住時(shí)的key前綴
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 統(tǒng)計(jì)次數(shù)時(shí)的key前綴
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 自定義注解 + 反射 實(shí)現(xiàn), 版本 2.0
if (handler instanceof HandlerMethod) {
// 訪問的是接口方法,轉(zhuǎn)化為待訪問的目標(biāo)方法對(duì)象
HandlerMethod targetMethod = (HandlerMethod) handler;
// 獲取目標(biāo)接口方法所在類的注解@AccessLimit
AccessLimit targetClassAnnotation = targetMethod.getMethod().getDeclaringClass().getAnnotation(AccessLimit.class);
// 特別注意不能采用下面這條語句來獲取,因?yàn)?nbsp;Spring 采用的代理方式來代理目標(biāo)方法
// 也就是說targetMethod.getClass()獲得是class org.springframework.web.method.HandlerMethod ,而不知我們真正想要的 Controller
// AccessLimit targetClassAnnotation = targetMethod.getClass().getAnnotation(AccessLimit.class);
// 定義標(biāo)記位,標(biāo)記此類是否加了@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("目標(biāo)接口方法所在類上有@AccessLimit注解");
isBrushForAllInterface = true;
second = targetClassAnnotation.second();
maxTime = targetClassAnnotation.maxTime();
forbiddenTime = targetClassAnnotation.forbiddenTime();
}
// 取出目標(biāo)方法中的 AccessLimit 注解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
// 判斷此方法接口是否要進(jìn)行防刷處理
if (!Objects.isNull(accessLimit)) {
// 需要進(jìn)行防刷處理,接下來是處理邏輯
second = accessLimit.second();
maxTime = accessLimit.maxTime();
forbiddenTime = accessLimit.forbiddenTime();
if (isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
} else {
// 目標(biāo)接口方法處無@AccessLimit注解,但還要看看其類上是否加了(類上有加,代表針對(duì)此類下所有接口方法都要進(jìn)行防刷處理)
if (isBrushForAllInterface && isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}
return true;
}
/**
* 判斷某用戶訪問某接口是否已經(jīng)被禁用/是否需要禁用
*
* @param second 多長時(shí)間 單位/秒
* @param maxTime 最大訪問次數(shù)
* @param forbiddenTime 禁用時(shí)長 單位/秒
* @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被禁用時(shí)的存在Redis中的 key
Object isLock = redisTemplate.opsForValue().get(lockKey);
// 判斷此ip用戶訪問此接口是否已經(jīng)被禁用
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 {
// 此用戶前一點(diǎn)時(shí)間就訪問過該接口,且頻率沒超過設(shè)置
if ((Integer) count < maxTime) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用訪問{}", ip, uri);
// 禁用
redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
// 刪除統(tǒng)計(jì)--已經(jīng)禁用了就沒必要存在了
redisTemplate.delete(countKey);
return true;
}
}
} else {
// 此用戶訪問此接口已被禁用
return true;
}
return false;
}
}
時(shí)間邏輯漏洞
-
第2秒請(qǐng)求到,為首次訪問,Redis中統(tǒng)計(jì)次數(shù)為1(過期時(shí)間為5秒) -
第7秒,此時(shí)有兩個(gè)動(dòng)作,一是請(qǐng)求到,二是剛剛第二秒Redis存的值現(xiàn)在過期 -
我們先假設(shè)這一刻,請(qǐng)求處理完后,Redis存的值才過期 -
按照這樣的邏輯走 -
第七秒請(qǐng)求到,Redis存在對(duì)應(yīng)key,且不大于3, 次數(shù)+1 -
接著這個(gè)key立馬過期 -
再繼續(xù)往后走,第8秒又當(dāng)做新的一個(gè)起始,就不往下說了,反正就是不會(huì)出現(xiàn)禁用的情況
路徑參數(shù)問題
PassController中有如下接口方法
-
不要使用路徑參數(shù)
-
替換uri
-
我們獲取uri的目的,其實(shí)就是為了區(qū)別訪問接口 -
而把uri替換成另一種可以區(qū)分訪問接口方法的標(biāo)識(shí)即可 -
最容易想到的就是通過反射獲取到接口方法名稱,使用接口方法名稱替換成uri即可 -
當(dāng)然,其實(shí)不同的Controller中,其接口方法名稱也有可能是相同的 -
實(shí)際上可以再獲取接口方法所在類類名,使用類名 + 方法名稱替換uri即可 -
實(shí)際解決方案有很多,看個(gè)人需求吧
真實(shí)ip獲取
request.getRemoteAddr()獲取的
總結(jié)
推薦閱讀:
10 個(gè) Java Stream 頂級(jí)技巧,大量簡(jiǎn)化代碼!
互聯(lián)網(wǎng)初中高級(jí)大廠面試題(9個(gè)G) 內(nèi)容包含Java基礎(chǔ)、JavaWeb、MySQL性能優(yōu)化、JVM、鎖、百萬并發(fā)、消息隊(duì)列、高性能緩存、反射、Spring全家桶原理、微服務(wù)、Zookeeper......等技術(shù)棧!
?戳閱讀原文領(lǐng)取! 朕已閱

