商户 API 签名认证说明
1. 概述
商户调用平台 API 时,需要通过以下 Header 完成身份认证和请求签名:
| Header | 说明 |
|---|---|
X-Access-Key | 商户 API Key,用于定位商户 |
X-Timestamp | Unix 时间戳(秒); 平台会与当前时间校验,不能超过 5 分钟 |
X-Nonce | 本次请求唯一随机字符串,用于防重放 |
X-Signature | HMAC-SHA256 签名结果 |
认证流程:
- 检查 4 个 Header 是否完整。
- 解析 Header。
- 校验
X-Timestamp是否在允许的时间窗口内。 - 根据
X-Access-Key查询商户。 - 检查商户状态。
- 使用 5 分钟 TTL 校验
X-Nonce,防止同一个请求被重复使用。 - 读取 HTTP Body , 对 HTTP 请求 Body 的原始字节进行 SHA-256 哈希计算,得到 64 位小写十六进制字符串,并将该字符串加入签名原文。。
- 按固定规则生成签名字符串。
- 使用商户
ApiSecret进行 HMAC-SHA256 验签。
2. 请求 Header
示例:
POST /api/v1/game/user-create HTTP/1.1
Content-Type: application/json
X-Access-Key: your-access-key
X-Timestamp: 1757210000
X-Nonce: 9f4a7c2e8b1d
X-Signature: your-hmac-signature
{"username":"test001","nickname":"测试用户"}
3. 签名原文
签名原文由以下内容按固定顺序拼接:
HTTP_METHOD + "\n" +
REQUEST_PATH + "\n" +
TIMESTAMP + "\n" +
NONCE + "\n" +
SHA256(BODY)
例如:
BODY:
{"username":"test001","nickname":"测试用户"}
SHA256(BODY):
ffaddaxqw4452sasdasaf22cbdss0a
POST
/api/v1/game/user-create
1757210000
9f4a7c2e8b1d
ffaddaxqw4452sasdasaf22cbdss0a
注意:
HTTP_METHOD必须使用实际请求方法的大写,例如GET、POST。REQUEST_PATH只使用 URL Path,例如/api/v1/game/user-create。- 不包含协议、域名和 QueryString。
TIMESTAMP必须与X-Timestamp完全一致。NONCE必须与X-Nonce完全一致。BODY必须使用实际发送出去的原始 JSON 字节内容,并将其进行 SHA256(BODY)。- JSON 字段顺序、空格、换行等发生变化都会导致签名不同。
4. HMAC-SHA256
使用商户的 ApiSecret 对签名原文进行 HMAC-SHA256:
signature = HMAC-SHA256(ApiSecret, SignString)
最终签名编码为十六进制小写字符串。
例如:
ApiSecret:
abc123456
BODY:
{"username":"test001","nickname":"测试用户"}
SHA256(BODY)后:
ffaddaxqw4452sasdasaf22cbdss0a
SignString:
POST
/api/v1/game/user-create
1757210000
9f4a7c2e8b1d
ffaddaxqw4452sasdasaf22cbdss0a
Signature:
e4d909c290d0fb1ca068ffaddf22cbd0...
5. Timestamp 防重放
客户端必须发送当前 Unix 时间戳。
平台会检查是否超过允许的时间范围。
因此客户端和服务端需要保持较准确的系统时间。
如果时间戳过期,平台返回:
{
"code": 401,
"message": "timestamp expired"
}
6. Nonce 防重放
每次请求都应该生成一个全新的随机 Nonce。
如果 Key 已经存在,则认为请求被重复发送:
{
"code": 401,
"message": "repeat request"
}
因此:
同一个
AccessKey + Nonce只能成功使用一次。
7. 示例
Go 示例
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func SHA256(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func HMACSHA256(data []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(data)
return hex.EncodeToString(mac.Sum(nil))
}
func RandomNonce() (string, error) {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func main() {
accessKey := "your-access-key"
secret := "your-api-secret"
url := "https://api.example.com/api/v1/game/user-create"
path := "/api/v1/game/user-create"
body := []byte(`{"username":"test001","nickname":"测试用户"}`)
method := "POST"
timestamp := time.Now().Unix()
nonce, err := RandomNonce()
if err != nil {
panic(err)
}
// SHA256(body)
bodyHash := SHA256(body)
// 构造签名原文
signString := fmt.Sprintf(
"%s\n%s\n%d\n%s\n%s",
strings.ToUpper(method),
path,
timestamp,
nonce,
bodyHash,
)
// HMAC-SHA256
signature := HMACSHA256(
[]byte(signString),
secret,
)
req, err := http.NewRequest(
method,
url,
bytes.NewReader(body),
)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Access-Key", accessKey)
req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp))
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", signature)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(resp.StatusCode)
fmt.Println(string(respBody))
}
PHP 示例
<?php
$accessKey = 'your-access-key';
$secret = 'your-api-secret';
$url = 'https://api.example.com/api/v1/game/user-create';
$path = '/api/v1/game/user-create';
$body = json_encode([
'username' => 'test001',
'nickname' => '测试用户',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$method = 'POST';
// 对 body 做 SHA-256
$bodyHash = hash('sha256', $body);
$signString =
strtoupper($method) . "\n" .
$path . "\n" .
$timestamp . "\n" .
$nonce . "\n" .
$bodyHash;
$signature = hash_hmac('sha256', $signString, $secret);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Access-Key: ' . $accessKey,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Signature: ' . $signature,
],
]);
$response = curl_exec($ch);
if ($response === false) {
throw new RuntimeException(curl_error($ch));
}
curl_close($ch);
echo $response;
Java 示例
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
public class ApiClient {
public static void main(String[] args) throws Exception {
String accessKey = "your-access-key";
String secret = "your-api-secret";
String url = "https://api.example.com/api/v1/game/user-create";
String path = "/api/v1/game/user-create";
String body = """
{"username":"test001","nickname":"测试用户"}
""".trim();
String method = "POST";
String timestamp = String.valueOf(Instant.now().getEpochSecond());
String nonce = randomNonce();
// SHA256(body)
String bodyHash = sha256(body);
// 构造签名原文
String signString =
method.toUpperCase() + "\n" +
path + "\n" +
timestamp + "\n" +
nonce + "\n" +
bodyHash;
// HMAC-SHA256
String signature = hmacSha256(signString, secret);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("X-Access-Key", accessKey)
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(
body,
StandardCharsets.UTF_8
))
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
System.out.println(response.statusCode());
System.out.println(response.body());
}
/**
* SHA-256
*/
private static String sha256(String data) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(
data.getBytes(StandardCharsets.UTF_8)
);
return hex(hash);
}
/**
* HMAC-SHA256
*/
private static String hmacSha256(
String data,
String secret
) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec key = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(key);
byte[] hash = mac.doFinal(
data.getBytes(StandardCharsets.UTF_8)
);
return hex(hash);
}
/**
* 生成随机 nonce
*/
private static String randomNonce() {
byte[] bytes = new byte[16];
new SecureRandom().nextBytes(bytes);
return hex(bytes);
}
/**
* byte[] -> 小写十六进制
*/
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
8. 实现时最容易出错的地方
8.1 Body 必须完全一致
下面两个 JSON 从业务角度可能表示相同数据,但签名不同:
{"username":"test001","nickname":"测试用户"}
{
"username": "test001",
"nickname": "测试用户"
}
因此应该:
- 先生成 JSON。
- 使用生成后的同一份字符串计算签名。
- 原样发送这份 Body。
8.2 Path 必须一致
例如:
/api/v1/game/user-create
和:
/api/v1/game/user-create/
签名不同。
8.3 Query 参数
QueryString 不参与当前签名。
例如:
/api/v1/game/user-list?page=1
当前签名中的 Path 是:
/api/v1/game/user-list
而不是:
/api/v1/game/user-list?page=1
8.4 Nonce 必须每次请求唯一
不要固定:
nonce = 123456
应该使用随机值,例如:
9f4a7c2e8b1d
或者 UUID。
8.5 Secret 不能泄露
不要把:
ApiSecret
放到:
- 浏览器前端
- Vue/React 前端代码
- 微信小程序前端
- App 可被逆向获取的位置
- Git 仓库
如果调用方是浏览器/App 等不可信客户端,应通过调用方自己的后端服务完成签名。