目的
- 短信發送及短信驗證碼校驗接口防刷
一方面防止用戶循環調用刷短信驗證碼
另一方面防止用戶循環調用測短信驗證碼(一般短信驗證碼爲6位純數字,一秒鐘上百次調用,如果不做限制很快就能試出來了) - 很多接口需要防止前端重複調用
誤操作多次點擊,不屬於攻擊類型,正常用戶經常會觸發的,例如信息發佈可能前端限制未做好,誤點擊了多次,這種情況實際上應該只記錄第一次的,後續的不應該繼續操作數據庫。 - 極端的情況
可能很多接口一天或者很長時間只能調用一次(類似簽到?個人想法是儘量不讓數據到了數據庫層再拋異常)
解決措施
利用Spring AOP理念,自定義註解實現接口級訪問次數限制
訪問次數記錄使用Redis存儲,Redis的過期機制很適合當前場景,而且可以在更大程度上提升性能
-
定義註解
package com.cong.core.rate; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimit { /** 週期,單位是秒 */ int cycle() default 5; /** 請求次數 */ int number() default 1; /** 默認提示信息 */ String msg() default "請勿重複點擊"; }
默認是5秒調用一次,現在網上一大堆腳本,貼吧發帖跟帖自動化,實際上打字點擊發帖的正常頻率也不會超過2秒一次吧,但是機器很容易就超過這個速度了,在一定程度上也可以限制這種情況的發生。
接口級限制,所以當前註解只作用在方法上。 -
定義接口訪問頻次限制接口
package com.cong.core.rate; public interface RateLimitService { /** * 接口頻次限制校驗 * * @param ip * 客戶端IP * @param uri * 請求接口名 * @param rateLimit * 限制頻次信息 * @return * @author single-聰 * @date 2020年6月1日 * @version 1.6.1 */ Boolean limit(String ip, String uri, RateLimit rateLimit); }
因爲Interceptor攔截器最終返回值是true或false,所以當前接口返回值爲boolean類型。
關於參數,可以設法獲取設備Mac地址,對於某些明顯是攻擊的IP及設備封禁。 -
RateLimitService接口默認實現類
package com.cong.core.rate; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; import lombok.extern.slf4j.Slf4j; @Slf4j public class DefaultRateLimitServiceImpl implements RateLimitService { private RedisTemplate<String, Integer> redisTemplate; public void setRedisTemplate(RedisTemplate<String, Integer> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public Boolean limit(String ip, String uri, RateLimit rateLimit) { log.info("默認的實現,請自定義實現類覆蓋當前實現"); String key = "rate:" + ip + ":" + uri; // 緩存中存在key,在限定訪問週期內已經調用過當前接口 if (redisTemplate.hasKey(key)) { // 訪問次數自增1 redisTemplate.opsForValue().increment(key, 1); // 超出訪問次數限制 if (redisTemplate.opsForValue().get(key) > rateLimit.number()) { return false; } // 未超出訪問次數限制,不進行任何操作,返回true } else { // 第一次設置數據,過期時間爲註解確定的訪問週期 redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS); } return true; } }
默認實現類中使用Redis作爲存儲策略,加上下面的Bean注入策略你就可以自定義接口實現類使用自己的存儲方式了。
-
Bean配置
package com.cong.core.rate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; @Configuration public class RateLimitBeanConfig { @Autowired private RedisTemplate<String, Integer> redisTemplate; @Bean @ConditionalOnMissingBean(RateLimitService.class) public RateLimitService rateLimitService() { DefaultRateLimitServiceImpl defaultRateLimitServiceImpl = new DefaultRateLimitServiceImpl(); defaultRateLimitServiceImpl.setRedisTemplate(redisTemplate); return defaultRateLimitServiceImpl; } }
此配置意爲讓用戶編寫接口實現類覆蓋默認實現。
-
定義攔截器
package com.cong.core.rate; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; @Component public class RateLimitInterceptor extends HandlerInterceptorAdapter { private RateLimitService rateLimitService; public void setRateLimitService(RateLimitService rateLimitService) { this.rateLimitService = rateLimitService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判斷請求是否屬於方法的請求 if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; // 獲取方法中的註解,看是否有該註解 RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); if (rateLimit == null) { return true; } // 請求IP地址 String ip = request.getRemoteAddr(); // 請求url路徑 String uri = request.getRequestURI(); return rateLimitService.limit(ip, uri, rateLimit); } return true; } }
重點,只對添加了
@RateLimit
註解的接口進行訪問頻次限制。 -
配置攔截器
package com.cong.config; import com.cong.core.rate.RateLimitInterceptor; import com.cong.core.rate.RateLimitService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; @Configuration public class WebMvcConfig extends WebMvcConfigurationSupport { @Autowired private RateLimitService rateLimitService; @Override protected void addInterceptors(InterceptorRegistry registry) { RateLimitInterceptor rateLimitInterceptor = new RateLimitInterceptor(); rateLimitInterceptor.setRateLimitService(rateLimitService); registry.addInterceptor(rateLimitInterceptor); } }
文中的很多地方接口使用set方式注入,是爲了防止接口注入失敗,報錯空指針異常(應該很多人遇到過)。
使用
-
使用註解
@RestController @RequestMapping("open/public") public class OpenPublicController { @RateLimit(number = 2, cycle = 10) @PostMapping("rate") public void rate() { throw new VersionException(); } }
上述註解的作用是10秒內可以請求兩次,其他的請求就不處理了,VersionException
是我自定義的異常,用於提示用戶升級新版本,在2次內返回用戶正常提示信息:
{
"state": 1000,
"msg": "請升級到新版本",
"data": null
}
超出限制後無返回信息(RateLimitInterceptor
攔截器中返回的是false,直接結束了這次請求,同時未向前端返回任何信息,實際開發中應該會返回提示信息,補充內容中解決這個問題)
補充
關於攔截器中接口調用超出限制頻次的自定義返回:
package com.cong.core.rate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.cong.core.support.ReturnData;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class RateLimitInterceptor extends HandlerInterceptorAdapter {
private RateLimitService rateLimitService;
public void setRateLimitService(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
private ObjectMapper objectMapper;
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// 判斷請求是否屬於方法的請求
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 獲取方法中的註解,看是否有該註解
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
return true;
}
// 請求IP地址
String ip = request.getRemoteAddr();
// 請求url路徑
String uri = request.getRequestURI();
if (!rateLimitService.limit(ip, uri, rateLimit)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ReturnData(rateLimit.msg())));
response.setStatus(HttpStatus.OK.value());
return false;
}
}
return true;
}
}
注入ObjectMapper
需要set一下。
ReturnData
是封裝的返回值信息,前端可以根據這個給用戶友好的提示,後端也可以自定義提示信息。
@Data
@NoArgsConstructor
public class ReturnData {
private Integer state = 1000;
private String msg;
private Object data;
public ReturnData(String msg) {
super();
this.msg = msg;
}
}
不過建議是自定義失敗處理器,這樣所有的錯誤統一走失敗處理器,更方便以後的代碼維護,這裏只是爲了實現接口頻次限制,其他的這裏就不描述了。
超頻之後返回值:
接口名 | 註解 | 返回值 |
---|---|---|
open/public/rate | @RateLimit(number = 4, cycle = 10) | { "state": 1000, "msg": "請勿重複點擊","data": null} |
open/public/rate1 | @RateLimit(number = 4, cycle = 10, msg = “調用頻次過高”) | { "state": 1000, "msg": "調用頻次過高","data": null} |
至此即實現接口訪問頻次限制以及自定義返回提示信息。
我目前的服務端開發用戶信息是無狀態的Token,基於JWT,使用的Security框架(前段時間的文章有一組筆記),用戶權限校驗是單獨實現的。
關於性能:
使用了當前註解的接口請求耗時會長一點,我的Redis在一臺學生機上,而且跨省,耗時大概增加了40ms,本地的話大概也就20ms左右,如果對性能還有要求的話建議使用lua腳本。
建議
-
定義IP過濾器
在使用Redis的情況下,可以定義IP過濾器,計算指定IP請求速率,在上文中更多的是防止重複提交,但是對於文章開始所說的超高頻次的調用並沒有處理,建議在過濾器中攔截所有請求,每個IP對於單獨接口在訪問週期內超出限制之後將當前IP限制一段時間(是限制所有請求還是當前請求自行決定) -
基於IP過濾器統計接口訪問次數
在IP過濾器中藉助Redis計算接口訪問次數,每天同步一次,對於後面的服務擴展,接口限流等還是很有好處的。
歡迎留言,共同探討。