前言
最近在接入第三方接口的时候,要验证参数里的签名,签名采用SHA256withRSA (RSA2),以确认数据是不是被修改了。具体SHA256withRSA的原理不在这里讲解,本文主要记录在go(gin框架)验签时,踩到的一些坑,加以总结和记录。
ps: 以下的代码有些没有做错误处理,实际开发中不可取。
SHA256withRSA
待签字符串
接口参数以x-www-form-urlencoded的形式传入, 如下的形式
1 | vbnet复制代码utc_timestamp:1624864579690 |
组装待签名字符串:
- 获取全部参数,剔除sign与sign_type参数
- 将筛选的参数按照第一个字符的键值ASCII码递增排序(字母升序排序),如果遇到相同字符则按照第二个字符的键值ASCII码递增排序,以此类推
- 将排序后的参数与其对应值,组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来,此时生成的字符串为待签名字符串
按照要求,则获取的待签字符串为:app_id=20210701&content={“page”:1,”size”:20}&utc_timestamp=1624864579690
签名与验签
虽然是作为验签方,但是为了方便测试,也实现了签名方法,先准备公钥与私钥,私钥签名,公钥验签。
1 | vbnet复制代码-----BEGIN PUBLIC KEY----- |
实现签名函数
1 | go复制代码func RsaSignWithSha256(data []byte, keyBytes []byte) ([]byte, error) { |
最终返回的结果是一个字节切片,但是参数是以字符串的形式传递的,因此需要把这个字节切片转为字符串,有两种形式 :
- hex
hex.EncodeToString
- base64
base64.StdEncoding.EncodeToString
编码之后的数据,也会有明显的差异
1 | arduino复制代码// base64 |
不管采用哪种编码,在验证签名的时候都要先解码转成字节切片,否则验签不会通过,以下是验签函数,采用hex
1 | go复制代码func RsaVerySignWithSha256(data, signData, keyBytes []byte) bool { |
最后整个签名与验签的过程就完成了
1 | go复制代码func main (){ |
既然验签过程完成了,接下就是应用在项目中了,这里使用的是gin框架,而接下来的这部分,踩了不少的坑 Orz
go(Gin)踩坑
从参数到待签字符串
在使用gin中,我都会使用ShouldBind将参数与结构体绑定,这次也自然而然的这么使用。将参数绑定到结构体之后,想拼接待签字符串,那就要遍历结构体,要遍历结构体,就要靠反射
1 | go复制代码func GetPendingSign(p interface{}) []byte { |
上面的方法中,有几个部分做了注释,这几个地方也是比较关键的。接着用postman进行测试,将最开始的参数传入,会得到和预期一样的待签字符串。
1 | go复制代码 r := gin.Default() |
然而就这样结束了吗?不!这种方式,有一个很大的问题!如果把content:{"page":1,"size":20}
改成content:{"size":20,"page":1}
入参,会发现签名验证失败了!是的!就是调换了size和page的位置,这种方式的问题就暴露出来了。
结构体/map和json
为什么会验签失败呢?第一个想法就是待签字符串是否一致?再次请求,打印拼接出来的字符串,会发现仍然是content={"page":1,"size":20}
而不是传入的content:{"size":20,"page":1}
是不是很神奇?居然换位置了?来看下结构体的定义
1 | go复制代码type Content struct { |
明显的发现,序列化之后key的顺序和定义结构体属性的顺序保持了一致,而不是以最开始的json为准了,即:结构体序列化成json时,json的key值顺序以定义结构体时,属性的顺序为准
既然说到了结构体,再来看看map
1 | go复制代码 s := `{"size":20,"page":1}` |
key的顺序也是被调整了,那么map又是以什么规则来调整key的顺序呢?
从源码的 encoding/json/encode.go 第793行中看到这行代码
1 | go复制代码sort.Slice(sv, func(i, j int) bool { return sv[i].s < sv[j].s }) |
即:map转json是有序的,按照ASCII码升序排列key。
好吧,既然两个方式都会改变key的顺序,那么这种先绑定结构体再遍历拼接的方式就不可取了。
解决方案
既然不能先反序列化,那么就要采取其他的方案了。不以ShouldBind的形式获取参数,那么就用ioutil.ReadAll
的方式来获取参数,打印看看获取到的参数
1 | perl复制代码utc_timestamp=1624864579690&sign=LPyc1kQNle9fTNfPi7zDz77eZFG0XD0YBXsRQNw%2FcCq00YE2dISzZIizi5S30ssHfVS2uuQsOyYYZoI8BgT1VR3vcf3CdOY8rkPPdqhBgcEJyKNRvQ3z%2B3VnM33gP84J5Ntg%2FLS8ZAlGpGjL9xTWtKVUbHZk0oy1qJwt3Da%2Bwqchk5oh%2FcYeQnTyyUheQBf2WwPeNYCoauUS6R3KCtF3X8d2qUjx2ZEMkAMQhqGG9DwapWdTdoStjDZt%2B%2FUz2wNT%2F4ctTa0iTvKPh5Zn1fBhBEKiflXlC32tRjS5hC2RfXR%2F1JR%2FAF%2Bu937THwZmWv4xDPAQwRNcNwIH%2Ba6mafygKg%3D%3D&sign_type=RSA2&app_id=20210701&content=%7B%22size%22%3A20%2C%22page%22%3A1%7D |
x-www-form-urlencoded的参数形式,和query的参形式类似,都是用&和=来拼接,既然都是字符串,那么就手动切割,然后拼成map,最后遍历map,拼接成待签字符串。
1 | go复制代码 bodyArray := strings.Split(string(body), "&") //1、先按&切割 |
按照上面的形式,就可以得到一个map,而content的值,因为只是个字符串,没有被反序列化之后再序列化。因此就不会再出现key顺序不一致的问题。接着只需要遍历这个map,按照要求组装待签字符串即可。
最后把这些步骤都封装成一个中间件使用,验签功能完成。
总结
来看看最终都有哪些知识:
- 签名有base64和hex的编码方式,验签的时候,要对应解码
- 使用反射遍历结构体
- 结构体序列化成json时,json key按照结构体的属性顺序重新排序
- map序列化成json时,json key按照ASCII码升序排列
- x-www-form-urlencoded 的参数形式,以&和=拼接,并且会被urlescape,处理的时候要unescape
- 最后一个小知识点, 在中间件中用
ioutil.ReadAll
读完body,记得重新把body写回去c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
,否则后面的路由就读不到body了
go路漫漫~
Thanks!
本文转载自: 掘金