前言
大部分情况下,我们使用已有的API签名方案(如腾讯云API签名、阿里云APi签名、亚马逊API签名等等)即可,无需从零开始设计一个API签名方案。写这篇文章的主要目的,是希望通过思考如何去设计一个可用API签名的过程,更好地理解现有的各种大同小异的签名方案背后的设计原理,从而更好地保护好我们的API接口。当然,有需要自己设计一个签名方案的场景也可参考一下。
1、API签名是什么
API签名可以理解为就是对API的调用进行签名保护。是在进行API调用时,加了一个调用者及其调用行为的指纹信息,以帮助服务端更好的识别用户及其调用行为的合法性。其直接目的归纳为:
(1)明确调用者的身份(确认调用者是谁)
(2)明确调用者的调用行为(确认调用者想要做什么)
而明确调用者的身份和调用行为后,可基于调用者的身份做到包括但不限于以下几点:
A:拒绝非法用户身份者的调用请求
B:拒绝越权使用者的调用请求,保护隐私
C:控制访问者的调用频率,保护服务
D:记录调用者的访问记录,以便追溯
......
由此可见,API签名的真正目的是:通过明确调用者的身份,以便控制API的访问权限,从而保护数据的安全性。
2、如何设计一个API签名
既然API签名的目的是:明确调用者的身份及其调用行为,那么我们进行设计时只有围绕这两点即可。
2.1、如何明确调用者
我们都知道,在程序的世界中,很难找到一个稳定且唯一的信息去标识一个调用者,因为调用者本身的信息(如IP、设备等)也是不固定的,所以,标识调用者最好的方法就是服务端统一分配:
2.1.1、用户身份标识
(1)调用者调用API前,必须向系统申请一个唯一的标识
(2)系统为每个调用者分配一个唯一的ID,这里暂定为SecretID
(3)调用者调用API时带上该SecretID
(4)服务端 通过SecretID确认调用者身份
以上流程的问题,在于SecretID是明文显示的,很容易被窃取和伪造;但SecretID又不能隐藏或加密,因为SecretID需要明确告诉服务端:我是谁?
所以,需要在SecretID之外,增加一个和SecretID绑定的信息,我们称之为:
2.1.2、用户密钥
用户密钥(即SecretKey)就是为了验证用户身份用的,为了提高其安全性能,必须保证
(1)调用者必须保护好SecretKey,不能在任何地方明文显示
(2)SecretKey最好不在请求过程中传输
至于,密钥如何分配、更换、失效、存储等密钥管理的内容不是本文重点,暂不深入。
那么,问题来了,有了密钥之后,如何验证用户的身份呢?这个就需要靠算法来解决了。
2.1.3、签名算法选择
在密码学中,有对称加密算法、非对称加密算法、 希运算消息认证码等等几种方案可以很好保护用户密钥的同时,验证用户的身份。那么,我们应该如何选择呢?
(1)首先排除的是非对称加密算法,理由是耗时长,性能差。
通过实测,非对接加密算法(RSA)相对加密算法(AES)和 希运算消息认证码算法(HmacSHA256)的加解密耗时要高2~3个数量级,对于一个服务端来说,性能也是很重要的考虑标准,故一般不选择非对称加密算法。
算法类型 | 加密耗时 | 解密耗时 |
---|---|---|
RSA | 380317 ns/op | 34427 ns/op |
AES | 885 ns/op | 938 ns/op |
HmacSHA256 | 1458 ns/op | 1458 ns/op |
(2)从以上结果看,Hmac和AES似乎都不错,而且AES更优。但考虑到签名的目的,除了明确用户身份外,还要明确调用者的调用行为;也就是说,为了需要保证整个请求的完整性,需要加密整个请求的所有关键内容,这时,Hmac算法的防伪造性(即修改一个字节,签名信息就完全不一样)的优势就突显出来了,在性能差不多的情况下,当然,选择Hmac算法了。
(3)Hmac支持的hash算法非常多,但一般不建议使用MD5和SHA1,因其有哈希长度扩展攻击(Hash Length Extension Attacks)的风险,故一般推荐使用HmacSHA256或HmacSHA512。
若服务端支持多种算法,则请求时,需明确带上使用的签名方法:SignatureMethod。
2.2、如何明确调用者的调用行为
方法很简单,那就是把调用行为涉及的关键信息都放到签名内容中进行签名。那么,哪些是关键信息呢?
2.2.1、请求的方法和接口
即每个请求Method和URL,这是每个请求都有的信息,且最为关键的信息。
2.2.2、请求的内容
请求内容一般指HEADERS、QueryString、BODY三大类。
那么,哪一类内容需要添加的签名内容中呢?
一个简单的判断标准,就是看这一块的内容的变更是否影响请求结果,若影响了,一般要求加入到签名内容中;若设计时还不确定,则全部内容加到签名内容中即可。
备注:实际上,一般是哪个字段有影响,添加哪个字段最简洁;但这样的话,服务端就非常麻烦,需要对每个API接口的每个字段分析,无论请求端还是服务端实现都特别麻烦且需要每个接口进行签名联调,不太现实。所以,一般是按大类进行的。
好了,到这里,API签名似乎已经完成了。
2.2.3、防重放
但是,对于部分请求来说,是有请求一次性要求,即同一请求内容一次和两次的结果是不一样的。这种情况下,恶意攻击者,截取一个合法请求后,不停地使用该请求对服务端进行攻击;这种攻击可能造成
(1)如果该请求是写请求,且服务端逻辑允许重复(如A向B转1元),则会造成严重后果。
(2)如果该请求是非常耗时操作,则可能造成服务性能下降。
(3)如果是普通读请求,看似无害,实则量大也是对后端服务性能的一种消化。
所以,在API签名这里,需要进行防重放设计,可以为后端其他服务减少压力。实现的方法,也很简单,那就是调用者每次调用时:
A:调用者生成并带上一个随机数Nonce
B:服务端该随机数是否已出现,有则拒绝,无则存储该随机数并放过请求
这里服务端要保证Nonce唯一,就得存储已经用过的Nonce,但长期保持会带来两个问题
(1)存储成本增加,日积月累,这里要存储的Nonce会越来越多,需要的存储空间就越大
(2)碰撞概率增加,正常服务被拒绝概率增大;这里随着生成Nonce值越来越多,碰撞的概率一定越来越大,若通过增加Nonce值的长度,有增加存储成本。
那么,另一个可行的办法,就是调用者每次请求时带上当前请求时间点Timestamp,然后由服务端限制请求的时效性。
2.2.4、请求的时效性
即某个请求,其请求时间戳Timestamp,和服务端的当前时间在规定时间内(如1分钟内)则为合法请求,反之,则视为无效请求。
如此,上面提到的Nonce值存储成本可能比较大的问题,在结合Timestamp后,可大大降低存储成本,如Timestamp=1min,则仅需存储1min内的请求Nonce值即可,大大减少存储的量级。
2.2.5、版本控制
另外,每个设计都很难做到完美,或者当前看已经比较完善,但随着技术的发展,会逐渐的暴露一些缺陷,此时,想做一个可持续发展的API签名方案,版本迭代自然少不了,所以,请求内容也可加上版本信息。
3、API签名方案实现
3.1、客户端流程
(1)生成随机数Nonce
(2)拼接签名内容,生成签名信息
(3)调用API时,带上签名信息
3.2、服务端流程
(1)校验时间有效性Timestamp
(2)校验Nonce唯一性
(3)提取SecretId和签名信息
(4)根据SecretId提取用户密钥SecretKey,用于生产签名
(5)拼接签名内容,生成签名
(6)校验请求签名和服务端生产签名是否一致
3.3、签名生产流程
几点声明:
(1)以下示例签名信息可放在QueryString中,但签名信息也可放在包头中
(2)为简化流程,以下部分暂不考虑包头签名,原理相通,实现时,自己加上即可
(3)这里的签名算法,指定为HmacSHA256
(4)拼接规则是多样化的,这里的各种拼接规则仅供参考
生成签名串的大致过程如下:
假设调用者已经有了SecretID 和 SecretKey 分别是:
代码语言:javascript复制secretId: "SKIDz8krbsJ5yKBZQpn74WFkmLPx3EXAMPLE"
secretKey: "Gu5t9xGARNpq86cd98joQYCN3EXAMPLE"
3.3.1、确认是否做包体签名
有包体,则做包体签名,无包体,则不做包体签名。以下请求包体格式规定为JSON,这里的无需提取包体字段进行拼接,直接对整个包体内容进行签名即可,但对包体字段到顺序有要求。
假设本次要调用API接口名称为:GetLibTypeList,POST方法,请求包体格式为JSON,请求包体字段有PageIndex和PageSize。
(1)请求JSON包体转换为字符串
假设,本次请求的Json包结构,如下所示
代码语言:javascript复制{
"PageIndex":0,
"PageSize":10
}
先将json包结构体进行json序列化成byte, 再将byte转换为字符串,最终得到的包体签名原字符串如下所示:
代码语言:javascript复制{"PageIndex":0,"PageSize":10}
(2)生成包体签名串
首先使用签名算法HmacSHA256对上一步中获得的 包体原文字符串 进行签名,然后将生成的签名串使用 Base64 进行编码,即可获得的包体签名串。
代码语言:javascript复制UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=
(3)签名串编码
生成的签名串并不能直接作为请求参数,需要对其进行 URL 编码。 如上一步使用HmacSHA256 生成的签名串为UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=
,则其编码后为
UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=
因此,最终得到的签名串请求参数 (Signature) 为:UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=
,它将用于生成最终的请求URL。
3.3.2、拼接请求字符串
请求参数主要有:
参数名称 | 类型 | 示例值 |
---|---|---|
Version | 固定为:20191001 | 20191001 |
SecretId | 密钥ID | SKIDz8krbsJ5yKBZQpn74WFkmLPx3EXAMPLE |
Timestamp | 当前时间戳 | 1569490800 |
Nonce | 随机正整数 | 3557156860265374221 |
SignatureMethod | 签名方式 | HmacSHA256或HmacSHA1 |
HashedRequestPayload | 包体签名字符串 | UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s= |
根据上述参数,使用HmacSHA256签名方式拼接的请求字符串如下:
代码语言:javascript复制Version=20191001&SecretId=SKIDz8krbsJ5yKBZQpn74WFkmLPx3EXAMPLE&Timestamp=1569490800&Nonce=3557156860265374221&SignatureMethod=HmacSHA256&HashedRequestPayload=UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=
备注:当无需包体签名时,则不拼接HashedRequestPayload即可
3.3.3、拼接签名原字符串
这里规定签名原文字符串的拼接规则为:
请求方法 请求主机 请求路径 ? 请求字符串
参数构成说明:
- 请求方法: 即 POST 、GET等方法, 为保证签名结果一致,一般需规定注意方法为全大写。
- 请求主机:即主机域名,此处是本地测试,则使用:
localhost:8008
,具体请以实际请求的域名为准。 - 请求路径: 即API 的请求路径,本例中请求路径为
/GetLibTypeList
。 - 请求字符串: 即上一步生成的请求字符串。
使用 HmacSHA256签名方式拼接的签名原字符串如下:
代码语言:javascript复制POSTlocalhost:8008/GetLibTypeList?Version=20191001&SecretId=SKIDz8krbsJ5yKBZQpn74WFkmLPx3EXAMPLE&Timestamp=1569490800&Nonce=3557156860265374221&SignatureMethod=HmacSHA256&HashedRequestPayload=UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=
3.3.4、生成签名串
使用签名算法HmacSHA256对上一步中获得的 签名原文字符串 进行签名,然后将生成的签名串使用 Base64 进行编码,即可获得请求签名串如下所示:
代码语言:javascript复制 ysXvBSshSbHOsCX2zWBE1tapVs68hi5GLdcQtwBUNk=
生成的签名串并不能直接作为请求参数,需要对其进行 URL 编码,编码后的签名串如下所示:
代码语言:javascript复制+ysXvBSshSbHOsCX2zWBE1tapVs68hi5GLdcQtwBUNk=
3.3.5、将签名信息添加到请求参数中
使用 HmacSHA256签名方式,发送的POST请求URL如下所示:
代码语言:javascript复制http://localhost:8008/GetLibTypeList?Version=20191001&SecretId=SKIDz8krbsJ5yKBZQpn74WFkmLPx3EXAMPLE&Timestamp=1569490800&Nonce=3557156860265374221&SignatureMethod=HmacSHA256&HashedRequestPayload=UodgxU3P77iThrEJtsiHi2kjYJmNA2jGEgYNnMD/X0s=&Signature
=+ysXvBSshSbHOsCX2zWBE1tapVs68hi5GLdcQtwBUNk=
注意:
(1)发送的请求URL各个参数无排序要求,但其顺序必须和签名原字符串的顺序保持一致
(1)需规定签名信息Signature必须作为最后一个参数,拼接在最后面,以便截取
(2)所有请求参数的参数值均需要做 URL 编码
需要注意的是,部分语言库会自动对 URL 进行编码,重复编码会导致签名校验失败。
4、代码实现
Go语言版实现代码可参考:https://github.com/esonlin/signature (含服务端代码和客户端demo)