前端请求加签名及后端验签流程剖析

https可以保证信道安全,但是不能保证数据源安全,于是对请求头加签名的方案应运而生。

为什么网络请求需要添加签名?

接口中使用签名机制,其实都是为了防止发送的信息被串改,发送方通过将一些字段要素按一定的规则排序后,再转化成字符串(包含仅前后端知道的秘钥),通过MD5加密机制发送,当接收方接受到请求后需要验证该信息是否被篡改过,也需要将对应的字段按照同样的规则生成验签sign,然后在于接收到的进行比对,可以发现信息是否被串改过(仔细想想还是仿君子不防小人,毕竟前端代码都是暴露在网络上的,别有用心的人总有办法在前端海量代码里找出秘钥和加密方式)。

签名流程

签名规则

  1、线下分配appid和appsecret(仅内部系统简单使用可不用数据库管理,添加到配置文件即可),针对不同的调用方分配不同的appid和appsecret

  2、加入timestamp(时间戳),10分钟内数据有效

  3、加入流水号nonce(防止重复提交),至少为10位。针对查询接口,流水号只用于日志落地,便于后期日志核查。 针对办理类接口需校验流水号在有效期内的唯一性,以避免重复请求。(如果没有防止重复提交的需求此字段后端可以不做处理该逻辑,仅认为signature生成的一部分即可)

  4、加入signature,所有数据的签名信息。

签名生成

signature是有系统参数+业务参数(业务参数根据系统需要,非必须)+appsecrect共同组成,并通过MD5(32)加密生成。

对除签名signature外所有参数(系统参数+业务参数)生成键值对(按照key的ASCLL排序),最终得到:key=value&key=value……&key=value&appsecret=appsecret值(按照key的ascll顺序拼接,字段连接形式按照后端方式定义)。

将上一步顺序拼接好参数和值的字符串进行md5即可得到最的signature

参数列表

字段类型必传说明
appidString后台分发的appid
timestampString当前时间戳
nonceString流水号(后端可用于校验是否重复提交)
signatureString接口签名,用户接口验证
nameString业务参数
ageInteger业务参数

实例

系统参数
1
2
3
appid 	  = 142803973
timestamp = 1682041538659
nonce = d89eba6440a14515990aedd69ade61a7
业务参数
1
2
name = "uyynot"
age = 18
appSecret
1
appsecret = 2dffc82386a84ff18256c6d2949e58d1b4095385552d4a8084660fb06ff39ee7
生成源串

根据字段名ascall排序拼接键值,这个源串拼接方式不一定非得以query参数形式去拼接,实际情况按后端提供的规则拼接即可,我这里只是示例演示

1
'age=18&appid=142803973&name=uyynot&nonce=d89eba6440a14515990aedd69ade61a7&timestamp=1682041538659'
拼接appSecret
1
'age=18&appid=142803973&name=uyynot&nonce=d89eba6440a14515990aedd69ade61a7&timestamp=1682041538659&appsecret=2dffc82386a84ff18256c6d2949e58d1b4095385552d4a8084660fb06ff39ee7'
生成signature

将上述拼接好的源串进行md5加密即可

1
signature = md5('age=18&appid=142803973&name=uyynot&nonce=d89eba6440a14515990aedd69ade61a7&timestamp=1682041538659&appsecret=2dffc82386a84ff18256c6d2949e58d1b4095385552d4a8084660fb06ff39ee7')

客户端请求

作为一个后端,模拟客户端我这里直接采用的第三方接口测试工具apipost,说明一下,该工具没有给我一分广告费,纯属个人觉得好用。

其他几个参数都比较简单,唯一麻烦点的就是生成signature。apipost支持预执行脚本,所以我这里的timestamp和signature我是通过脚本动态写入请求头的,nonce我暂时以一个固定的字符串去模拟,另外为了简化测试后端校验签名只通过系统参数,业务参数实际应用中可以加进来。

默认header

先添加两个固定参数,timestamp和signature会通过预执行脚本动态添加到header

预执行脚本

我们将预执行的脚本写入到这里,代码如下:

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
// 获取当前时间戳
let timestamp = Date.now();

// 生成签名
function _create_signature(){
// 从请求头中获取固定参数appid和nonce
_params = {
'appid': request.headers['appid'],
'nonce': request.headers['nonce']
}
// 排序参数
order_params = {}
Object.keys(_params)?.sort().forEach(key=>{
// 有值则加入排序字符串
if(_params[key]){
order_params[key]=_params[key]
}
})
// 拼接为query_params
query_params = ""
for(let k in order_params){
query_params += `${k}=${order_params[k]}&`
}
// 最后拼接secret
let appsecret = '2dffc82386a84ff18256c6d2949e58d1b4095385552d4a8084660fb06ff39ee7'
query_params += `appsecret=${secret}`
console.log("query", query_params)
// Md5(apipost内置了md5,所以可直接使用)生成signature
return $.md5(query_params);

}
let x_signature = _create_signature()

// 将动态获取的timestamp和signature添加到请求头
apt.setRequestHeader("timestamp", x_timestamp);
apt.setRequestHeader("signature", x_signature);

发送请求

点击发送按钮即可将请求发送至后端

服务器校验

服务端我这边采用的是python,可以以在中间件里拦截要校验的接口请求,流程其实跟客户端一样的,根据参数生成签名,与客户端传来的签名做校对即可,代码直接如下:

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
71
72
73
74
75
76
77
78
79
def create_sign(params: typing.Dict[str, str], filter_null: bool = False) -> str:
"""
生成签名参数字符串
- 并按照参数名ASCII字典序排序
- filter_null 过滤掉字段为空时 不加入排序

:param typing.Dict[str, str] params: 需参数名 ASCII 字典序排序 的 map
:param bool filter_null: 是否需要过滤字段值为空的不计入签名计算

:returns: 根据params得到排序后的签名字符串
:rtype: str

"""

order_params = OrderedDict()
for key in sorted(params.keys()):
if filter_null:
if params[key]:
order_params.update({key: params[key]})
else:
order_params.update({key: params[key]})

# params_str = "&".join(["{}={}".format(quote(key), quote(str(vl))) for key, vl in order_params.items()])
params_str = "&".join([f"{key}={vl}" for key, vl in order_params.items()])
return params_str

def validate_params(appid: str,
app_secret: str,
nonce: str,
timestamp: int,
signature: str,
threshold: int = 3 * 60 * 1000
) -> bool:
"""校验参数签名

:param str appid: 签名用户信息主体字段键名,由 self.key_name 控制
:param str app_secret: 签名参与计算的 密钥,不会参与网络请求明文传输
:param str nonce: 签名参与计算的 随机字符串
:param int timestamp: 签名参与计算的 13位时间戳 精确到ms
:param str signature: sign签名数据值,由其他签名参数计算生成
:param int threshold: 解析计算入参 时间戳 timestamp 的 有效阈值,默认3分钟的有效期时延,即 请求后该签名在三分钟内才有效

:returns: 是否通过签名校验
:retype: bool

"""

# 1. build params
_params = {
'appid': appid,
'timestamp': f"{timestamp}",
'nonce': nonce
}

sub_timestamp = int(time.time() * 1000) - timestamp

# 验证请求有效时间
val_timestamp_result = (0 <= sub_timestamp <= threshold)
if not val_timestamp_result:
return False

# 2. create sign
_sign = create_sign(_params)

# 3. create signature
_signature = hashlib.md5(f"{_sign}&appsecret={app_secret}".encode('utf-8')).hexdigest().lower()
_params['signature'] = _signature
# 校验签名
b = (_signature == signature)
return b

if __name__ == '__main__':
appid, nonce, timestamp, signature = request.META.get("appid", ""), request.META.get("nonce", ""), request.META.get("timestamp", ""), request.META.get("signature", "")
# 判断系统参数是否完整
if not any([appid, nonce, timestamp, signature]):
raise AuthenticationFailed(_('Invalid token params.'))
# 校验参数
if not validate_params(appid, "2dffc82386a84ff18256c6d2949e58d1b4095385552d4a8084660fb06ff39ee7", nonce, timestamp, signature):
raise AuthenticationFailed(_('Invalid token .'))

以上便是整个流程及前后端的实现