暴露在公网服务 API 通常需要做防刷机制,防止恶意请求,维护系统的稳定。
API 接口防刷前后端配合使用。后端实现主要有 拦截器方式 和 使用 AOP 对控制层(Controller)进行切面编程的方式。
API 防刷
API 防刷策略通常有两种,分别是在前端和后端实现。
前端实现:通过短信验证码,图片验证码,拖图片滑块,识别不同图片,图片翻转摆正等。例如短信验证码接口防刷等。
前端验证达到次数,增加二次验证。例如短信验证码输入错误达到次数,增加图片验证码二次验证。
这类更多对授权访问或注册类的接口防刷。如登录、注册接口等。
后端实现:识别访问来源及在限定时间内的访问次数,超过阀值则归为恶意用户给出提示拒绝访问。
识别用户来源会获取用户的IP 地址,要注意同一公网 IP 下的多个合法用户。IP 地址存在请求头中,容易被窃取篡改,建议服务器使用 HTTPS 连接。
拦截器方式
关于 拦截器(Interceptor) 可参考 SpringMVC之HandlerInterceptor拦截器,Spring Boot 2实践系列(二十七):Listener, Filter, Interceptor。
请求限制注解
定义请求限制注解,作用的 Controller 类或里面的方法上
使用注解的方式,可以灵活地对不同的接口设置各自的限制条件。
/**
* 请求限制注解
*/
@Target(ElementType.METHOD,ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
/**
* 允许访问的次数,默认值 MAX_VALUE
*/
int count() default Integer.MAX_VALUE;
/**
* 时间段,单位为毫秒,默认值一分钟
*/
long second() default 60;
}
拦截器 方式 和 AOP 方式都需要用到此注解。
限制访问拦截器
注意:可以有多个拦截器组成拦截器链,可以在拦截器上使用 @Order
注解指定执行顺序,值越大,执行越靠后。
@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();
}
}
//获取IP(或使用 SessionId)
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) {
//加 1
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 使其生效。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login");
registry.addInterceptor(new RequestLimitInterceptor());
}
}
使用请求拦截器
在 Controller 类或方法上使用请求限制注解。
@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 注解方式的切入点的表达式有多种写法,可以指定包或类路径,指定注解路径,指定注解参数。
/**
* 切点传入注解参数使用这种方式
* @param requestLimit
*/
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *) && @annotation(requestLimit)")
public void pointcut(RequestLimit requestLimit) {
}
/**
* 指定注解,传入注解参数
* @param requestLimit
*/
@Pointcut("@annotation(requestLimit)")
public void pointcut(RequestLimit requestLimit) {
}
/**
* 切入点表达式
* 指定包和类路径
* 下面示例:
* 指定 com.springboot.demo.controller 包下的所有类的所有方法都为切入点
*/
@Pointcut("execution(* com.springboot.demo.controller.*.*(..))")
public void pointcut() {
}
请求限制注解
定义请求限制注解,同上面拦截器中的请求限制注解一致。
AOP 切面方式一
定义 AOP ,在执行请求前处理,在类和方法上都有效,方法优先于类(方法粒度更细)。
下面的方式只对请求限制注解作用在 Controller 类和方法上都有效。
@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() {
}
//ProceedingJoinPoint is only supported for around advice
@Before("pointcut()")
public void around(JoinPoint joinPoint) throws IOException, NoSuchMethodException {
//代理的目标对象
Object target = joinPoint.getTarget();
//通知的签名(代理的目标方法签名)
Signature signature = joinPoint.getSignature();
//获取RequestAttributes
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
//获取HttpServletRequest
HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
//获取HttpServletResponse
HttpServletResponse response = ((ServletRequestAttributes) requestAttributes).getResponse();
//获取Session Id
String sessionId = requestAttributes.getSessionId();
//获取IP
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) {
//加 1
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,在执行请求前处理,只在方法上有效。
下面的方式只对方法上的注解有效,如果请求限制注解作用在类上则无效。
@Aspect
@Component
public class RequestLimitAspect {
private static final Logger logger = LogManager.getLogger(RequestLimitAspect.class);
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 切点传入注解参数使用这种方式
* @param requestLimit
*/
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *) && @annotation(requestLimit)")
public void pointcut(RequestLimit requestLimit) {
}
//ProceedingJoinPoint is only supported for around advice
@Before("pointcut(requestLimit)")
public void around(JoinPoint joinPoint, RequestLimit requestLimit) throws IOException {
//获取RequestAttributes
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
//获取HttpServletRequest
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
//获取HttpServletResponse
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) {
//加 1
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();
}
}
}
结合防爬虫防刷
拦截器实现
@Slf4j
public class IPBlockInterceptor implements HandlerInterceptor {
/** 10s内访问50次,认为是刷接口,就要进行一个限制 */
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);
//IP恶意请求
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) {
// 如果存在,直接cnt++
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);
// 设置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 脚本实现访问统计
#!/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
其它参考
注意:本文归作者所有,未经作者允许,不得转载