前言

随着Internet网的广泛应用,信息安全问题日益突出,系统间的接口交互,每个请求都有可能被抓取到数据、被伪造请求去获取数据或者攻击服务,所以接口安全至关重要。 API接口要做到:

  • 防伪装攻击:第三方恶意调用接口。
  • 防重放攻击:请求被截获后,被多次重放。
  • 防数据泄漏:被截获到系统数据,例如账号、密码、交易信息等。
  • 防篡改攻击:请求在传输过程被修改。

防伪装攻击

身份验证分为用户登录和开放平台.我们需要在每个接口的请求都要加上请求用户的信息,防止第三方恶意调用接口.

用户登录

用户使用用户名密码登录后服务器给客户端返回一个Token(通常是UUID),并将Token-UserId以键值对的形式存放在缓存服务器中。服务端接收到请求后进行Token验证,如果Token不存在,说明请求无效。Token是客户端访问服务端的凭证。

开放平台

为开发者分配AccessKey(开发者标识,确保唯一)和SecretKey(用于接口加密,确保不易被穷举,生成算法不易被猜测)。

防重放攻击

理论上,一个 API 接口请求被收到,服务会做校验,但是当一个合法请求被中间人拦截后,中间人原封不动得重复发送该请求一次或多次,这种重复利用合法请求进行得攻击被成为重放。

重放会造成服务器问题,所以我们需要针对重放做防重放。本质上就是如何区别去一次正常、合法的请求。

基于时间戳timestamp

理论上,客户端发起一次请求,到服务端接收到这个请求的时间,业界判定为不超过60秒。利用这个特征,客户端每次请求都加上 timestamp1,客户端将 timestamp1 和其他请求参数一起签名得到 signature,之后发送请求到服务器。

  • 服务器拿到当前时间戳 timestamp2,timestap2 - timestamp1 > 60s,则认为非法
  • 服务端接收到客户端请求,使用约定好的密钥对请求参数(不包括 signature、timestamp1)进行再次签名,得到值 autograph。比对 signature 和 autograph,若不相等则认为是一次非法请求

假如中间人拦截到请求,修改了 timestamp 或者其他的任何参数,但是不知道密钥,所以服务器依旧判定为非法请求。 中间人从抓包、篡改参数、发起请求的过程一般来说大于60秒,所以服务器依旧会判定为非法请求。

基于 timestamp 的设计缺陷也很明显,种种原因下,60秒内的请求,会钻规则漏洞,服务器判定为一次合法请求。

基于流水号nonce

既然时间戳会有漏洞,那么新方案是基于随机字符串 nonce。也就是说每次请求都加入一个随机字符串,然后将其他参数一起利用密钥加密得到签名 signature。服务端收到请求后

  • 先判断 nonce 参数是否能存在于某个集合中,如果存在则认为是非法请求;如果不存在,则将 nonce 添加到当前的集合中
  • 服务端将客户端请求参数(除 nonce)结合密钥加密得到 autograph,将 signature 和 autograph 比对,不相等则认为非法请求

但是该方案也有缺点,因为当次的请求都需要和集合中去搜索匹配,所以该集合不能太大,不然匹配算法特别耗时,接口性能降低。所以不得不定期删除部分 nonce 值。但是这样的情况下,被删除的 nonce 被利用为重放攻击,服务器判定为合法请求。

假设服务器只保存24小时内请求的 nonce,该存储仍旧是一笔不小的开销。

基于 timestamp + nonce 的方案

根据 timestamp 和 nonce 各自的特点:timestamp 无法解决60秒内的重放请求;nonce 存储和查找消耗较大。所以结合2者的特点,便有了 「timestamp + nonce 的防重放方案」。

  • 利用 timestamp 解决超过60秒被认为非法请求的问题
  • 利用 nonce 解决 timestamp 60秒内的漏网之鱼

步骤:

  1. 客户端将当前 timestamp1、随机字符串和其他请求参数,按照密钥,生成签名 signature
  2. 服务端收到请求,利用服务端密钥,将除 timestamp1、随机字符串之外的请求参数,加密生成签名 autograph
  3. 服务端对比 signature 和 autograph,不相等则认为非法请求
  4. 拿到服务端时间戳, timestamp2 - timestamp1 < 60,则判定为一次合法请求,然后保存 nonce
  5. 服务端只保存60秒内的 nonce,定时将集合内过期的 nonce 删除

该集合不应该直接操作文件或者数据库,否则服务端 IO 太多,造成性能瓶颈。可以是 mmap 或者其他内存到文件的读写机制。根据场景可以选择乐观锁、悲观锁。

其中有一个 timestamp 的问题,服务器会将请求参数中的 timestamp 判断差值,其中一个致命的缺点是服务器的时间和客户端的时间是存在时间差的,当然你也可以通过校验时间戳解决此问题。时间同步请继续看下面部分。

计算机网络时间同步技术原理

客户端和服务端的时间同步在很多场景下非常重要,举几个例子,这些场景都是经常发生的。

  • 一个商品秒杀系统。用户打开页面,浏览各个类目的商品,商品列表界面右侧和详情页都有倒计时秒杀功能。用户在详情页加购、下单、结算。发现弹出提示“商品库存不足,请购买同类其他品牌商品”
  • 一个答题系统,题目是该公司核心竞争力。所以有心的程序员为接口设计了「防重放」功能。但是前端小哥不给力,接口带过去的 timestamp 与服务器不在一个时区,差好几秒。别有用心的竞品公司的爬虫工程师发现了该漏洞,爬取了题目数据。

所以该现象在计算机领域有非常普遍,有解决方案。

如果精度要求不高的情况下:先请求服务器上的时间 ServerTime,然后记录下来,同时记录当前的时间 LocalTime1;需要获取当前的时间时,用最新的当前时间 (LocalTime2 - LocalTime1 + ServerTime)

拿 iOS 端举例:

  • App 启动后通过接口获取服务器时间 ServerTime,保存本地。并同时记录当前时间 LocalTime1
  • 需要使用服务器时间时,先拿到当前时间 LocalTime2 - LocalTime1 + ServerTime
  • 若获取服务器时间接口失败,则从缓存中拿到之前同步的结果(初始的时间在 App 打包阶段内置了)
  • 使用 NSSystemClockDidChangeNotification 监测系统时间发生改变,若变化则重新获取接口,进行时同步

如果需要精度更高,比如 100纳秒的情况,则需要使用 NTP(Network Time Protocol)网络时间协议、PTP (Precision Time Protocol)精确时间同步协议了。

防数据泄露

为了防止数据泄漏,我们需要将业务数据加密传输,同时签名里也有用到加密,这里就涉及到了加密算法。

RSA算法是公开密钥系统的代表,其安全性建立在具有大素数因子的合数,其因子分解困难这一法则之上的。Rijndael算法作为新一代的高级加密标准,运行时不需要计算机有非常高的处理能力和大的内存,操作可以很容易的抵御时间和空间的攻击,在不同的运行环境下始终能保持良好的性能。这使AES将安全,高效,性能,方便,灵活性集于一体,理应成为网络数据加密的首选。相比较,因为AES密钥的长度最长只有256比特,可以利用软件和硬件实现高速处理,而RSA算法需要进行大整数的乘幂和求模等多倍字长处理,处理速度明显慢于AES;所以AES算法加解密处理效率明显高于RSA算法。在密钥管理方面,因为AES算法要求在通信前对密钥进行秘密分配,解密的私钥必须通过网络传送至加密数据接收方,而RSA采用公钥加密,私钥解密(或私钥加密,公钥解密),加解密过程中不必网络传输保密的密钥;所以RSA算法密钥管理要明显优于AES算法。

从上面比较得知,由于RSA加解密速度慢,不适合大量数据文件加密,因此在网络中完全用公开密码体制传输机密信息是没有必要,也是不太现实的。AES加密速度很快,但是在网络传输过程中如何安全管理AES密钥是保证AES加密安全的重要环节。这样在传送机密信息的双方,如果使用AES对称密码体制对传输数据加密,同时使用RSA不对称密码体制来传送AES的密钥,就可以综合发挥AES和RSA的优点同时避免它们缺点来实现一种新的数据加密方案。加解密实现流程如图

具体过程是先由接收方创建RSA密钥对,接收方通过Internet发送RSA公钥到发送方,同时保存RSA私钥。而发送方创建AES密钥,并用该AES密钥加密待传送的明文数据,同时用接受的RSA公钥加密AES密钥,最后把用RSA公钥加密后的AES密钥同密文一起通过Internet传输发送到接收方。当接收方收到这个被加密的AES密钥和密文后,首先调用接收方保存的RSA私钥,并用该私钥解密加密的AES密钥,得到AES密钥。最后用该AES密钥解密密文得到明文。

方案

Android端

  1. 服务器端(server)分别生成自己的RSA密钥对,并提供接口给Android客户端获取RSA公钥(rsaPublicKey)
  2. client随机生成AES密钥(aesKey)
  3. client使用自己的AES密钥(aesKey)对转换为json格式的请求明文数据(data)进行加密,得到加密后的请求数据encryptData
  4. client使用server提供的接口获取RSA公钥(rsaPublicKey)
  5. client使用获取RSA公钥(rsaPublicKey)对AES密钥(aesKey)进行加密,得到encryptAesKey
  6. client将encryptAesKey作为http请求头参数,将加密后的请求数据encryptData作为请求体一起传输给服务器端

服务器端

  1. server响应client的http请求,读取http请求头。获得client传过来的加密后的AES密钥(encryptAesKey),读取http请求体,获得client传过来的加密后的请求数据(encryptData)。
  2. server使用自己的RSA私钥(rsaPrivateKey)对加密后的AES密钥(encryptAesKey)进行RSA解密,得到AES密钥(aesKey)
  3. 使用解密后的AES密钥(aesKey)对加密后的请求数据(encryptData),进行AES解密操作,得到解密后的请求数据(data),该数据为json格式
  4. 对解密后的请求数据(data)进行json解析,然后做相关的响应操作。 基本上如下图所示的流程:

防篡改攻击

HTTPS

数据在传输过程中是很容易被抓包的,如果直接传输,数据可以被任何人获取,所以必须对数据加密。加密方式有对称加密和非对称加密:

  • 对称加密:对称密钥在加密和解密的过程中使用的密钥是相同的,常见的对称加密算法有 DES、AES。优点是计算速度快,缺点是在数据传送前,发送方和接收方必须商定好密钥,并完好保存。如果一方的密钥被泄露,那么加密信息也就不安全了;
  • 非对称加密:服务端会生成一对密钥,私钥存放在服务器端,公钥可以发布给任何人使用。与对称加密相比,这种方式更安全,但速度慢太多了。

现在主流的做法是使用 HTTPS 协议,在 HTTP 和 TCP 之间添加一层加密层 (SSL 层),这一层负责数据的加密和解密。HTTPS 的实现方式结合了对称加密与非对称加密的优点,在安全和性能方面都比较突出。

API签名

如果黑客使用特殊手段让请求方设备使用了伪造的证书进行通信,那么https加密的内容也会被解密。

在API接口中我们除了使用https协议进行通信外,还需要有自己的一套加解密机制,对请求的参数进行保护,防止被篡改。

  1. API 签名保证的是应用的数据安全和防篡改,并且可以作为业务的参数校验和处理重放攻击。
  2. HTTPS 保证的是运输层的加密传输,但是无法防御重放攻击。

因此最安全的方法就是结合 HTTPS 和 API 签名。

过程如下:

  1. 客户端使用约定好的秘钥对传输的参数进行加密,得到签名值signature,并且将签名值也放入请求的参数中,发送请求给服务端
  2. 服务端接收到客户端的请求,然后使用约定好的秘钥对请求的参数(除了signature以外)再次进行签名,得到签名值autograph。
  3. 服务端比对signature和autograph的值,如果对比一致,认定为合法请求。如果对比不一致,说明参数被篡改,认定是合法请求。

因为黑客不知道签名的秘钥,所以即使截获到请求数据,对请求参数进行篡改,但是却无法对参数进行前面,无法得到修改后参数的签名值signature。

签名的秘钥我们可以使用很多方案,可以采用对称加密或者非对称加密

参数排序

将需要签名的内容根据参数名称进行排序,排序规则按照第一个字符的ASCII码值递增排序(字母升序排序),如果遇到相同字符则按照第二个字符的ASCII码递增排序,以此类推。将参数内容进行排序,可以保证签名、验签双方参数内容的一致性。

Body签名

Json排序编码

签名方以 Json 格式将参数内容发送给验签方,验签方需要将 Json 格式的参数内容反序列化为对象,由于验签方可能使用不同的编程语言,不同的 Json 框架,所以会导致双方的参数顺序不一致。

若是下面的参数,会拼接成:aello=world&bello=java

1
2
3
4
{
	"aello": "world",
	"bello": "java
}

但是若 value 是数组,或是一个对象时,该如何拼接呢?

1
2
3
4
5
6
{
	"aello": "world",
	"bello": {
    	"hi": "hi"
    }
}

需要递归进行每个子结构的字段排序.

Body编码

JSON 的无序,体现在 JSON Object 被 序列化 为 JSON String 的时候,无法保证不同的序列化方式序列化出的 JSON String 是一致的。

而 request body,本身就是一段已经被 序列化 好了的 JSON String,这段已经被 序列化 后的字符串,是可以确定不变的。

除非你的请求中间有被篡改过,否则,客户端发送的 request body,和服务端收到的 request body,应当是完全一致的字符串,HTTP 是不会把你的 “hello world” 变成 “world hello” 的。

签名的目的是保证 request 的 body 不被篡改,至于 body 的 content-type 是什么没有关系。

request body 就是一串纯文本,可以理解为一个 String,在被解析之前,它是固定不变的,完全不涉及到底是 json 还是 xml 还是 form 的问题。

直接对 request 的 body 进行签名就好了。

方案

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Signature = Base64(HMAC-SHA1( '您的 SecretKey', UTF-8-Encoding-Of( $StringToSign ) ));

StringToSign = $Verb + "\n" +
$Resource + "\n" +
$Query + "\n" +
$Content-Type + "\n" +
$Content-MD5 + "\n" +
$timestamp + "\n" +
$Nonce + "\n" +
$AccessKey;

上面公式中的\n表示换行转义字符,加号(+)表示字符串连接操作,其他各个部分定义如下所示。

  • Verb: 指 HTTP 请求方法如, GET, POST, PUT, DELETE
  • Resource: 指所访问的资源路径如, /your/resource/path
  • Query: 指请求中的 Query 参数, 其构成规则如下:
    • 对参数 key 进行升序排序
    • 对于所有参数以 key+value 方式串联
  • Content-Type: 请求 Header 中的 Content-Type 值
  • Content-MD5: 请求 Header 中的 Content-MD5 值, 等同于对 body 数据的 md5sum,.
  • Timestamp: 整型, 请求发生时的时间戳(用于防重放的,不需要可省略)
  • nonce 唯一随机串
  • AccessKey: 用户自己的 AccessKey

最后在 request header 中增加:

1
2
3
4
x-singnature: $Signature
x-accesskey: $AccessKey
x-date: $Date
x-nonce: $Nonce

对于部分无Body的HTTP请求,其CONTENT-MD5和CONTENT-TYPE两个域为空字符串,这时整个签名字符串的生成方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Signature = Base64(HMAC-SHA1( '您的 SecretKey', UTF-8-Encoding-Of( $StringToSign ) ) );

StringToSign = $Verb + "\n" +
$Resource + "\n" +
$Query + "\n" +
+ "\n" +
+ "\n" +
$Timestamp + "\n" +
$nonce + "\n" +
$AccessKey;

必须使用 Base64 编码 HMAC 请求签名。Base64 编码将签名转换为可附加到请求的简单 ASCII 字符串。如果要在 URI 中使用诸如加号 (+)、正斜杠 (/) 和等号 (=) 等可在签名中显示的字符,必须对它们进行编码。例如,如果身份验证代码包括一个加号 (+) 标志,请在请求中将其编码为 %2B。将正斜杠编码为 %2F,并将等号编码为 %3D。

参考

数据安全(反爬虫)之「防重放」策略

API接口安全方案

Android采用AES+RSA的加密机制对http请求进行加密

API 验证签名时,若 payload 的 json value 为复杂对象时该如何处理?

请求签名

签署和对 REST 请求进行身份验证