• 联系办法:1761430646@qq.com
  • 编写时刻:2023年2月15日20:56:49
  • 博客地址:www.zeroeden.cn
  • 菜狗摸索,有误勿喷,烦请联系

前语

  • 本文为描绘经过Interceptor以及Redis完成接口拜访防刷Demo
  • 这儿会经过逐渐找问题,逐渐去完善的方法展现

原理

  • 经过ip地址+uri拼接用以作为拜访者拜访接口区别

  • 经过在Interceptor中阻拦恳求,从Redis中计算用户拜访接口次数然后到达接口防刷意图

  • 如下图所示

    接口防刷实现

工程

  • 项目地址:interface-brush-protection

  • Apifox地址:Apifox 暗码:Lyh3j2Rv

  • 其间,Interceptor处代码处理逻辑最为重要

    接口防刷实现

    /**
     * @author: Zero
     * @time: 2023/2/14
     * @description: 接口防刷阻拦处理
     */
    @Slf4j
    public class AccessLimintInterceptor implements HandlerInterceptor {
      @Resource
      private RedisTemplate<String, Object> redisTemplate;
    ​
      /**
       * 多长时刻内
       */
      @Value("${interfaceAccess.second}")
      private Long second = 10L;
    ​
      /**
       * 拜访次数
       */
      @Value("${interfaceAccess.time}")
      private Long time = 3L;
    ​
      /**
       * 禁用时长--单位/秒
       */
      @Value("${interfaceAccess.lockTime}")
      private Long lockTime = 60L;
    ​
      /**
       * 锁住时的key前缀
       */
      public static final String LOCK_PREFIX = "LOCK";
    ​
      /**
       * 计算次数时的key前缀
       */
      public static final String COUNT_PREFIX = "COUNT";
    ​
    ​
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    ​
        String uri = request.getRequestURI();
        String ip = request.getRemoteAddr(); // 这儿忽略署理软件办法拜访,默认直接拜访,也便是获取得到的便是拜访者实在ip地址
        String lockKey = LOCK_PREFIX + ip + uri;
        Object isLock = redisTemplate.opsForValue().get(lockKey);
        if(Objects.isNull(isLock)){
          // 还未被禁用
          String countKey = COUNT_PREFIX + ip + uri;
          Object count = redisTemplate.opsForValue().get(countKey);
          if(Objects.isNull(count)){
            // 初次拜访
            log.info("初次拜访");
            redisTemplate.opsForValue().set(countKey,1,second, TimeUnit.SECONDS);
           }else{
            // 此用户前一点时刻就拜访过该接口
            if((Integer)count < time){
              // 放行,拜访次数 + 1
              redisTemplate.opsForValue().increment(countKey);
             }else{
              log.info("{}禁用拜访{}",ip, uri);
              // 禁用
              redisTemplate.opsForValue().set(lockKey, 1,lockTime, TimeUnit.SECONDS);
              // 删去计算
              redisTemplate.delete(countKey);
              throw new CommonException(ResultCode.ACCESS_FREQUENT);
             }
           }
         }else{
          // 此用户拜访此接口已被禁用
          throw new CommonException(ResultCode.ACCESS_FREQUENT);
         }
        return true;
       }
    }
    
    • 在多长时刻内拜访接口多少次,以及禁用的时长,则是经过与装备文件配合动态设置

      接口防刷实现

    • 当处于禁用时直接抛反常则是经过在ControllerAdvice处一致处理 (这儿代码写的有点丑恶)

      接口防刷实现

  • 下面是一些测验(能够把项目经过Git复原到“【初始化】”情况进行测验)

    • 正常拜访时

      接口防刷实现

      接口防刷实现

    • 拜访次数过于频频时

      接口防刷实现

      接口防刷实现

自我提问

  • 上述完成就好像就现已到达了咱们的接口防刷意图了
  • 可是,还不够
  • 为便利后续描绘,项目中新增弥补Controller,如下所示

    接口防刷实现

    • 简单来说便是

      • PassCotrollerRefuseController
      • 每个Controller别离有对应的getpostputdelete类型的办法,其映射途径与办法名称一致

接口自在

  • 对于上述完成,不知道你们有没有发现一个问题
  • 便是现在咱们的接口防刷处理,针对是一切的接口(项目事例中我只是写的接口比较少)
  • 而在实践开发中,说对于一切的接口都要做防刷处理,感觉上也不太可能(写此文时现在大四,实践工作经验较少,这儿不敢肯定)
  • 那么问题有了,该怎么处理呢?现在来说想到两个处理方案

阻拦器映射规矩

  • 项目经过Git复原到”【Interceptor设置映射规矩完成接口自在】”版别即可得到此事例完成
  1. 咱们都知道阻拦器是能够设置阻拦规矩的,然后到达阻拦处理意图

    接口防刷实现

  2. 这个AccessInterfaceInterceptor是专门用来进行防刷处理的,那么实践上咱们能够经过设置它的映射规矩去匹配需求进行【接口防刷】的接口即可

  3. 比方说下面的映射装备

    接口防刷实现

  4. 这样就初步到达了咱们的意图,经过映射规矩的装备,只针对那些需求进行【接口防刷】的接口才会进行处理

  5. 至于为啥说是初步呢?下面我就说说现在我想到的运用这种办法进行【接口防刷】的缺乏点:

    • 一切要进行防刷处理的接口一致都是装备成了 x 秒内 y 次拜访次数,禁用时长为 z 秒

      • 要知道便是要进行防刷处理的接口,其 x, y, z的值也是并不一定会一致的
      • 某些防刷接口处理比较消耗性能的,我就把x, y, z设置的紧一点
      • 而某些防刷接口处理相对来说比较快,我就把x, y, z 设置的松一点
      • 这没问题吧
      • 可是现在呢?x, y, z值全都一致了,这就不行了
      • 这便是其间一个缺乏点
      • 当然,其实针对当时这种情况也有处理方案
      • 那便是弄多个阻拦器
      • 每个阻拦器的【接口防刷】处理逻辑跟上述一致,并去映射对应要处理的防刷接口
      • 仅有不同的便是在每个阻拦器内部,去修正对应防刷接口需求的x, y, z值
      • 这样便是感觉会比较费事
    • 防刷接口映射途径修正后保护问题

      • 尽管说防刷接口的映射途径基本上定下来后就不会改变
      • 但实践上前后端联调开发项目时,不会有那么谨慎的Api文档给咱们用(这个在实习中倒是碰到过,公司不是很大,开发起来也就不那么谨慎,啥都要自己搞,功用能完成就好)
      • 也便是说还是会有那种要修正接口的映射途径需求
      • 当防刷接口数量特别多,后边的接手人员就很痛苦了
      • 就算是项目是自己从0到1完成的,其实有时分项目开发到后边,自己也会忘记自己前面是怎么设计的
      • 而运用当时这种办法的话,谁保护谁蛋疼

自定义注解 + 反射

  • 咋说呢
  • 便是经过自定义注解中定义 x 秒内 y 次拜访次数,禁用时长为 z 秒
  • 自定义注解 + 在需求进行防刷处理的各个接口办法上
  • 在阻拦器中经过反射获取到各个接口中的x, y, z值即可到达咱们想要的接口自在意图

  • 下面做个完成

  • 声明自定义注解

    接口防刷实现

  • Controlller中办法中运用

    接口防刷实现

  • Interceptor处逻辑修正(最重要是经过反射判别此接口是否需求进行防刷处理,以及获取到x, y, z的值

    /**
     * @author: Zero
     * @time: 2023/2/14
     * @description: 接口防刷阻拦处理
     */
    @Slf4j
    public class AccessLimintInterceptor implements HandlerInterceptor {
      @Resource
      private RedisTemplate<String, Object> redisTemplate;
      /**
       * 锁住时的key前缀
       */
      public static final String LOCK_PREFIX = "LOCK";
    ​
      /**
       * 计算次数时的key前缀
       */
      public static final String COUNT_PREFIX = "COUNT";
    ​
    ​
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //     自定义注解 + 反射 完成
        // 判别拜访的是否是接口办法
        if(handler instanceof HandlerMethod){
          // 拜访的是接口办法,转化为待拜访的方针办法方针
          HandlerMethod targetMethod = (HandlerMethod) handler;
          // 取出方针办法中的 AccessLimit 注解
          AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
          // 判别此办法接口是否要进行防刷处理(办法上没有对应注解就代表不需求,不需求的话进行放行)
          if(!Objects.isNull(accessLimit)){
            // 需求进行防刷处理,接下来是处理逻辑
            String ip = request.getRemoteAddr();
            String uri = request.getRequestURI();
            String lockKey = LOCK_PREFIX + ip + uri;
            Object isLock = redisTemplate.opsForValue().get(lockKey);
            // 判别此ip用户拜访此接口是否现已被禁用
            if (Objects.isNull(isLock)) {
              // 还未被禁用
              String countKey = COUNT_PREFIX + ip + uri;
              Object count = redisTemplate.opsForValue().get(countKey);
              long second = accessLimit.second();
              long maxTime = accessLimit.maxTime();
    ​
              if (Objects.isNull(count)) {
                // 初次拜访
                log.info("初次拜访");
                redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
               } else {
                // 此用户前一点时刻就拜访过该接口,且频率没超过设置
                if ((Integer) count < maxTime) {
                  redisTemplate.opsForValue().increment(countKey);
                 } else {
    ​
                  log.info("{}禁用拜访{}", ip, uri);
                  long forbiddenTime = accessLimit.forbiddenTime();
                  // 禁用
                  redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
                  // 删去计算--现已禁用了就没必要存在了
                  redisTemplate.delete(countKey);
                  throw new CommonException(ResultCode.ACCESS_FREQUENT);
                 }
               }
             } else {
              // 此用户拜访此接口已被禁用
              throw new CommonException(ResultCode.ACCESS_FREQUENT);
             }
           }
         }
        return true;
       }
    }
    
  • 由于不好演示作用,这儿就不贴测验结果图片了
  • 项目经过Git复原到”【自定义主键+反射完成接口自在”版别即可得到此事例完成,后边自己能够针对接口做下测验看看是否好像我所说的那样完成自定义x, y, z 的作用
  • 嗯,现在看起来,能够针对每个要进行防刷处理的接口进行针对性自定义多长时刻内的最大拜访次数,以及禁用时长,哪个接口需求,就直接+在那个接口办法出即可

  • 感觉还不错的姿态,现在网上挺多材料也都是这样完成的

  • 可是还是能够有改善的当地

  • 先举一个比如,以咱们的PassController为例,如下是其完成

    接口防刷实现

  • 下图是其映射途径关系

    接口防刷实现

  • 同一个Controller的一切接口办法映射途径的前缀都包含了/pass

  • 咱们在类上经过注解@ReqeustMapping符号映射途径/pass,这样一切的接口办法前缀都包含了/pass,而且以致于后边要修正映射途径前缀时只需改这一块当地即可

  • 这也是咱们运用SpringMVC最常见的用法

  • 那么,咱们的自定义注解也可不能够这样做呢?先惹是生非个需求

  • 假定PassController中一切接口都是要进行防刷处理的,而且他们的x, y, z值就一样

  • 如果咱们的自定义注解还是只能加载办法上的话,一个一个接口加,那么无疑这是一种很呆的做法

  • 要改的话,其实也很简单,首先是修正自定义注解,让其能够作用在类上

    接口防刷实现

  • 接着便是修正AccessLimitInterceptor的处理逻辑

    • AccessLimitInterceptor中代码修正的有点多,主要逻辑如下

      接口防刷实现

    • 与之前完成比较,不同点在于x, y, z的值要首先测验在方针类中获取

    • 其次,一旦类中标有此注解,即代表此类下一切接口办法都要进行防刷处理

    • 如果其接口办法同样也标有此注解,依据就近优先原则,以接口办法中的注解标明的值为准

    /**
     * @author: Zero
     * @time: 2023/2/14
     * @description: 接口防刷阻拦处理
     */
    @Slf4j
    public class AccessLimintInterceptor implements HandlerInterceptor {
      @Resource
      private RedisTemplate<String, Object> redisTemplate;
    ​
      /**
       * 锁住时的key前缀
       */
      public static final String LOCK_PREFIX = "LOCK";
    ​
      /**
       * 计算次数时的key前缀
       */
      public static final String COUNT_PREFIX = "COUNT";
    ​
    ​
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    ​
    //    自定义注解 + 反射 完成, 版别 2.0
        if (handler instanceof HandlerMethod) {
          // 拜访的是接口办法,转化为待拜访的方针办法方针
          HandlerMethod targetMethod = (HandlerMethod) handler;
          // 获取方针接口办法地点类的注解@AccessLimit
          AccessLimit targetClassAnnotation = targetMethod.getMethod().getDeclaringClass().getAnnotation(AccessLimit.class);
          // 特别注意不能选用下面这条句子来获取,由于 Spring 选用的署理办法来署理方针办法
          //  也便是说targetMethod.getClass()获得是class org.springframework.web.method.HandlerMethod ,而不知咱们真正想要的 Controller
    //       AccessLimit targetClassAnnotation = targetMethod.getClass().getAnnotation(AccessLimit.class);
          // 定义符号位,符号此类是否加了@AccessLimit注解
          boolean isBrushForAllInterface = false;
          String ip = request.getRemoteAddr();
          String uri = request.getRequestURI();
          long second = 0L;
          long maxTime = 0L;
          long forbiddenTime = 0L;
          if (!Objects.isNull(targetClassAnnotation)) {
            log.info("方针接口办法地点类上有@AccessLimit注解");
            isBrushForAllInterface = true;
            second = targetClassAnnotation.second();
            maxTime = targetClassAnnotation.maxTime();
            forbiddenTime = targetClassAnnotation.forbiddenTime();
           }
          // 取出方针办法中的 AccessLimit 注解
          AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
          // 判别此办法接口是否要进行防刷处理
          if (!Objects.isNull(accessLimit)) {
            // 需求进行防刷处理,接下来是处理逻辑
            second = accessLimit.second();
            maxTime = accessLimit.maxTime();
            forbiddenTime = accessLimit.forbiddenTime();
            if (isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
              throw new CommonException(ResultCode.ACCESS_FREQUENT);
             }
           } else {
            // 方针接口办法处无@AccessLimit注解,但还要看看其类上是否加了(类上有加,代表针对此类下一切接口办法都要进行防刷处理)
            if (isBrushForAllInterface && isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
              throw new CommonException(ResultCode.ACCESS_FREQUENT);
             }
           }
         }
        return true;
       }
    ​
      /**
       * 判别某用户拜访某接口是否现已被禁用/是否需求禁用
       *
       * @param second     多长时刻  单位/秒
       * @param maxTime    最大拜访次数
       * @param forbiddenTime 禁用时长 单位/秒
       * @param ip       拜访者ip地址
       * @param uri      拜访的uri
       * @return ture为需求禁用
       */
      private boolean isForbindden(long second, long maxTime, long forbiddenTime, String ip, String uri) {
        String lockKey = LOCK_PREFIX + ip + uri; //如果此ip拜访此uri被禁用时的存在Redis中的 key
        Object isLock = redisTemplate.opsForValue().get(lockKey);
        // 判别此ip用户拜访此接口是否现已被禁用
        if (Objects.isNull(isLock)) {
          // 还未被禁用
          String countKey = COUNT_PREFIX + ip + uri;
          Object count = redisTemplate.opsForValue().get(countKey);
          if (Objects.isNull(count)) {
            // 初次拜访
            log.info("初次拜访");
            redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
           } else {
            // 此用户前一点时刻就拜访过该接口,且频率没超过设置
            if ((Integer) count < maxTime) {
              redisTemplate.opsForValue().increment(countKey);
             } else {
              log.info("{}禁用拜访{}", ip, uri);
              // 禁用
              redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
              // 删去计算--现已禁用了就没必要存在了
              redisTemplate.delete(countKey);
              return true;
             }
           }
         } else {
          // 此用户拜访此接口已被禁用
          return true;
         }
        return false;
       }
    }
    
  • 好了,这样就到达咱们想要的作用了

    接口防刷实现

  • 项目经过Git复原到”【自定义注解+反射完成接口自在-版别2.0】”版别即可得到此事例完成,自己能够测验万一下

  • 这是现在来说比较抱负的做法,至于其他做法,暂时没啥了解到

时刻逻辑漏洞

  • 这是我一开始都有留意到的问题

  • 也是一向搞不懂,便是咱们现在的一切做法其实感觉都不是严厉意义上的x秒内y次拜访次数

  • 特别注意这个x秒,它是接连,恣意的(代表这个x秒时刻片段其实是能够发生在恣意一个时刻轴上)

  • 我下面测验表达我的意思,可是我不知道能不能表达清楚

  • 假定咱们固定某个接口5秒内只能拜访3次,以下面比如为例

    接口防刷实现

    • 底下的小圆圈代表此时恳求拜访接口
  • 依照咱们之前一切做法的逻辑走

    1. 第2秒恳求到,为初次拜访,Redis中计算次数为1(过期时刻为5秒)
    2. 第7秒,此时有两个动作,一是恳求到,二是刚刚第二秒Redis存的值现在过期
    3. 咱们先假定这一刻,恳求处理完后,Redis存的值才过期
    4. 依照这样的逻辑走
    5. 第七秒恳求到,Redis存在对应key,且不大于3, 次数+1
    6. 接着这个key立马过期
    7. 再继续往后走,第8秒又作为新的一个开始,就不往下说了,横竖便是不会呈现禁用的情况
  • 依照上述逻辑走,实践上也便是说当呈现初次拜访时,作为这5秒时刻片段的开始

  • 第2秒是,第8秒也是

  • 可是有没有想过,实践上这个5秒时刻片段实践上是能够放置在时刻轴上恣意区域的

  • 上述情况咱们是依据恳求的到来情况人为的把它放在【2-7】,【8-13】上

  • 而实践上这5秒时刻片段是能够放在恣意区域的

  • 那么,这样的话,【7-12】也能够放置

  • 而【7-12】这段时刻有4次恳求,就到达了咱们禁用的条件了

  • 是不是感觉怪怪的

  • 想过其他做法,可是好像严厉意义上真的做不到我所说的那样(至少现在来说想不到)
  • 之前咱们的做法,正常来说也够用,至少说有到达防刷的作用
  • 后边有时机的话再看看,不知道我是不是钻牛角尖了

途径参数问题

  • 假定现在PassController中有如下接口办法

    接口防刷实现

  • 也便是咱们在接口办法中常用的在恳求途径中获取参数的套路

  • 可是运用途径参数的话,就会发生问题

  • 那便是同一个ip地址拜访此接口时,我带着的参数值不同

  • 依照咱们之前那种前缀+ip+uri拼接的方法作为key的话,其实是区别不了的

  • 下图是拜访此接口,带着不同参数值时获取的uri情况

    接口防刷实现

  • 这样的话在咱们之前阻拦器的处理逻辑中,会认为是此ip用户拜访的是不同的接口办法,而实践上拜访的是同一个接口办法

  • 也就导致了【接口防刷】失效

  • 接下来便是处理它,现在来说有两种

    • 不要运用途径参数

      • 这算是比较抱负的做法,相当于没这个问题
      • 但有一定局限性,有时分接手别的项目,或者自己底子没这个权限说不能运用途径参数
    • 替换uri

      • 咱们获取uri的意图,其实便是为了区别拜访接口
      • 而把uri替换成另一种能够区别拜访接口办法的标识即可
      • 最简单想到的便是经过反射获取到接口办法名称,运用接口办法名称替换成uri即可
      • 当然,其实不同的Controller中,其接口办法名称也有可能是相同的
      • 实践上能够再获取接口办法地点类类名,运用类名 + 办法名称替换uri即可
      • 实践处理方案有很多,看个人需求吧

实在ip获取

  • 在之前的代码中,咱们获取代码都是经过request.getRemoteAddr()获取的
  • 可是后续有了解到,如果说经过署理软件办法拜访的话,这样是获取不到来访者的实在ip
  • 至于怎么获取,后续我再研究下http再说,这儿先提个醒

总结

  • 说实话,挺有意思的
  • 一开始自己想【接口防刷】的时分,感觉也便是转化成计算下拜访次数的问题摆了
  • 后边到网上看别人的写法,又再自己给自己找点问题出来
  • 后边会衍生出来一推东西出来
  • 比如自定义注解+反射这种完成办法
  • 以前其实对注解 + 反射其实有点不太懂干嘛用的
  • 而从之前的数据报表导出,再到基本权限操控完成,最后到今日的【接口防刷】
  • 一点点来前进去弥补自己的常识点
  • 而且,感觉写博客真的是件挺有意义的工作
  • 它会让你去更深入的了解某个点,而且常识是相相关的,探究的过程中会牵扯到其他别的常识点
  • 就像之前的写的【单例形式】完成,一开始就了解到懒汉式,饿汉式
  • 后边深入的话就知道其实会还有序列化/反序列化,反射调用生成实例,方针克隆这几种办法回去损坏单例形式
  • 又是怎么处理的,这也是一个前进的点
  • 后续为了保证线程安全问题,牵扯到的synchronizedvoliate关键字
  • 继而又相关到JVMJUC,操作系统的东西
  • 哈哈

参考

  1. SpringBoot之接口防刷约束
  2. SpringBoot怎么防止接口恶意刷新和暴力恳求
  3. 一个注解搞定SpringBoot接口防刷