一种优雅的方式整合限流、幂等、防盗刷

大家在工作中肯定遇到过接口被人狂刷的经历,就算没有经历过,在接口开发的过程中,我们也需要对那些容易被刷的接口或者和会消耗公司金钱相关的接口增加防盗刷功能。例如,发送短信接口以及发送邮件等接口,我看了国内很多产品的短信登录接口,基本上都是做了防盗刷,如果不做的话,一夜之间,也许公司都赔完了┭┮﹏┭┮。

假设我们正在开发一个发送短信(仅国内)的接口,过程如下

  1. 接口定义为/sendSms
  2. 请求参数只有phone
  3. 在处理请求时,我们对请求参数phone进行了合法性校验
  4. 如果手机号合法,那么调用腾讯云等服务商的发送短信Api,向目标手机号发送短信
  5. 流程结束

上面便是一个最简单的向手机号发送短信验证码的接口,不考虑其他和业务相关的操作。我们现在来分析一下,该接口存在的问题(刷接口)。

  1. 只对请求参数中的手机号进行合法性校验(11位手机号),并没有对手机号是否为空号进行验证,会导致别人构造大量合法但是是空号的手机号
  2. 没有增加单个手机号,每天最大发送次数
  3. 没有控制每个手机号发送间隔,会导致同一时间,向相同手机号发送大量短信

既然我们知道了发送短信验证码接口存在的缺陷,那我们将这些问题一一解决了,是不是就可以避免接口被盗刷呢?答案是只能在一定程度上防止被盗刷,因为这些恶意请求中,手机号都是通过程序无限生成的,都能通过我们的正则校验,所以对手机号进行发送次数和发送间隔限制,对他们是没有任何效果的。另外,想要避免向空号手机号发送短信的话,还需要额外的引入第三方的空号检验Api,增加了新的资源消耗。

我们现在从发送短信验证码的接口转移到其他的接口来看看,寻找一种能够应用于所有的接口,并能实现限流,幂等,防盗刷功能的方案。

公众号: 后端随笔

个人博客:https://knowledge.xcye.xyz/

解决接口请求参数容易被构造

我们其实不难发现,导致接口被盗刷的根本原因在于请求参数很容易通过算法构造构造出来,这些通过程序生成的参数,在我们的程序看来,都是合法的。

{
"phone": "11位手机号"
}

通过上面两个对比,我们不难发现,先对于只有一个参数phone的发送短信接口来说,想要构造出淘宝发送短信的参数,难度直接上升了很多个阶梯。

我们从解决接口请求参数容易被构造的角度出发,我目前能想到的只有对请求参数进行加密,使用非对称加密的方式。具体的思路为,客户端在发送请求之前,使用服务端提供的公钥对请求参数进行加密,让请求参数看上去不那么容易被构造出来。服务端获取到请求参数后,使用私钥进行解密,然后再进行后续的一些验证操作。

那么这样可以防止接口被盗刷么?答案是,只能防君子,不能防小人。特别是对于Web端来说,如果发起盗刷的这个人,同样是一个开发者,他直接F12就可以从js文件中找到公钥。对于App来说,获取源码的方式会更难一点,但是最终公钥应该还是能够被找到的。

如果我们解决公钥容易被获取的问题,是不是可以通过这种方式防止接口被盗刷呢?如果能够解决公钥容易被获取的问题,在一定程度上,确实是可以解决接口被盗刷的问题,但是现在又将问题转移到了获取公钥接口上,我们还是需要解决获取公钥接口被盗刷的问题。

而且如果获取到的公钥不能存在时效性,可以被多次使用,那么这些通过加密实现防盗刷的接口,在公钥被泄露的情况下,还是会存在被盗刷的问题。想要解决的话,可以让公钥只能使用一次,或者只能在很短时间内使用,再者只能被多少个请求使用。我最终的解决方案也是类似于这个,让令牌只能使用一次。

而且使用公钥进行加密,通常是防止在请求过程中发生的中间人攻击,是为了解决参数被修改以及泄露的问题。

Ticket机制

我最终并不是通过解决参数容易被构造来防止盗刷的,我是通过对请求进行是否是机器人判断,如果是非法请求,强制必须先通过图形验证码,只有合法的请求,服务端才会进行处理。

我基于Ticket机制,客户端在发送请求之前,必须先向服务端申请一个Ticket。服务端在处理申请Ticket请求时,对请求进行判断,判断包含了是否是恶意请求和是否需要进行限流。当这两步都通过后,服务端会生成一个被加密,存在时效性并且只能使用一次的Ticket,客户端发送真正请求时,需要携带这个Ticket。每个Ticket只能被使用一次,而且客户端每次都携带Ticket,还可以通过Ticket实现请求的幂等性。

这种方案并不和任何的接口耦合,Ticket是携带在请求头上,不会对请求参数造成污染。

申请Ticket

我最终是使用Ticket完成了限流,防盗刷,幂等性这三个功能,为了让这个功能更加的通用,不和任何的接口相耦合。在申请Ticket时,客户端需要传递两个参数,分别是serviceType和primaryKey。serviceType用于控制该接口的类型,而primaryKey会被用于限流。最终结合配置中心,做到了能够轻松的对任何类型的请求进行独立的限流,UserAgent黑名单与白名单,Ip限流等操作。

具体的执行过程为(以发送短信验证码为例):

  1. 客户端调用接口申请Ticket,传递的参数为{serviceType: sms, primaryKey: 用户手机号}

  2. 服务端对客户端请求进行验证

    1. UserAgent是否在黑名单中(恶意请求的UserAgent基本上都是同一个),UserAgent还可以有很多的玩法,比如类似于Ip一样,对UserAgent进行限流(会影响一部分正常用户)
    2. 从请求头中对用户身份进行初步识别。可以和客户端协商好,在一些请求头值上做文章,帮助服务端识别请求者身份
    3. 对IP进行识别。很多的恶意请求都来自于不同的Ip,有部分来自同一个网段,我们可以对Ip结合serviceType进行限制。
  3. 如果服务端识别请求是恶意请求,则在响应体中将captchaStatus设置为true,表示需要客户端进行图形验证码验证

  4. 下一步,服务端通过serviceType,从配置中获取限流规则。通过serviceType+primaryKey作为key,看是否能通过指定的限流。

  5. 通过限流后,服务端使用对称加密对{captchaStatus, primaryKey}进行加密,得到Ticket。这一步的目的是为了在最终验证Ticket时,从解密的数据中获取captchaStatus,避免captchaStatus是由客户端传递,从而解决请求绕过图形验证码验证问题,客户端根据captchaStatus判断该Ticket是否需要用户通过图形验证码,才能执行后续操作。

  6. 服务端将Ticket放入Redis,并且设置过期时间,然后将{ticket, captchaStatus}返回给客户端。

服务端返回的Ticket是加密后的密文,存在过期时间,保存在Redis中,并且只能被使用一次,无法被客户端构造出来。尽管加密算法被不小心泄露,服务端也无法从Redis中查询到这个"合法的Ticket",所以这个Ticket是足够安全的。

图形验证码

调用申请Ticket接口后,响应参数中包含两个参数:captchaStatus, ticket。captchaStatus表示该Ticket是否需要客户端通过图形验证码。

当captchaStatus为true时,客户端调用另一个接口加载图形验证码,在调用接口时,需要携带上一步获得的Ticket,服务端最终会将本次的图形验证码和Ticket进行绑定,最终实现在下一步中通过Ticket获取图形验证码的验证结果,具体步骤为:

  1. 客户端携带申请到的Ticket加载图形验证码数据
  2. 服务端从请求头中获取Ticket,从Db中查询该Ticket加载过几次图形验证码,如果超过最大加载次数,那么直接通知客户端重新申请新Ticket,并且删除和旧Ticket相关的数据。
  3. 验证通过后,生成图形验证码数据,得到该图形验证码的key,然后将key和ticket放入Db中存储起来,目的是为了保存图形验证码验证结果
  4. 客户端接收到图形验证码数据并加载

在防盗刷功能中,最有效的还得是验证码功能

服务端验证Ticket

当客户端完成上面两个后,客户端现在才开始调用真正的接口(发送短信)。在调用发送短信验证码时,客户端需要携带申请到的Ticket和图形验证码Key(如果captchaStatus为true)。

服务端接收到请求后,具体的处理步骤如下:

  1. 从请求中获取Ticket,并且对Ticket进行解密,从Redis中查询该Ticket是否存在

    尽管我们的防盗刷逻辑被人知晓,他们也不能随意的构造Ticket

  2. 从解密后的数据中获取captchaStatus字段的值,如果为true,则表示该Ticket需要执行图形验证码验证。服务端从DB中查询和该Ticket最后一次绑定的图形验证码Key的结果,如果没有进行验证或者结果为失败的话,直接结束流程

  3. 对Ticket进行幂等性验证,主要是通过判断该Ticket之前是否被使用过,如果上一个请求已经完成,那么直接从Redis中获取执行结果,并返回

  4. 当上面都没有问题后,现在才开始执行最终的业务逻辑,这里是执行发送短信验证码。因为这个功能并不和任何的接口耦合,如果我们需要更细的防盗刷,还可以在具体的接口里面做文章。

  5. 执行完毕后,需要把Ticket相关的数据都删除。

上面便是我实现接口防盗刷的具体过程,现在我们来验证一下,这个防盗刷是否真的能防(还是以发送短信验证码)?

  1. 构造大量合法但空号的手机号

    每次请求时,都需要先申请Ticket,primaryKey为手机号。因为这些恶意请求的UserAgent是相同的,如果我们预先接收到报警并且将UserAgent放入黑名单中,这些请求会直接被拦截。

    就算UserAgent每被拦截,还有Ip等其他的限流措施。如果都通过,我们还可以直接强制要求每一个请求都进行图形验证码验证,因为图形验证码的破解难度更高,基本上已经劝退很多人了,强制进行图形验证码验证,对于正常用户来说,也只会降低使用体验。

    对于手机号为空号来说,如果这个用户确实通过了上面这些措施,那么基本上可以保证他是一个真实用户,所以手机号是否为空号验证,我觉得是多此一举,除非发送短信的资源真的非常宝贵。

  2. Ticket被泄露,被伪造

    在公司没出内鬼的情况下,Ticket是不可用被伪造出来的,并且就算被伪造出来,这个Ticket也没有保存至Db。如果该Ticket的captchaStatus为false并且被泄露了,他们也只能在指定时间内使用该Ticket,并且只能使用一次。不可能会存在Ticket无限泄露的情况。

在上面的过程中,服务端验证请求是否是机器人,还可以在发送真正请求时进行验证,如果验证失败,客户端根据响应体执行对应的操作,然后携带Ticket重发请求。

上面的逻辑并没有对正常用户的验证结果进行缓存,这会导致,正常用户在调用这些接口时,每调用一次,都需要通过图形验证码。

其他措施

还有其他的措施,也可以增加接口被盗刷的情况。这些措施包括增加防盗刷逻辑被破解难度和防止接口被盗刷。

先说防止接口被盗刷,本质上是防止接口被泄露。对于App来说,某个人想要知道我们接口信息的话,必须对App进行反编译,我对App反编译不太了解,可以试试那些增大反编译的措施,就算不进行反编译,使用Fiddler工具也是可以看到请求信息的。对于Web端来说,用户只需要按F12就可以看到JavaScript代码,以及每个请求的参数,响应体等。我们可以禁用F12以及右键(降低用户体验),以及在生产环境中,添加当用户按F12后,自动进入无限Debug模式。这两个操作可以增加我们接口被暴露的风险,从而在一定程度上起到"防盗刷"目的。

对于增加防盗刷逻辑被破解难度来说,市场上有很多的App的限流等规则都被人攻破了,我个人觉得会被攻破,除了接口设计的原因外,还有一个是接口的响应体中提示了很明显的错误信息。比如我们访问某个增加了防刷功能的接口,该接口提示UserAgent无效,当前Ip已被限流,Ticket无效,未进行图形验证码验证等很明显的信息。这些信息其实已经间接提示了让请求变合法的步骤是什么,这虽然可以帮助开发人员进行调试,但也间接的帮助了那些发送恶意请求的人。所以为了增大防盗刷逻辑被破解的难度,我们不需要返回这些很明显的提示信息,可以无论什么原因,都返回"非法请求",对于公司开发人员来说,他们自己通过code从开发文档中查询每个code所代表的意思。

以上便是我对于防止接口被盗刷的一些见解,可能还有更优的方案,但是我目前确实只能想到这一种。另外,也可以使用已有的服务,比如腾讯云和阿里云等服务商的验证码。我使用的图形验证码是开源的,来自于dromara大佬开源的Java 行文验证码,使用起来非常的方便,并且支持滑块,旋转,滑动,文字点选,非常感谢大佬。此外,因为每次请求时申请到的Ticket都是加密的,在加密和解密的过程中,性能消耗也是一个可以优化的点,具体得看自己选择的算法是什么。

posted @ 2024-09-01 13:59  xcye  阅读(738)  评论(0编辑  收藏  举报