接口签名与验签

很多项目经常会有很多对外开放的接口,这些接口暴露在外网容易被篡改或攻击,因此需要设计一些安全保护的方式来增强接口安全,在网络传输层可添加 SSL 证书,在应用层主要是通过一些加密逻辑来实现,本文主要讲解接口签名的实现方式,借鉴了阿里的摘要签名认证方式,也是稍微复杂一点的方案。

客户端生成签名

提取签名串

客户端需要从HTTP请求中提取出关键数据,组合成一个签名串,格式如下:

1
2
3
4
5
6
7
HTTPMethod
Accept
Content-MD5
Content-Type
User-Agent
Date
Query

以上7个字段构成整个签名串,并且须与服务端保持一致,字段之间使用\n间隔,大小写敏感。请求中的Content-MD5头的值,可为空。只有在请求存在Body且Body为非Form形式时才计算Content-MD5头。

计算签名

我用 apifox 模拟前端请求,使用它的前置脚本功能生成签名并插入到request.headers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var cryptoJs = require("crypto-js");
var uuid = require("uuid");

let secretKey = pm.globals.get('App-Secret')
let queryParams = pm.request.url.query
let headers = pm.request.headers
// 存放需要用来签名的参数
let params = [];
let contentType = headers.get("Content-Type") || "application/json;charset=utf-8";
let ua = headers.get("User-Agent")
let accept = "*/*";
let contentMd5 = "";
let ts = new Date().getTime()+"";
let dateGmt = new Date().toUTCString();
let uid = uuid.v4();
let querystring = ""
// 为了举例方便,真实请求中请使用body
let body = "123456"

let paramPair = []
let keys = Object.keys(queryParams)
keys.sort() // 参数名 ASCII 码从小到大排序(字典序)
queryParams.each(item => {
if (!item.disabled && item.value !== undefined && item.value !== '') {
paramPair.push(item.key+"="+encodeURIComponent(item.value));
}
});
querystring = paramPair.join("&")
if (body) {
contentMd5 = cryptoJs.MD5(body).toString()
}

params.push(pm.request.method);
params.push(accept);
params.push(contentMd5)
params.push(contentType)
params.push(ua)
params.push(dateGmt)
params.push(querystring)

// console.log(params)

headers.upsert({key: "Content-MD5", value: contentMd5});
headers.upsert({key: "Content-Type", value: contentType});
headers.upsert({key: "X-Ca-Timestamp", value: ts});
headers.upsert({key: "X-Ca-Nonce", value: uid });
headers.upsert({key: "Date", value: dateGmt });
// 生成hmac签名
let sign = cryptoJs.HmacSHA256(params.join('\n'), secretKey).toString()
headers.add({key: "X-Ca-Signature",value: sign})

这个软件功能还是挺强大的,可以为所有接口生成签名,在根目录设置里引用公共脚本即可。
脚本API可以参考帮助文档

传输签名

客户端需要将以下四个Header放在HTTP请求中传给服务端,进行签名校验:

  • X-Ca-Timestamp:请求的时间
  • X-Ca-Nonce:唯一标识符 uuid
  • x-ca-signature-method:签名算法,可选,默认为HmacSHA256;
  • X-Ca-Signature:签名

服务端验签

以Node框架eggjs为例,一般在中间件进行验签,先上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
'use strict'
const crypto = require('node:crypto')

// 在收到请求后会使用同样的方法计算签名,同客户端计算的签名进行比较,相同则验证通过
module.exports = () => {
return async function(ctx, next) {
const headers = ctx.headers
// 不超过10分钟
if (!headers['x-ca-nonce'] || !headers['x-ca-timestamp'] || new Date().getTime() - headers['x-ca-timestamp'] > 600) {
ctx.status = 401
return (ctx.body = {
code: 401,
msg: '非法访问',
})
}
const reqKey = `appname:${headers['x-ca-nonce']}`
const arr = [
ctx.method,
headers.accept,
headers['content-md5'] || '', // 可为空
headers['content-type'],
headers['user-agent'],
headers.date,
query2string(ctx.query),
]
const signature = genSignature(arr.join('\n'))

// 鉴别是不是来自客户端的请求
if (signature !== headers['x-ca-signature']) {
ctx.status = 401
return (ctx.body = {
code: 401,
msg: '无效签名',
})
}
const val = await ctx.app.redis.get(reqKey)
if (val) {
ctx.status = 401
return (ctx.body = {
code: 401,
msg: '非法访问',
})
}
// 通过则保存唯一标识至redis 表示已请求
ctx.app.redis.set(reqKey, 1, 'EX', 60 * 30)

await next()
}
}

function genSignature(arr) {
const hmac = crypto.createHmac('sha256', 'this is an App Secret')
hmac.update(arr.join('\n'))
return hmac.digest('hex')
}
// 格式化query
function query2string(query) {
if (!query) return ''
const keys = Object.keys(query)
keys.sort() // 参数名 ASCII 码从小到大排序(字典序)
const paramPair = []
for (let i = 0; i < keys.length; i++) {
if (query[keys[i]] === '') {
continue
}
paramPair.push(`${keys[i]}=${encodeURIComponent(query[keys[i]])}`)
}
return paramPair.join('&')
}

上面arr的参数顺序可以随机,join的连接符也可以自定义,但要注意有些平台如小程序处理Accept不一样,可以在客户端写死,验签通过后保存nounce到redis,设置ttl 30min,作用是防止短时间大量的恶意请求对服务端的压力。

加https后还有必要加签吗?

有:

  • 保证应用的数据安全和防篡改,并且可以阻止重放攻击。
  • https 只是在传输层加密了数据,但是可以重复大量的请求。

更多参考资料:
使用摘要签名认证方式调用API
Open API 中签名的使用
API接口设计:防参数篡改+防二次请求