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

2020-01-16 22:15:15 +08:00
 LinJunzhu

提供 API 给客户端使用时,为了验证是客户端发起的请求,并且保证请求中不被用户恶意串改,我们一般会这么做:

客户端:

1、将请求参数按照 key 字典序排序,将所有 key=value 进行拼接 2、将 [ secret ] [ nonce ] [ timestamp ] 和上述的参数进行拼接

最终将这个字符串通过 sha1/或其他算法 生成一个 token

服务端:

接受到对应的参数时,根据同样的逻辑,生成 token,验证 token 是否一致。

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

{
	"aello": "world",
	"bello": "java
}

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

{
	"aello": "world",
	"bello": {
    	"hi": "hi"
    }
}

翻看了微信和支付宝的文档,两者的参数类型都是基本类型,无数组和对象,但是我们自己在实现过程中,偶尔难免会有复杂结构的 json 参数。

不知道大家是如何做的?

8927 次点击
所在节点    程序员
70 条回复
billlee
2020-01-16 22:36:50 +08:00
你不按 key=value 格式拼接,直接算 JSON 字符串的 digest 也行啊
LinJunzhu
2020-01-16 22:43:31 +08:00
@billlee Hash 对象的 key 是无序的, 同一个 json 文本, 在不同系统 /语言 解析成 对象后,再格式化为 json 文本,key 的位置是有可能发生变化的,所以这段 json 文本的 digest/md5 也是有可能不一样的。
shoaly
2020-01-16 22:57:14 +08:00
微信和支付宝的做法都一样, 他们最外层的结构依然是 一层关系的, 当 value 是数组或者对象的时候, 他们把 value 的内容转换成了 json 字符串
Vegetable
2020-01-16 23:05:11 +08:00
递归呗.
LinJunzhu
2020-01-16 23:11:29 +08:00
@shoaly 哈希对象的 key 是无顺序的,同一段 json 文本,在最终解析出来的顺序是可能不一致的;

若 value 为对象,那么客户端在生成 token 时,该 value 转换为 json 文本是:

"{\"a\":\"a\",\"b\":\"b\"}"

服务端在生成 token 时,该 value 转换为 json 文本可能是:

"{\"b\":\"b\",\"a\":\"a\"}"

所以最终验签会失败
also24
2020-01-16 23:12:39 +08:00
首先,JSON array 是有序的,可以不用担心无序的问题。

此类问题没有标准的解决方式,只能选择提供若干思路:

思路一,参考 query string 对数组参数的解决方案:
key=value 这种格式,可以认为是参考了 query string 的形式,那么我们可以翻一下 qs 是如何处理的,比如说看看 node 的文档
https://nodejs.org/docs/latest-v12.x/api/querystring.html#querystring_querystring_parse_str_sep_eq_options

可见,node 此处是使用了数组的 key 多次出现,以出现顺序作为数组顺序的方式来解决数组问题的。
不过这种方式暂时无法解决复杂对象的问题。

思路二,参考 form 对数组参数的解决方案
https://stackoverflow.com/questions/9073690/post-an-array-from-an-html-form-without-javascript
我没有仔细核查这种方式是一种标准,还是某个框架 /语言约定俗成。

很显然它利用中括号,又构建了一层 k-v 体系,从而不但解决了数组问题,连复杂对象问题一起解决了。


思路三,直接 JSON String 不香么?
反正验签和业务代码肯定是分离开的,那干脆直接把业务数据都封在一起呗,还能顺便搞个 RSA 之类的加密。

{
"rsa_key_id": "24",
"req_body": "ewogICJyc2Ffa2V5X2lkIjogIjI0IiwKICAicmVxX2JvZHkiOiAiIiwKICAibm9uY2UiOiAidjJleCIsCiAgInRpbWVzdGFtcCI6IDE1ODA1ODAxMjIKfQ==",
"nonce": "v2ex",
"timestamp": 1580580122,
"sign": "82f7f51055326385bf9f7e151b212066"
}

注意:nonce 和 timestamp 同时也包含在 req_body 中,外面再放一份是方便你验签。
LinJunzhu
2020-01-16 23:12:54 +08:00
@Vegetable 这也是我一开始的想法,但在网上搜索了好一阵,都没有个约定说怎么做,所以上来问问大家的做法。
also24
2020-01-16 23:19:01 +08:00
补充一下,关于第二种思路,我确认了一下,似乎不是标准实现,但是确实是 php 官方支持的,参见:
https://www.php.net/manual/en/faq.html.php#faq.html.arrays
LinJunzhu
2020-01-16 23:19:13 +08:00
@also24 数组的有序性我倒是不担心, 唯一疑惑的点就在于 value 为对象时,该如何确保 客户端和服务端生成 sign 的逻辑是一致的。

因为哈希对象的 key 是无顺序的,同一段 json 文本,在最终解析出来的顺序是可能不一致的;

若 value 为对象,那么客户端在生成 token 时,该 value 转换为 json 文本是:

"{\"a\":\"a\",\"b\":\"b\"}"

服务端在生成 token 时,该 value 转换为 json 文本可能是:

"{\"b\":\"b\",\"a\":\"a\"}"

所以最终会导致验签失败。

我能想到的就是对 value 为复杂对象时,也进行 key=value 的形式排序,如此递归下去
also24
2020-01-16 23:23:58 +08:00
@LinJunzhu #9
你没看我给出的第二种和第三种方法么?

第二种方法参考了 php 对 form 的数组实现(同时可以应用于复杂对象)

第三种方式则直接嵌套了一个 JSON String,可能因为我添加了一个虚假的 rsa 导致看起来不太直观,我再补充一下吧:
{
"req_body": "{\n \"title\": \"v2ex 发帖\",\n \"url\": \"/t/638565\",\n \"nonce\": \"v2ex\",\n \"timestamp\": 1580580122\n}",
"nonce": "v2ex",
"timestamp": 1580580122,
"sign": "82f7f51055326385bf9f7e151b212066"
}
wangyzj
2020-01-17 00:02:09 +08:00
为什么会有数组出现?
dawniii
2020-01-17 00:10:41 +08:00
为什么要解析了签呢,直接用 http body 签不就好了。
JCZ2MkKb5S8ZX9pq
2020-01-17 00:12:33 +08:00
碰到过几个 api,直接把 value 变成字符串了。
xl224
2020-01-17 00:13:05 +08:00
直接拿传过来的 json string 验证,通过了再去反序列化成内部对象
geelaw
2020-01-17 08:25:37 +08:00
> 提供 API 给客户端使用时,为了验证是客户端发起的请求,并且保证请求中不被用户恶意篡改

目的错误,用户就是客户端,客户端就是用户,运行在最终用户机器上的代码都是最终用户可以查看的,没有必要防范用户篡改——用户本来就有完全控制能力。

> 翻看了微信和支付宝的文档

这两个和你说的场景不同,考虑 A 公司向 B 用户通过支付宝收款,则收款请求是 A 的服务器发出的而不是 B 的设备发出的。要求 A 进行消息认证的目的是为了确保收款请求确实是 A 的意思而不是别人栽赃。

注意是消息认证而不是数字签名。后者是可公开验证的,前者不一定要是这样。在楼主提出的机制里校验也需要 secret。

简单来说,API 的消费者是不安全、成千上万的用户设备则无需画蛇添足,API 的消费者是受控制、安全的服务器才需要消息认证。
StarUDream
2020-01-17 09:17:36 +08:00
按照 key 的 ascii 递归拼接 value。
1. worldjava
2. worldhi
qyvlik
2020-01-17 09:45:46 +08:00
有些平台是不对 http body 为 JSON 字符串格式进行签名(其实是消息认证、数字签名也有),例如 火币的部分 open-api。

如果想要对 JSON 字符串进行签名,可以剖析一下 HTTP 的请求格式

```
METHOD PATH[QUERY] HTTP 1.1
[HEADERS]
[BODY]
```

在**部分请求头不参与签名**情况下,对一个 HTTP 请求进行签名,第一步是整理出需要签名的原始内容,然后确保原始内容和内容格式,客户端和服务端都可以**轻易**获取和处理,例如一种可能的原始内容格式如下:

```
METHOD\n
PATH\n
QUERY\n
CONTENT-TYPE\n
PAYLOAD\n
NONCE
```

- `METHOD`: http 请求方法,例如 GET, POST 等
- `PATH`: 请求路径,例如 `/`, `/api/v1/time`
- `QUERY`: 查询字符串,例如 `?userId=1` 或者 `?userId=1&state=active`
- `CONTENT-TYPE`: http body 的格式,例如 `application/x-www-form-urlencoded`,或者:`application/json`
- `PAYLOAD`: http body,例如是表单,或者 JSON 字符
- `NONCE`:一般是时间戳,防止重放的

然后客户端使用 HMAC-SHA256 的消息认证算法进行签名,**并将签名的字符串放到请求头中**,服务端从请求头获取签名串,然后从请求按照上诉格式拼接处原始内容,在服务端再签名一次,比对两次的签名串是否一致,即可做到**验证签名**的效果。

由于签名串是放在请求头,而不是写回到 `QUERY`(查询字符串),所以不用在客户端重新排序 `QUERY` 或者表单,服务端也不用。

当然,这是其中一种处理方式,更多时候,可能是要求 客户端将 POST JSON 方式修改为 POST FORM,也就是将 JSON 格式转为表单中的一个字段,类似:`&json_payload={"a":"1", "b":"2"}`,然后在对其进行 url 编码。

可以看看如何在 spring 中处理 hmac 消息认证的,[qyvlik/spring-hmac-rest-verify]( https://github.com/qyvlik/spring-hmac-rest-verify)
index90
2020-01-17 12:15:32 +08:00
你签名的目的是保证 request 的 body 不被篡改,至于 body 的 content-type 是什么没有关系。
直接对 request 的 body 进行签名就好了。

我猜 lz 主要是把 json 字符串和 json 对象搞混了,json 对象是具体语言对 json 字符串的面向对象表达方式。
http request 的 payload 实际上只能是一个 byte 数组,你之所以觉得会有 json value,other value 之分,是因为你所使用的库抽象封装了。查看源码你会发现,json 对象是需要 encode 成 json 字符串,才放进 request payload 里面。

回到问题,lz 应该自行对 json 对象进行 encode,并对 json 字符串进行签名,你所使用的库应该提供 set raw payload 的方法。
引申问题,lz 你对 http 协议的理解可能还只停留在你所使用的库层面上,建议你还是更仔细学一下 http 协议。
LinJunzhu
2020-01-17 14:09:05 +08:00
@index90

你看我上面的回复,就能看到,我指出了 JSON 文本,和 JSON 对象(各个语言有各自的类型),我对 http 的协议还是蛮熟悉的 :)

至于为什么我一直在说 json, 是因为现在 API 交互中, 双方基本约定成俗为 content_type: application/json

直接对 request body 进行验签不可取,还是那句话,hash key 是无序的,不同语言、不同库 解析和反解析,顺序不一定一致。

你可以看下微信支付商户的 API 文档: [https://pay.weixin.qq.com/wiki/doc/api/micropay.php?chapter=4_3] 也是进行 键值对的 排序组合, 只不过微信支付的 API 参数 value,都是基本类型
LinJunzhu
2020-01-17 14:14:47 +08:00
@geelaw

>这两个和你说的场景不同,考虑 A 公司向 B 用户通过支付宝收款,则收款请求是 A 的服务器发出的而不是 B 的设备发出的。要求 A 进行消息认证的目的是为了确保收款请求确实是 A 的意思而不是别人栽赃。

那我们回到这个场景,微信支付商户文档也是对参数进行了键值对的排序组合。

若 参数值为 一个 json 对象,而非基本类型(String, int),该如何做解析?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/638565

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX