SpringBoot2实践系列(四十六):SpringAOP与拦截器实现API接口防刷

star2017 1年前 ⋅ 672 阅读

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

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

API 防刷

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

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

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

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

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

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

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

其它参考

  1. Spring(二):Spring AOP 理解与应用
  2. Spring Boot 项目 API 防刷
更多内容请访问:IT源点

相关文章推荐

全部评论: 0

    我有话说: