优化签名排序逻辑
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -37,7 +38,6 @@ func newNonceStore() *nonceStore {
|
||||
func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 清理过期
|
||||
for k, exp := range s.data {
|
||||
if exp < now {
|
||||
delete(s.data, k)
|
||||
@@ -52,11 +52,15 @@ func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
|
||||
|
||||
// OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名
|
||||
//
|
||||
// 待签名字符串(UTF-8,\n 换行):
|
||||
// 待签名参数(value 原样,不做 URL encode):
|
||||
//
|
||||
// {apiKey}\n{timestamp}\n{nonce}\n{METHOD}\n{path}\n{body}
|
||||
// api_key, body, method, nonce, path, timestamp
|
||||
//
|
||||
// path 为 URL.Path(不含 query),METHOD 大写;GET 时 body 为空字符串。
|
||||
// 按 key 字典序排序后拼接:
|
||||
//
|
||||
// k1=v1&k2=v2&...
|
||||
//
|
||||
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
|
||||
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
|
||||
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||
if cfg.SkewSeconds <= 0 {
|
||||
@@ -105,7 +109,6 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 防重放:同一 nonce 在时间窗口内只能用一次
|
||||
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
|
||||
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
|
||||
c.Abort()
|
||||
@@ -124,15 +127,7 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
body := string(bodyBytes)
|
||||
|
||||
stringToSign := strings.Join([]string{
|
||||
apiKey,
|
||||
timestamp,
|
||||
nonce,
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
}, "\n")
|
||||
|
||||
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
|
||||
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
|
||||
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
||||
response.Unauthorized(c, "签名校验失败")
|
||||
@@ -144,6 +139,28 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSignString 生成待签名字符串:参数字典序 + & 拼接
|
||||
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
|
||||
params := map[string]string{
|
||||
"api_key": apiKey,
|
||||
"body": body,
|
||||
"method": strings.ToUpper(method),
|
||||
"nonce": nonce,
|
||||
"path": path,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+params[k])
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func hmacSHA256Hex(secret, content string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
@@ -159,13 +176,5 @@ func abs64(v int64) int64 {
|
||||
|
||||
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
|
||||
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
|
||||
stringToSign := strings.Join([]string{
|
||||
apiKey,
|
||||
timestamp,
|
||||
nonce,
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
body,
|
||||
}, "\n")
|
||||
return hmacSHA256Hex(apiSecret, stringToSign)
|
||||
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
|
||||
}
|
||||
|
||||
+55
-22
@@ -1,6 +1,6 @@
|
||||
# 店铺订单对接 API 说明(发给皮肤源头)
|
||||
# 店铺订单对接 API 文档
|
||||
|
||||
> 文档对象:皮肤源头 / 发货系统技术人员
|
||||
> 文档对象:发货系统技术人员
|
||||
> 版本:v1
|
||||
> 更新说明:鉴权为 **ApiKey + HMAC 签名**,请按本文实现,勿只传 Key。
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境信息(由店铺方填写后发给你们)
|
||||
## 1. 环境信息
|
||||
|
||||
| 项 | 值(示例 / 请替换) |
|
||||
|----|---------------------|
|
||||
| **API 根地址 Base URL** | `https://api.example.com`(开发:`http://主机:8080`) |
|
||||
| **X-Api-Key** | 由店铺方分配,如 `sk_xxxx` |
|
||||
| **api_secret** | 由店铺方单独发送,**只用于本地算签名,不要写在 URL/Header** |
|
||||
| **API 根地址 Base URL** | `https://http://221329.cc.cd` 开发临时地址 |
|
||||
| **X-Api-Key** | 测试使用,如 `sk_source_dev_key_3ad3d1bbacda0e54` |
|
||||
| **api_secret** | 测试使用,**只用于本地算签名,不要写在 URL/Header**, `sk_source_dev_secret_5a43ec55cd020d83` |
|
||||
| **时间偏差** | 默认允许 ±300 秒 |
|
||||
|
||||
统一响应格式:
|
||||
@@ -49,23 +49,43 @@
|
||||
| `X-Nonce` | 是 | 随机串,长度 8~64;同一 Key 在有效期内不可重复 |
|
||||
| `X-Sign` | 是 | 见下方算法 |
|
||||
|
||||
### 2.1 签名字符串(6 行,用 `\n` 连接)
|
||||
### 2.1 签名字符串(参数字典序 + `&` 拼接)
|
||||
|
||||
参与签名的参数:
|
||||
|
||||
| 参数名 | 说明 |
|
||||
|--------|------|
|
||||
| `api_key` | 与 Header `X-Api-Key` 相同 |
|
||||
| `timestamp` | 与 Header `X-Timestamp` 相同 |
|
||||
| `nonce` | 与 Header `X-Nonce` 相同 |
|
||||
| `method` | 大写:`GET` / `POST` |
|
||||
| `path` | 仅路径,**不要**域名、**不要** query。例:`/api/open/v1/orders/O123` |
|
||||
| `body` | 原始 HTTP body;GET 用**空字符串** |
|
||||
|
||||
规则:
|
||||
|
||||
1. value **原样拼接,不做 URL encode**
|
||||
2. 按参数名 **ASCII 字典序** 排序
|
||||
3. 拼成:`k1=v1&k2=v2&k3=v3...`
|
||||
4. `X-Sign = hex( HMAC-SHA256( api_secret, 签名字符串 ) )`,**小写**十六进制
|
||||
|
||||
排序后参数名顺序固定为:
|
||||
|
||||
```text
|
||||
{api_key}
|
||||
{timestamp}
|
||||
{nonce}
|
||||
{METHOD}
|
||||
{path}
|
||||
{body}
|
||||
api_key, body, method, nonce, path, timestamp
|
||||
```
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| METHOD | 大写:`GET` / `POST` |
|
||||
| path | 仅路径,**不要**域名、**不要** query。例:`/api/open/v1/orders/O123` |
|
||||
| body | 原始 HTTP body 字符串;GET 用**空字符串** |
|
||||
| X-Sign | `hex( HMAC-SHA256( api_secret, 签名字符串 ) )`,**小写**十六进制 |
|
||||
**GET 示例**(body 为空):
|
||||
|
||||
```text
|
||||
api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O202607201550038000×tamp=1721450000
|
||||
```
|
||||
|
||||
**POST 示例**:
|
||||
|
||||
```text
|
||||
api_key=sk_xxx&body={"order_no":"O202607201550038000","ship_status":"success"}&method=POST&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/ship-notify×tamp=1721450000
|
||||
```
|
||||
|
||||
### 2.2 Python 参考实现
|
||||
|
||||
@@ -76,10 +96,22 @@ API_KEY = "请替换为店铺下发的 key"
|
||||
API_SECRET = "请替换为店铺下发的 secret"
|
||||
BASE = "https://api.example.com" # 请替换
|
||||
|
||||
def build_sign_string(api_key: str, timestamp: str, nonce: str, method: str, path: str, body: str = "") -> str:
|
||||
params = {
|
||||
"api_key": api_key,
|
||||
"body": body,
|
||||
"method": method.upper(),
|
||||
"nonce": nonce,
|
||||
"path": path,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
# 字典序 + & 拼接,value 不 encode
|
||||
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
|
||||
def sign_headers(method: str, path: str, body: str = "") -> dict:
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
raw = "\n".join([API_KEY, ts, nonce, method.upper(), path, body])
|
||||
raw = build_sign_string(API_KEY, ts, nonce, method, path, body)
|
||||
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"X-Api-Key": API_KEY,
|
||||
@@ -101,8 +133,9 @@ print(requests.post(BASE + path, headers=headers, data=body.encode("utf-8")).jso
|
||||
|
||||
**注意:**
|
||||
|
||||
- POST 时 `json.dumps` 后的字符串要原样发送,不要一边签名一边再改空格/字段顺序。
|
||||
- 签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复。
|
||||
- POST 时签名用的 `body` 必须与实际发送的 body **字节级一致**(不要一边签名一边再改空格/字段顺序)。
|
||||
- value **不要** URL encode。
|
||||
- 签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复、参数未按字典序拼接。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,10 +12,22 @@ API_KEY = "sk_source_dev_key_change_me"
|
||||
API_SECRET = "sk_source_dev_secret_change_me"
|
||||
BASE = "${baseUrl}"
|
||||
|
||||
def build_sign_string(api_key, timestamp, nonce, method, path, body=""):
|
||||
params = {
|
||||
"api_key": api_key,
|
||||
"body": body,
|
||||
"method": method.upper(),
|
||||
"nonce": nonce,
|
||||
"path": path,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
# 字典序 + & 拼接,value 不 URL encode
|
||||
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
|
||||
def sign_headers(method: str, path: str, body: str = "") -> dict:
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
raw = "\\n".join([API_KEY, ts, nonce, method.upper(), path, body])
|
||||
raw = build_sign_string(API_KEY, ts, nonce, method, path, body)
|
||||
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"X-Api-Key": API_KEY,
|
||||
@@ -79,15 +91,15 @@ export default function OpenApiDocs() {
|
||||
label: '签名规则',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="待签名字符串(6 行,\\n 分隔)">
|
||||
<pre style={preStyle}>{`{api_key}
|
||||
{timestamp}
|
||||
{nonce}
|
||||
{METHOD}
|
||||
{path}
|
||||
{body}`}</pre>
|
||||
<Card size="small" title="待签名字符串(字典序 + & 拼接)">
|
||||
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||
参数:api_key / body / method / nonce / path / timestamp → 按 key 排序后拼成
|
||||
k=v&k=v…(value <Text strong>不</Text> URL encode)
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123×tamp=1721450000`}</pre>
|
||||
<pre style={{ ...preStyle, marginTop: 8 }}>{`api_key=sk_xxx&body={"order_no":"O123","ship_status":"success"}&method=POST&nonce=...&path=/api/open/v1/orders/ship-notify×tamp=1721450000`}</pre>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginTop: 12 }}>
|
||||
<Descriptions.Item label="METHOD">大写 GET / POST</Descriptions.Item>
|
||||
<Descriptions.Item label="method">大写 GET / POST</Descriptions.Item>
|
||||
<Descriptions.Item label="path">
|
||||
URL.Path,不含 query,如 /api/open/v1/orders/O123
|
||||
</Descriptions.Item>
|
||||
|
||||
Reference in New Issue
Block a user