Spring Boot 2系列(四十六):Spring AOP与拦截器实现API接口防刷

暴露在公网服务 API 通常需要做防刷机制,防止恶意请求,维护系统的稳定。

API 接口防刷前后端配合使用。后端实现主要有 拦截器方式 和 使用 AOP 对控制层(Controller)进行切面编程的方式。

API 防刷

API 防刷策略通常有两种,分别是在前端和后端实现。

  1. 前端实现:通过短信验证码,图片验证码,拖图片滑块,识别不同图片,图片翻转摆正等。例如短信验证码接口防刷等。

    前端验证达到次数,增加二次验证。例如短信验证码输入错误达到次数,增加图片验证码二次验证。

    这类更多对授权访问或注册类的接口防刷。如登录、注册接口等。

  2. 后端实现:识别访问来源及在限定时间内的访问次数,超过阀值则归为恶意用户给出提示拒绝访问。

识别用户来源会获取用户的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 {

/**
* 允许访问的次数,默认值 MAX_VALUE
*/
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();
}
}
//获取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 使其生效。

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
/**
* 切点传入注解参数使用这种方式
* @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 类和方法上都有效。

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() {
}

//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,在执行请求前处理,只在方法上有效。

下面的方式只对方法上的注解有效,如果请求限制注解作用在类上则无效。

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;

/**
* 切点传入注解参数使用这种方式
* @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();
}
}
}

结合防爬虫防刷

拦截器实现

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 {

/** 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 脚本实现访问统计

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

摘自 如何识别恶意请求,进行反爬虫操作?

其它参考

  1. Spring(二):Spring AOP 理解与应用
  2. Spring Boot 项目 API 防刷

Spring Boot 2系列(四十六):Spring AOP与拦截器实现API接口防刷

http://blog.gxitsky.com/2019/11/22/SpringBoot-46-aop-api-request-limit/

作者

光星

发布于

2019-11-22

更新于

2022-06-17

许可协议

评论