Appearance
认证机制
2.1 认证方式
采用 AppKey + HMAC-SHA256 签名 认证。每个请求都需要携带以下请求头:
| 请求头 | 必填 | 说明 |
|---|---|---|
X-App-Key | 是 | 平台分配的应用标识 |
X-Timestamp | 是 | 请求发起时的 Unix 时间戳(秒),与服务器时间差不得超过 5 分钟 |
X-Nonce | 是 | 随机字符串(建议 UUID),用于防重放,同一 nonce 10 分钟内不可重复使用 |
X-Signature | 是 | HMAC-SHA256 签名值 |
Content-Type | 是 | 固定为 application/json |
2.2 签名算法
第一步:拼接签名字符串
sign_string = "{app_key}\n{timestamp}\n{nonce}\n{method}\n{path}\n{sorted_query}\n{body_md5}"各字段说明:
| 字段 | 说明 | 示例 |
|---|---|---|
app_key | 你的应用标识 | crm_system_001 |
timestamp | 与 X-Timestamp 请求头一致 | 1722844800 |
nonce | 与 X-Nonce 请求头一致 | a1b2c3d4e5f6 |
method | HTTP 方法(大写) | GET、POST |
path | 请求路径(不含域名) | /api/open/v1/guests |
sorted_query | 将原始 query string 按 & 分割后按字母序排列再拼接;必须使用 URL 编码后的原始值(不要解码后重新编码);无 query 参数时为空字符串 | city=%E4%B8%8A%E6%B5%B7&page=1 |
body_md5 | 请求体的 MD5 摘要(小写 hex);GET 请求无 body 时使用空字符串的 MD5 | d41d8cd98f00b204e9800998ecf8427e |
注意:
body_md5是对原始请求体(raw body)计算 MD5,确保签名时使用的 body 与实际发送的 body 完全一致。sorted_query必须参与签名,防止攻击者在不变签名的情况下篡改 URL 查询参数。
第二步:计算 HMAC-SHA256
signature = HMAC-SHA256(key=app_secret, message=sign_string)输出为小写十六进制字符串(64 位)。
2.3 签名示例
Python
python
import hashlib
import hmac
import time
import uuid
import json
def build_request_headers(
app_key: str, app_secret: str,
method: str, path: str,
query_string: str = "", body: dict = None
) -> dict:
"""
构建开放接口请求头
:param query_string: 原始 query string(URL 编码后),如 "page=1&city=%E4%B8%8A%E6%B5%B7"
注意:必须使用原始编码,不要解码后重新拼接
"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
# 序列化 body
if body:
body_bytes = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
else:
body_bytes = b""
# 规范化 query string(按 & 分割后按字母序排列,保持原始编码)
if query_string:
sorted_query = "&".join(sorted(query_string.split("&")))
else:
sorted_query = ""
body_md5 = hashlib.md5(body_bytes).hexdigest()
sign_string = f"{app_key}\n{timestamp}\n{nonce}\n{method}\n{path}\n{sorted_query}\n{body_md5}"
signature = hmac.digest(
app_secret.encode("utf-8"),
sign_string.encode("utf-8"),
"sha256"
).hex()
return {
"X-App-Key": app_key,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
"Content-Type": "application/json",
}Java
java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.*;
public class OpenApiClient {
private String appKey;
private String appSecret;
/**
* @param queryString 原始 query string(URL 编码后),如 "page=1&city=%E4%B8%8A%E6%B5%B7"
* 注意:必须使用原始编码,不要解码后重新拼接
*/
public Map<String, String> buildHeaders(String method, String path,
String queryString, String body) throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String nonce = UUID.randomUUID().toString().replace("-", "");
// 规范化 query string(按 & 分割后按字母序排列,保持原始编码)
String sortedQuery = "";
if (queryString != null && !queryString.isEmpty()) {
List<String> parts = new ArrayList<>(Arrays.asList(queryString.split("&")));
Collections.sort(parts);
sortedQuery = String.join("&", parts);
}
// 计算 body MD5
String bodyMd5 = md5(body != null ? body : "");
// 拼接签名字符串
String signString = appKey + "\n" + timestamp + "\n" + nonce + "\n"
+ method + "\n" + path + "\n" + sortedQuery + "\n" + bodyMd5;
// HMAC-SHA256
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes("UTF-8"), "HmacSHA256"));
String signature = bytesToHex(mac.doFinal(signString.getBytes("UTF-8")));
Map<String, String> headers = new HashMap<>();
headers.put("X-App-Key", appKey);
headers.put("X-Timestamp", timestamp);
headers.put("X-Nonce", nonce);
headers.put("X-Signature", signature);
headers.put("Content-Type", "application/json");
return headers;
}
private String md5(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes("UTF-8"));
return bytesToHex(digest);
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}JavaScript / Node.js
javascript
const crypto = require('crypto');
/**
* @param {string} queryString - 原始 query string(URL 编码后),如 "page=1&city=%E4%B8%8A%E6%B5%B7"
* 注意:必须使用原始编码,不要解码后重新拼接
*/
function buildHeaders(appKey, appSecret, method, path, queryString, body) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID().replace(/-/g, '');
// 规范化 query string(按 & 分割后按字母序排列,保持原始编码)
let sortedQuery = '';
if (queryString) {
sortedQuery = queryString.split('&').sort().join('&');
}
const bodyStr = body ? JSON.stringify(body) : '';
const bodyMd5 = crypto.createHash('md5').update(bodyStr).digest('hex');
const signString = `${appKey}\n${timestamp}\n${nonce}\n${method}\n${path}\n${sortedQuery}\n${bodyMd5}`;
const signature = crypto
.createHmac('sha256', appSecret)
.update(signString)
.digest('hex');
return {
'X-App-Key': appKey,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
'Content-Type': 'application/json',
};
}2.4 完整请求示例
python
import requests
APP_KEY = "crm_system_001"
APP_SECRET = "your_app_secret_here"
BASE_URL = "http://test-api.ailian.com"
# GET 请求(注意 query string 也参与签名)
path = "/api/open/v1/guests"
query_string = "page=1&page_size=20" # 原始 query string,保持 URL 编码
headers = build_request_headers(APP_KEY, APP_SECRET, "GET", path, query_string=query_string)
response = requests.get(f"{BASE_URL}{path}?{query_string}", headers=headers)
print(response.json())
# POST 请求
path = "/api/open/v1/guests"
body = {
"name": "张三",
"gender": "male",
"age": 28,
"phone": "13800138000",
"city": "上海"
}
headers = build_request_headers(APP_KEY, APP_SECRET, "POST", path, body=body)
response = requests.post(f"{BASE_URL}{path}", headers=headers, json=body)
print(response.json())