暴露在公网服务 API 通常需要做防刷机制,防止恶意请求,维护系统的稳定。
API 接口防刷前后端配合使用。后端实现主要有 拦截器 方式 和 使用 AOP 对控制层(Controller)进行切面编程的方式。
API 防刷 API 防刷策略通常有两种,分别是在前端和后端实现。
前端实现:通过短信验证码,图片验证码,拖图片滑块,识别不同图片,图片翻转摆正等。例如短信验证码接口防刷等。
前端验证达到次数,增加二次验证。例如短信验证码输入错误达到次数,增加图片验证码二次验证。
这类更多对授权访问或注册类的接口防刷。如登录、注册接口等。
后端实现:识别访问来源及在限定时间内的访问次数,超过阀值则归为恶意用户给出提示拒绝访问。
识别用户来源会获取用户的IP 地址,要注意同一公网 IP 下的多个合法用户。IP 地址存在请求头中,容易被窃取篡改,建议服务器使用 HTTPS 连接。
拦截器方式 关于 拦截器(Interceptor) 可参考 SpringMVC之HandlerInterceptor拦截器 ,Spring Boot 2实践系列(二十七):Listener, Filter, Interceptor 。
请求限制注解 定义请求限制注解,作用的 Controller 类或里面的方法上
使用注解的方式,可以灵活地对不同的接口设置各自的限制条件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Target(ElementType.METHOD,ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface RequestLimit { int count () default Integer.MAX_VALUE ; long second () default 60 ; }
拦截器 方式 和 AOP 方式都需要用到此注解。
限制访问拦截器 注意 :可以有多个拦截器组成拦截器链,可以在拦截器上使用 @Order
注解指定执行顺序,值越大,执行越靠后。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 @Component @Order(Integer.MAX_VALUE - 100) public class RequestLimitInterceptor extends HandlerInterceptorAdapter { Logger logger = LogManager.getLogger(RequestLimitInterceptor.class); @Autowired private RedisTemplate<String, Object> redisTemplate; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { logger.info("请求限制拦截器............." ); if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); RequestLimit methodReqLimit = handlerMethod.getMethodAnnotation(RequestLimit.class); RequestLimit classReqLimit = method.getDeclaringClass().getAnnotation(RequestLimit.class); RequestLimit requestLimit = (null != methodReqLimit ? methodReqLimit : classReqLimit); if (null == requestLimit) { return true ; } int second = requestLimit.second(); int maxCount = requestLimit.count(); boolean needLogin = requestLimit.needLogin(); String key = request.getRequestURI(); if (needLogin) { HttpSession session = request.getSession(); Account account = (Account) session.getAttribute("auth_user" ); if (null == account) { return false ; } else { key = key + "_" + account.getId(); } } key = key + "_" + IPUtil.getIpFromRequest(request); key = DigestUtils.md5DigestAsHex(key.getBytes(Charset.defaultCharset())); Integer count = (Integer) redisTemplate.opsForValue().get(key); if (null == count) { redisTemplate.opsForValue().setIfAbsent(key, 1 , second, TimeUnit.SECONDS); } else if (count < maxCount) { redisTemplate.opsForValue().increment(key); } else { logger.info("接口请求次数超限制:{}" , key); response.setCharacterEncoding("UTF-8" ); response.setContentType("application/json;charset=UTF-8" ); ServletOutputStream output = response.getOutputStream(); output.write("{\"msg\":\"请求次数超出限制\"}" .getBytes("UTF-8" )); output.flush(); output.close(); return false ; } } return true ; } }
配置拦截器生效 将拦截器添加到 WebMvcConfigurer 使其生效。
1 2 3 4 5 6 7 8 9 10 11 @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**" ) .excludePathPatterns("/login" ); registry.addInterceptor(new RequestLimitInterceptor()); } }
使用请求拦截器 在 Controller 类或方法上使用请求限制注解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @RequestLimit(count = 20, second = 60, needLogin = false) @RestController @RequestMapping("/user") public class UserController { @RequestLimit(needLogin = true, count = 10, second = 1) @RequestMapping("/getUser/{id}") public ResultBean<User> getUser (@PathVariable Integer id) { User user = new User("张飞" , 1 , "深圳" , "13822223333" , LocalDate.now()); user.setId(id); return ResultHelper.success(user); } @RequestMapping("/getUser") public ResultBean<User> getUser (User user) { user.setUsername("刘备" ).setSex(1 ).setAddress("湖北" ).setPhone("13020002000" ).setBirthday(LocalDate.now()); return ResultHelper.success(user); } }
AOP 切面方式 AOP 注解方式的切入点的表达式有多种写法,可以指定包或类路径,指定注解路径,指定注解参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Pointcut("within(@org.springframework.web.bind.annotation.RestController *) && @annotation(requestLimit)") public void pointcut (RequestLimit requestLimit) {} @Pointcut("@annotation(requestLimit)") public void pointcut (RequestLimit requestLimit) {} @Pointcut("execution(* com.springboot.demo.controller.*.*(..))") public void pointcut () {}
请求限制注解 定义请求限制注解,同上面拦截器中的请求限制注解一致。
AOP 切面方式一 定义 AOP ,在执行请求前处理,在类和方法上都有效,方法优先于类(方法粒度更细)。
下面的方式只对请求限制注解作用在 Controller 类和方法上都有效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 @Aspect @Component public class RequestLimitAspectC { private static final Logger logger = LogManager.getLogger(RequestLimitAspectC.class); @Autowired private RedisTemplate<String, Object> redisTemplate; @Pointcut("within(@com.springboot.demo.common.annotation.RequestLimit *)") public void pointcut () { } @Before("pointcut()") public void around (JoinPoint joinPoint) throws IOException, NoSuchMethodException { Object target = joinPoint.getTarget(); Signature signature = joinPoint.getSignature(); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST); HttpServletResponse response = ((ServletRequestAttributes) requestAttributes).getResponse(); String sessionId = requestAttributes.getSessionId(); String ip = IPUtil.getIpFromRequest(request); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); RequestLimit methodReqLimit = method.getAnnotation(RequestLimit.class); RequestLimit classReqLimit = target.getClass().getAnnotation(RequestLimit.class); RequestLimit requestLimit = (null != methodReqLimit ? methodReqLimit : classReqLimit); int maxCount = requestLimit.count(); int second = requestLimit.second(); String key =sessionId + ":" + request.getRequestURI() + ":" + ip; if (requestLimit.needLogin()) { Account account = (Account) request.getSession().getAttribute("auth_user" ); if (null == account) { throw new RuntimeException("访问必须先登录" ); } key = key + ":" + account.getId().toString(); } key = "RequestLimit:" + DigestUtils.md5DigestAsHex(key.getBytes(Charset.defaultCharset())); Integer count = (Integer) redisTemplate.opsForValue().get(key); if (null == count) { redisTemplate.opsForValue().setIfAbsent(key, 1 , second, TimeUnit.SECONDS); } else if (count < maxCount) { redisTemplate.opsForValue().set(key, count + 1 ); } else { logger.info("接口请求次数超限制:{}" , key); response.setCharacterEncoding("UTF-8" ); response.setContentType("application/json;charset=UTF-8" ); ServletOutputStream output = response.getOutputStream(); output.write("{\"msg\":\"请求次数超出限制\"}" .getBytes("UTF-8" )); output.flush(); output.close(); } } }
AOP 切面方式二 定义 AOP,在执行请求前处理,只在方法上有效。
下面的方式只对方法 上的注解有效,如果请求限制注解作用在类上则无效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @Aspect @Component public class RequestLimitAspect { private static final Logger logger = LogManager.getLogger(RequestLimitAspect.class); @Autowired private RedisTemplate<String, Object> redisTemplate; @Pointcut("within(@org.springframework.web.bind.annotation.RestController *) && @annotation(requestLimit)") public void pointcut (RequestLimit requestLimit) { } @Before("pointcut(requestLimit)") public void around (JoinPoint joinPoint, RequestLimit requestLimit) throws IOException { RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); HttpServletResponse response = ((ServletRequestAttributes) requestAttributes).getResponse(); int maxCount = requestLimit.count(); int second = requestLimit.second(); Account account = null ; String ip = IPUtil.getIpFromRequest(request); HttpSession session = request.getSession(); String key = session.getId() + ":" + request.getRequestURI() + ":" + ip; if (requestLimit.needLogin()) { account = (Account) session.getAttribute("auth_user" ); if (null == account) { throw new RuntimeException("访问必须先登录" ); } key = key + ":" + account.getId().toString(); } key = "RequestLimit:" + DigestUtils.md5DigestAsHex(key.getBytes(Charset.defaultCharset())); Integer count = (Integer) redisTemplate.opsForValue().get(key); if (null == count) { redisTemplate.opsForValue().setIfAbsent(key, 1 , second, TimeUnit.SECONDS); } else if (count < maxCount) { redisTemplate.opsForValue().set(key, count + 1 ); } else { logger.info("接口请求次数超限制:{}" , key); response.setCharacterEncoding("UTF-8" ); response.setContentType("application/json;charset=UTF-8" ); ServletOutputStream output = response.getOutputStream(); output.write("{\"msg\":\"请求次数超出限制\"}" .getBytes("UTF-8" )); output.flush(); output.close(); } } }
结合防爬虫防刷 拦截器实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 @Slf4j public class IPBlockInterceptor implements HandlerInterceptor { private static final long TIME = 10 ; private static final long CNT = 50 ; private Object lock = new Object(); private static final String USERAGENT = "User-Agent" ; private static final String CRAWLER = "crawler" ; @Autowired private RedisHelper<Integer> redisHelper; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { synchronized (lock) { boolean checkAgent = checkAgent(request); boolean checkIP = checkIP(request, response); return checkAgent && checkIP; } } private boolean checkAgent (HttpServletRequest request) { String header = request.getHeader(USERAGENT); if (StringUtils.isEmpty(header)) { return false ; } if (header.contains(CRAWLER)) { log.error("请求头有问题,拦截 ==> User-Agent = {}" , header); return false ; } return true ; } private boolean checkIP (HttpServletRequest request, HttpServletResponse response) throws Exception { String ip = IPUtils.getClientIp(request); String url = request.getRequestURL().toString(); String param = getAllParam(request); boolean isExist = redisHelper.isExist(ip); if (isExist) { int cnt = redisHelper.incr(ip); if (cnt > IPBlockInterceptor.CNT) { OscResult<String> result = new OscResult<>(); response.setCharacterEncoding("UTF-8" ); response.setHeader("content-type" , "application/json;charset=UTF-8" ); result = result.fail(OscResultEnum.LIMIT_EXCEPTION); response.getWriter().print(JSON.toJSONString(result)); log.error("ip = {}, 请求过快,被限制" , ip); redisHelper.set(ip, --cnt); return false ; } log.info("ip = {}, {}s之内第{}次请求{},参数为{},通过" , ip, TIME, cnt, url, param); } else { redisHelper.setEx(ip, IPBlockInterceptor.TIME, 1 ); log.info("ip = {}, {}s之内第1次请求{},参数为{},通过" , ip, TIME, url, param); } return true ; } private String getAllParam (HttpServletRequest request) { Map<String, String[]> map = request.getParameterMap(); StringBuilder sb = new StringBuilder("[" ); map.forEach((x, y) -> { String s = StringUtils.join(y, "," ); sb.append(x + " = " + s + ";" ); }); sb.append("]" ); return sb.toString(); } @Override public void postHandle (HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { } }
访问统计 使用 Shell 脚本实现访问统计
1 2 3 4 5 6 7 8 9 10 11 # !/bin/bash # 复制日志到当前目录 cp /home/tomcat/apache-tomcat-8.5.23/workspace/osc/osc.log /home/shell/java/osc.log # 将日志中的ip点号如: 120.74.147.123 换为 120:74:147:123 sed -i "s/\./:/g" osc.log # 筛选出只包含ip的行,并且只打印ip出来 awk '/limit/ {print $11}' osc.log > temp.txt # 根据ip的所有位数进行排序 并且统计次数 最后输出前50行 cat temp.txt | sort -t ':' -k1n -k2n -k3n -k4n | uniq -c | sort -nr | head -n 50 > result.txt # 删除无关紧要文件 rm -rf temp.txt osc.log
摘自 如何识别恶意请求,进行反爬虫操作?
其它参考
Spring(二):Spring AOP 理解与应用
Spring Boot 项目 API 防刷