对于开放的api接口,会面临如下几个安全性问题:
- 请求来源(身份)是否合法?
- 请求参数是否被篡改?
- 请求是否唯一?
为了解决这几个问题,常见的一种做法是在接口中使用签名(Signature)认证机制。
过程
- 接口提供方给出AccessKey和SecretKey
- 调用方根据AccessKey和SecretKey以及请求参数,按照一定算法生成签名sign
- 接口提供方验证签名
接口调用方
接口调用方(客户端)生成签名的步骤大同小异,根据接口提供方指定的规则即可,比如下面这个步骤:
- 所有参数使用UTF-8编码
- 将所有业务请求参数按字母先后顺序排序
- 参数名称和参数值链接成一个字符串A
- 在字符串A的首尾加上SecretKey组成一个新字符串B
- 对字符串进行md5加密,并将加密后的byte数组进行BASE64编码,得到签名sign
假设请求的参数为:f=1,b=23,k=33,排序后为b=23,f=1,k=33,参数名和参数值链接后为b23f1k33,首尾加上SecretKey后md5和Base64:1
Base64(md5(SecretKeykey1value1key2value2...SecretKey))
PS:1)采用utf-8提前对参数进行编码,可以减少客户端编码差异带来的影响,因为服务端通常都是统一使用utf-8来做的。
2)最后还进行了BASE64编码是为了将将摘要内容全部转换为可显字符,并把字符也做为一个接口的参数,因为不可显字符(即md5加密后的byte数组)在网络传输中可能丢失。
上面的签名方法安全有效地解决了参数被篡改和身份验证的问题,如果参数被篡改,没事,因为别人无法知道SecretKey,也就无法重新生成新的sign。
如何解决请求的唯一性:timestamp+nonce方案
使用上面的签名方法可以保证来源及请求参数的合法性,但是请求链接一旦泄露,别人可以拿这个链接反复请求,所以还存在着被盗用二次请求的隐患。
常用的一种做法是在请求参数中带上一个时间戳timestamp,并且把时间戳也作为签名的一部分,在接口提供方对时间戳进行验证,只允许一定时间范围内的请求,例如1分钟。因为请求方和接口提供方的服务器可能存在一定的时间误差,建议时间戳误差在5分钟内比较合适。这样,如果别人没有及时拿到这个链接,就没法啊二次请求。允许的时间误差越大,链接的有效期就越长,请求唯一性的保证就越弱。所以需要在两者之间衡量。
如果要做到更安全,除了时间戳,还带上一个唯一地随机字符串nonce,比如说一个uuid,为每个请求提供一个唯一的标识符。接口提供方可以使用redis的过期机制,将这个nonce存在redis,如果timestamp是在有效期(比如5分钟)内,参数上的nonce已经有过请求记录,那么可以判断为二次请求,直接拒绝。
服务端
接口提供方需要做的过程如下:
- 拦截请求,获取所有参数,包括AccessKey,timestamp,nonce,sign等。
- 判断timestamp是否在有效范围。
- 根据AccessKey查询对应的SecretKey,用客户端相同的加密过程,得到sign,并验证是否与请求参数的sign一致。
- 判断nonce在timestamp的有效时间内是否重复,重复则拒绝请求,没有重复则记录当前nonce和timestamp。
示例代码
如下是一个生成sign的示例代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17private static String buildMD5Sign(Map<String, String> params, String appSecret) {
Map<String, String> paramsMap;
if (params instanceof TreeMap) {
paramsMap = params;
} else {
paramsMap = new TreeMap<>(params);//加入所有参数
}
//拼参数字符串
StringBuilder query = new StringBuilder();
for (Map.Entry<String, String> entry : paramsMap.entrySet()) {
query.append(entry.getKey()).append(entry.getValue());
}
//前后加上secretKey
String temp = appSecret + query.toString() + appSecret;
return DigestUtils.md5Hex(temp).toUpperCase();
}