refactor(openapi): 客户端签名统一为字典序+&拼接风格
- BuildOpenV1Sign 改为 app_key/body_sha256/method/nonce/path/timestamp 按 ASCII 字典序用 & 拼接,与上游 BuildSignString 风格一致 - body 以 SHA256 摘要参与签名,避免大 body 与特殊字符问题 - 同步更新中间件调用点、测试用例与前端鉴权文档
This commit is contained in:
@@ -35,7 +35,8 @@ type OpenAuthConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
|
// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
|
||||||
// 客户侧新接口只使用 X-App-Key 和 body SHA256;上游发货接口独立使用 SourceOpenAuth。
|
// 客户侧接口使用 X-App-Key + 字典序 & 拼接签名(app_key/body_sha256/method/nonce/path/timestamp);
|
||||||
|
// 上游发货接口独立使用 SourceOpenAuth(api_key + 原始 body)。
|
||||||
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||||
if cfg.SkewSeconds <= 0 {
|
if cfg.SkewSeconds <= 0 {
|
||||||
cfg.SkewSeconds = 300
|
cfg.SkewSeconds = 300
|
||||||
@@ -100,7 +101,7 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
|||||||
|
|
||||||
method := strings.ToUpper(c.Request.Method)
|
method := strings.ToUpper(c.Request.Method)
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
expected := BuildOpenV1Sign(secret, timestamp, nonce, method, path, bodyBytes)
|
expected := BuildOpenV1Sign(secret, appKey, timestamp, nonce, method, path, bodyBytes)
|
||||||
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
||||||
response.Unauthorized(c, "签名校验失败")
|
response.Unauthorized(c, "签名校验失败")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -164,20 +165,23 @@ func GetMerchantID(c *gin.Context) uint {
|
|||||||
return merchantID
|
return merchantID
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildOpenV1Sign 生成新开放接口签名:timestamp、nonce、method、path 与 body SHA256。
|
// BuildOpenV1Sign 生成客户侧开放接口签名:参数按 ASCII 字典序 + "&" 拼接,
|
||||||
func BuildOpenV1Sign(secret, timestamp, nonce, method, path string, body []byte) string {
|
// 与上游 BuildSignString 风格一致;body 以 SHA256 摘要参与签名(避免大 body 与特殊字符问题)。
|
||||||
|
// 参与签名的参数固定顺序为:app_key, body_sha256, method, nonce, path, timestamp。
|
||||||
|
func BuildOpenV1Sign(secret, appKey, timestamp, nonce, method, path string, body []byte) string {
|
||||||
bodyHash := sha256.Sum256(body)
|
bodyHash := sha256.Sum256(body)
|
||||||
content := strings.Join([]string{
|
content := strings.Join([]string{
|
||||||
timestamp,
|
"app_key=" + appKey,
|
||||||
nonce,
|
"body_sha256=" + hex.EncodeToString(bodyHash[:]),
|
||||||
strings.ToUpper(method),
|
"method=" + strings.ToUpper(method),
|
||||||
path,
|
"nonce=" + nonce,
|
||||||
hex.EncodeToString(bodyHash[:]),
|
"path=" + path,
|
||||||
}, "\n")
|
"timestamp=" + timestamp,
|
||||||
|
}, "&")
|
||||||
return hmacSHA256Hex(secret, content)
|
return hmacSHA256Hex(secret, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSignString 保留旧接口的字典序签名算法,供兼容客户端和测试使用。
|
// BuildSignString 保留旧接口的字典序签名算法,供上游发货兼容客户端和测试使用。
|
||||||
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
|
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
"api_key=" + apiKey,
|
"api_key=" + apiKey,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestOpenAuthV1AcceptsSignedRequestAndRejectsReplay(t *testing.T) {
|
|||||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
nonce := "nonce-123456"
|
nonce := "nonce-123456"
|
||||||
path := "/api/client/v1/orders"
|
path := "/api/client/v1/orders"
|
||||||
sign := BuildOpenV1Sign(secret, ts, nonce, http.MethodPost, path, []byte(body))
|
sign := BuildOpenV1Sign(secret, appKey, ts, nonce, http.MethodPost, path, []byte(body))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
req.Header.Set("X-App-Key", appKey)
|
req.Header.Set("X-App-Key", appKey)
|
||||||
|
|||||||
@@ -125,33 +125,32 @@ function AuthTab() {
|
|||||||
|
|
||||||
<Card size="small" title="签名算法">
|
<Card size="small" title="签名算法">
|
||||||
<Descriptions size="small" column={1} bordered>
|
<Descriptions size="small" column={1} bordered>
|
||||||
<Descriptions.Item label="签名内容">
|
<Descriptions.Item label="参与参数">
|
||||||
<Text code>{'timestamp\nnonce\nMETHOD\npath\nsha256(body)'}</Text>
|
<Text code>app_key</Text>、<Text code>body_sha256</Text>、<Text code>method</Text>、<Text code>nonce</Text>、<Text code>path</Text>、<Text code>timestamp</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="拼接方式">按固定顺序,用换行符 <Text code>\n</Text> 拼接</Descriptions.Item>
|
<Descriptions.Item label="拼接方式">参数按 ASCII 字典序,用 <Text code>&</Text> 拼成 <Text code>k1=v1&k2=v2&...</Text></Descriptions.Item>
|
||||||
<Descriptions.Item label="METHOD">大写,如 GET / POST</Descriptions.Item>
|
<Descriptions.Item label="排序后顺序">
|
||||||
|
<Text code>app_key, body_sha256, method, nonce, path, timestamp</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="method">大写,如 GET / POST</Descriptions.Item>
|
||||||
<Descriptions.Item label="path">仅 URL.Path,不含域名和 query</Descriptions.Item>
|
<Descriptions.Item label="path">仅 URL.Path,不含域名和 query</Descriptions.Item>
|
||||||
<Descriptions.Item label="body">GET 为空字节;POST 必须与实际发送 body 完全一致</Descriptions.Item>
|
<Descriptions.Item label="body_sha256">
|
||||||
|
<Text code>SHA256(原始 body 字节)</Text> 的十六进制小写;GET 用空 body 的摘要
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="value">原样拼接,不做 URL encode</Descriptions.Item>
|
||||||
<Descriptions.Item label="X-Sign">
|
<Descriptions.Item label="X-Sign">
|
||||||
<Text code>hex( HMAC-SHA256( app_secret, 签名内容 ) )</Text>,小写十六进制
|
<Text code>hex( HMAC-SHA256( app_secret, 签名串 ) )</Text>,小写十六进制
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card size="small" title="签名示例(GET,body 为空)">
|
<Card size="small" title="签名示例(GET,body 为空)">
|
||||||
<pre style={preStyle}>{`timestamp
|
<pre style={preStyle}>{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}</pre>
|
||||||
nonce
|
|
||||||
GET
|
|
||||||
/api/client/v1/products
|
|
||||||
sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`}</pre>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card size="small" title="签名示例(POST,body 非空)">
|
<Card size="small" title="签名示例(POST,body 非空)">
|
||||||
<pre style={preStyle}>{`timestamp
|
<pre style={preStyle}>{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
|
||||||
nonce
|
app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&method=POST&nonce=a1b2c3d4e5f67890&path=/api/client/v1/orders×tamp=1721450000`}</pre>
|
||||||
POST
|
|
||||||
/api/client/v1/orders
|
|
||||||
sha256(body) = <实际请求 body 字节的 SHA256 十六进制>`}</pre>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
@@ -161,7 +160,8 @@ sha256(body) = <实际请求 body 字节的 SHA256 十六进制>`}</pre>
|
|||||||
description={
|
description={
|
||||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||||
<li>POST 签名用的 <Text code>body</Text> 必须与实际发送的 body <b>字节级一致</b>,不要签名后再改空格或字段顺序。</li>
|
<li>POST 签名用的 <Text code>body</Text> 必须与实际发送的 body <b>字节级一致</b>,不要签名后再改空格或字段顺序。</li>
|
||||||
<li>签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复。</li>
|
<li>value <b>不要</b> URL encode,原样参与拼接。</li>
|
||||||
|
<li>签名失败常见原因:secret 错、path 多了 query、body 与签名不一致、时间戳过期、nonce 重复、参数未按字典序拼接。</li>
|
||||||
<li>下单接口还需携带 <Text code>Idempotency-Key</Text>,且必须与 <Text code>client_order_no</Text> 一致。</li>
|
<li>下单接口还需携带 <Text code>Idempotency-Key</Text>,且必须与 <Text code>client_order_no</Text> 一致。</li>
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user