使用openresty打造接口校验服务

正文共:   5665 字  3 图, 预计阅读时间:   15 分钟

温旭峰好文:

PHP7升级的性能提升

gRPCurl调试工具使用指南

PhpStorm使用技巧

kafka 基本介绍与常见问题

Go Module 在项目中的应用

Go语言开发规范(更新)

自定义监控阿里云RDS - Prometheus使用Golang自定义Exporter

Golang HTTP服务平滑重启及升级

为什么要做?

线上出现了刷接口行为,产生了数倍的非正常请求。下图是上线了接口校验服务前后某接口的变化:

我们先了解以下几个问题:

  • 为什么会有人刷接口?

    • 牟利

    • 恶意攻击竞争对手

    • 压测

    • 脚本小子

  • 怎么定义刷?

    • 频率高

    • 次数多

    • 难识别

  • 当我们遭遇了刷接口时我们该怎么做?

    初期我们采用了在负载均衡侧直接禁用ip的方式,有一定的效果;但随后刷接口的手段升级,采用ip池的方式后,禁用ip的方法就显得捉襟见肘了;这种时候有几种常见的处理方式:

    • 拒绝请求

    • 接口频率限制、动态限制ip

    • 人机验证

    • 请求信息校验

  • 为什么要处理刷接口?

    • 有效的减轻后端服务的压力

    • 可以提升整体服务的可用性

前端调用接口的安全问题

前端web调用接口与app调用的接口共用一套,但是针对于前端web的调用,没有加入sign的校验,也没有任何其他校验性的逻辑,这样就导致拿到接口后,可以随意传递参数,随意调用,缺乏安全性。

基于上述两点,我们需要上线一套基于现有逻辑的接口校验服务;来结束前端接口调用的裸奔状态,并期望可以把刷接口的请求屏蔽,减少不必要的计算,也避免信息外泄。

如何选?

我们在选择安全方案上需要考虑便捷性、可操作性的问题,如果是安全性比较高,没有可操作性的方案,那也只能选择放弃;上面我们说了几种常见的处理方式,我们结合项目实际逐条分析利弊。

拒绝请求

拒绝所有的接口请求,这是最有效、直接、简单、粗暴的处理方式。但是我们的被刷的 是入口功能, 不能采取这样的方式处理。

接口频率限制、动态限制IP

客户端请求的时候 , 把ip记录下来,每次访问这个ip访问次数加一,如果超过指定次数,把这个ip拉黑。这种方式存在以下几个问题:

  • 次数如何设定;这是一个需要不断尝试来确定的值,且还需要根据业务流量的变化来跟着变化

  • 分布式服务中复杂度高;单体服务可以采用本机缓存,如果是分布式服务的话,就需要采用redis或memcache这类分布式缓存组件来支撑,以保证限制的准确性及有效性

入口接口比较特殊,可能涉及到推广业务,所以这个频率并不好控制,也不能根绝过往经验拍脑袋决定;另外就是再启动一套缓存组件或限流的中间件来支撑,有一定的成本;我们更倾向于将问题的解决前置,而不是后置到服务中。

人机验证

人机验证的方式有很多:如短信验证码、图片验证码、12306的验证、Google reCAPTCHA

这类验证可以挡掉大部分攻击流量,也是较为常见的处理方式,也是登录注册常见的组件功能。由于历史原因,我们的前端项目并不是使用统一的组件、统一的类库来处理接口请求,无法做到更改一处,随着项目更新将人机验证的代码上线,就导致我们需要更改大量的项目才能配合我们完成这项工作,时间成本及风险比较高。

另外一个原因:一般情况下,客户端很少情况触发人机验证规则。如过每次都做验证的话,会影像到用户的使用体验,尤其是新用户的使用。

请求信息校验

这种方式是指对请求信息中的header信息或body信息做验证,合法的才允许访问到后端服务,不合法的就直接返回错误信息。常见的有User-Agent的验证、referer的验证、签名sign字段的验证、cookie的验证等

这种方式对调用的规范性有一定要求;如果部分业务未使用通用的规范,那就要兼容或舍弃这部分业务请求。这种方式仅服务端即可处理,不需要客户端或前端项目发布配置。

怎么做?

我们采用请求信息校验的方式进行拦截非法请求,将问题的解决前置到nginx服务中,将非法请求拦截在php-fpm之前。

openresty介绍

开发nginx的模块需要C语言,同时需要熟悉nginx源码,成本与门槛比较高,而openresty 把LuaJIT VM嵌入到nginx中,使得我们可以直接通过lua脚本在nginx上进行编程,同时openresty提供了大量的类库。所以我们采用了nginx+lua的方式进行接口校验服务的开发,用openresty替代nginx。

nginx采用master-worker模型,一个master进程管理多个worker进程,worker真正负责对客户端的请求处理。master仅负责一些全局初始化、对worker进行管理。在openresty中,每个worker中有一个Lua VM,当一个请求被分配到worker时,Lua创建一个coroutine来负责处理。

nginx把一个请求分成了很多阶段,第三方模块可以根据自己的行为,挂载到不同阶段进行处理以达到目的;以下是各个阶段的解释及使用范围:

阶段指令 使用范围 解释
init by lua*, init worker by_lua* http 初始化全局配置,预加载lua模块
set by lua* server, server if, location, location if 设置nginx变量,此处是阻塞的,lua代码要做到非常快
rewrite by lua* http, server, location, location if rewrite处理阶段;可以实现复杂的转发、重定向逻辑
access by lua* http, server, location, location if 请求访问处理阶段;用于访问控制
content by lua* location, location if 内容处理阶段;接收请求处理并输出响应
header filter by_lua* http, server, location, location if 设置header与cookie
body filter by_lua* http, server, location, location if 对响应数据进行过滤,比如截断、替换
log by lua* http, server, location log处理阶段;比如记录访问量、统计平均影响时间

处理流程

前端的核心思路是:用户请求.html文件访问页面时,文件内容中动态写入拦截XHR逻辑、设置标记并禁用缓存。在accessbylua_file中加入验证逻辑。

同时,还需要在nginx配置如下:

  • nginx.conf 主要是加载lua类库,并开启lua代码缓存

    user nobody nobody;
    worker_processes 4;
    
    # ....
    http {
      # ....
    
      lua_package_path "/path/to/lua/lib/?.lua;;";
      lua_code_cache on;
    
      # ....
    }
    
  • xxx.conf 业务配置,所有流程中的处理配置都在此文件中

    server {
      # ... listen index root 配置
    
      location ~ .*\.php?$ {
        access_by_lua_file /path/to/lua/access.lua;
        # ... fastcgi 配置
      }
    
      # ...其他配置
    
      # 接口转发
      location ~ / {
        rewrite ^/(.*) /$1/index.php last;
      }
    
      # 前端页面所有html页面统一处理
      location ~ / {
        header_filter_by_lua_file /path/to/lua/mark.lua;
        body_filter_by_lua_file /path/to/lua/script.lua;
      }
    
      # ...其他配置
    }

遇到了哪些问题?

1. 错误: attempt to set status 403

错误原文:attempt to set status 403 via ngx.exit after sending out the response status 200

ngx.say 会默认输出200的状态码

使用 ngx.say 之后如果整个流程中还有其他状态码的输出,就会输出这个错误;所以在调试阶段不建议使用 ngx.say ,而是使用 ngx.log 来打印日志调试代码

2. ngx.exit与ngx.eof区分

  • ngx.exit:当传入200(ngx.HTTPOK)或其他http状态码时,会中断当前请求,并将传入的状态码返回nginx,当传入0(ngx.OK)时,ngx.exit会中断当前执行的阶段,进而执行后续的阶段;但是不能屏蔽headerfilter_lua*的逻辑;推荐ngx.exit与return一起使用,目的在于增强请求被终止的语义

  • ngx.eof:只是结束响应流的输出,中断http链接,后面的代码逻辑依然会在服务端继续执行,ngx.eof有返回值,而ngx.exit没有

3. 前端页面地址使用不规范的问题

因为我们需要处理所有html文件的请求,用于设置token;但是前端页面访问的地址有时是xxx.html文件访问,有时是类似/#/?xxx的没有明确html文件的访问,那这种情况如何匹配到呢?这里就需要明确nginx变量中 $request_uri 与 $uri 的区别:

  • $request_uri 是客户端发送来的原生请求uri,包括参数,不可以进行修改

  • $uri 则是不包含任何参数,反映任何内部重定向或index模块所做的更改

所以我们在实现的时候需要使用 ngx.var.uri 来获取到内部重定向或index模块更改后的地址,这样就可以确认请求是否为html文件

4. response body被截断的问题

因为我们需要使用 body_filter_by_lua 向html文件的<head>标签内动态加入安全逻辑,这样的话就更改了返回包内容的长度,导致response header中的content-length变短,导致页面内容无法完全加载。所以我们需要更新content-length为正确的值,有两个方法:

  • 手动计算content-length,然后重新设置

  • 忽略content-length,使用流方式处理,这种情况下response header会返回 Transfer-Encoding:chunked ;忽略的方式在openresty可使用 ngx.header.content_length = nil 来实现

通常情况下,更建议使用成本更小的第二种方式处理被截断的问题

5. 浏览器缓存html文件

我们更新了html,让其加载拦截XHR请求;但是通常情况下,浏览器尤其是微信中会缓存html文件;在访问html文件时会传递 if-modified-since 的请求头,如果服务端判断到没有更新,则会返回304,不会重新加载html文件内容;这时就会导致接口请求异常。

在http请求与响应中;我们可以通过 Cache-Control 指令来实现缓存机制。关于可缓存性有以下几个取值:

  • public 可以被任何对象(请求客户端、代理服务器等)缓存

  • private 只能被单个用户缓存,代理服务器不能缓存

  • no-cache 在发布缓存副本前,强制要求缓存把请求提交给原始服务器进行验证,即协商缓存验证

  • no-store 不使用任何缓存,即不应存储有关客户端请求或服务器响应的任何内容

在服务端中设置response header中的cache-control;设置为no-cache或no-store,告诉客户端不要缓存html文件,可以解决此类问题,实现方式为 ngx.header.cache_control = "no-store"

6. 小程序的处理

需要注意小程序比较特殊,没有时机设置标记,也无法操作cookie。无法通过user-agent来拆出小程序的请求,因为小程序的user-agent也是不规范的,每个版本都可能不一样。

7. 内部调用的处理

其他服务也可能会用到线上提供的api接口,但是服务中调用不规范统一处理就不切实际,需要先改造之后才可以进行统一处理,不规范主要表现为:

  • 不区分内网与公网;服务中有使用内网SLB调用api的,也有通过公网SLB调用api的;由于服务会启动弹性伸缩,这样就导致无法通过调用接口的来源ip来判定内部调用

  • 黑魔法调用参数;通过一个特定的参数来标记内部服务调用需要的特殊处理逻辑

  • 调用没有统一处理;往往都是用到的时候自己写一个,没有一个统一的类库来处理,就导致处理时要全局搜索,影响面广,工作量大

针对上面的问题;我们提供了统一的调用类库 requests ;这个类库主要提供了api接口GET、POST请求的封装,同时提供了内外部访问地址构造的单例对象。通过这个类库,可以统一的处理内部调用的请求,使用统一地址,同一套规则,更改规则只需更改一处。

有哪些结论?

1. 规范的重要性

主要表现在以下几个方面:

  • 前端项目的规范

    • 访问路径混乱、不统一、 随意

    • 通用基础库没有统一管理,一个项目一套东西

上面两个方面更多的是历史遗留问题造成的,统一规范访问路径,提取通用基础库,不用每个项目都做重复性的工作。

  • 接口调用的规范

    上面 内部调用处理 详细说明了存在的问题以及处理方案: 对于通用性的功能,都采用抽象出统一处理对象来处理,不建议过程化的处理,这样会对后续的抽象带来非常大的开发及测试工作量。

  • api使用的规范

    由于使用的php框架是yaf,所以在调用接口时首字母大小写都是可以执行,这就导致采用什么方式的都有,没有一个明确 的规范。 给白名单处理时带来不必要的麻烦;在添加白名单时需要考虑到各种各样的情况,甚至穷举。

    范为: controller与module都使用小写,action使用首字母小写,其余的与action function name一致;如:

2. 安全工作

安全工作是一项长期的和攻击者斗智斗勇的工作。没有一劳永逸的解决方案,不断交锋,不断成长

拖地先生,从事互联网技术工作,在这里每周两篇文章,聊聊日常的实践和心得。往期推荐:

说说这个公众号

平均响应1000ms到200ms,PHP和Go那家强?

崩溃率从1%到0.02%,iOS稳定性解决之道

七招优化Android包体减少30%

技术产品职业瓶颈?29份腾讯通道材料教你成长

低头赶路,也别忘了抬头看天

加班能解决交付的期望么?

如果对你有帮助,让大家也看看呗~

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章