API 请求签名与 SDK
给 App / 小程序 / H5 的 API 加 HMAC 一次性签名,抓包重放无效。协议规范、七种语言实现、现成 JS SDK 与灰度上线顺序。
画像绑定能挡住「拿浏览器直接调你 API」,但挡不住抓包重放——把 App 发出的合法请求原样录下来再发一遍,画像是一模一样的。「API 请求签名」给每个请求加一条一次性 HMAC 签名:时间戳必须新鲜、随机数只能用一次,录下来的请求再发就无效。这是给 App / 小程序 / H5 后端接口最实在的一道门槛。
任何语言都能对接——签名就是一条 HMAC-SHA256,协议在下面有精确定义。JS 客户端(App / 小程序 / H5 / Node)可以直接用现成 SDK,零依赖、单文件。
#工作原理
客户端 墨守 WAF
│ GET /api/foo │
│ X-Moguard-Ts: 1784609850 │ 1) ts 与服务器时间偏差 ≤ ±300s ?
│ X-Moguard-Nonce: 8f3k9x-a1b2 │ 2) nonce 本窗口内未用过 ?(防重放)
│ X-Moguard-Sig: 3e7c...(hex) │ 3) HMAC-SHA256(secret, 报文) == Sig ?
│ ─────────────────────────────────────▶ │ 三项全过 → 放行;否则 403- 签名报文固定为
METHOD + "\n" + path + "\n" + ts + "\n" + nonce。 - secret 由服务端与客户端共享:配在墨守里,内置在客户端。
- nonce 一次性 + 时间戳新鲜 ⇒ 抓包重放无效(重放会因 nonce 已用或时间戳过期被拒)。
#第一步:服务端开启签名
两种方式任选其一,都即时生效、无需重启。
方式 A:控制台(推荐)
- 1进「防护配置 → 人机验证 → API 画像」往下找到「API 请求签名 · 防抓包重放」。
- 2点「添加签名规则」,填路由前缀如
/api/,前缀匹配,覆盖/api/**下所有请求。 - 3点「随机生成」生成密钥32 字节随机密钥(也可粘贴自有密钥,≥16 位)。生成后先点「复制」取走。
- 4填免签例外每行下方的「免签例外」输入框,回车添加前缀。第三方回调必须放这里,见下一节。
- 5保存即时生效。
方式 B:配置文件
challenge:
api_signing:
- path: /api/
secret: "369da13a192373cc44c200cb069d2767733dc228b205817555aab5778fdaa133"
exclude: # 免签例外:这些子路径虽在 path 下,但不要求签名
- /api/pay/notify # 微信 / 支付宝支付回调(第三方服务器发起,带不了你的签名)
- /api/wx/callback # 微信授权 / 消息回调path 与 exclude 都是前缀匹配。#第三方回调必须放进免签例外
支付回调(微信、支付宝…)、OAuth 回调、各类 webhook 都是第三方服务器主动打你,它们只带各自的签名(如微信的 Wechatpay-Signature),永远不可能带你的 X-Moguard-Sig。若这些路径落在签名前缀下又没进 exclude,会被判 api-sig:missing 拒绝。
exclude,或者本就别放在签名前缀下。这些请求的真实性由它们各自的签名在你的业务代码里验证,不归墨守管。OPTIONS)已由墨守自动放行,无需在 exclude 里配置——浏览器发预检时带不了自定义头,真正的请求随后才带签名。#签名协议规范
任意语言实现,只需产出三个请求头:
| 请求头 | 值 |
|---|---|
X-Moguard-Ts | 当前 Unix 秒时间戳(十进制字符串),与服务器偏差须 ≤ ±300 秒 |
X-Moguard-Nonce | 一次性随机串(每个请求唯一,含重试;建议 ≥ 8 字符) |
X-Moguard-Sig | lowerhex( HMAC-SHA256( secret, signingString ) ) |
signingString 用换行符连接,不是字面量的反斜杠加 n:
signingString = METHOD + "\n" + path + "\n" + ts + "\n" + nonceMETHOD:HTTP 方法,大写(GET/POST/PUT/DELETE…)。path:URL 路径,不含协议、域名、query、fragment。例:https://api.x.com/api/list?p=1→/api/list。ts/nonce:与同名请求头逐字节一致。- secret 作为 HMAC 的 key,取 UTF-8 字节。
自检向量
拿它验证你的实现对不对——代入下面四项,必须得出同一个 hex:
secret = 369da13a192373cc44c200cb069d2767733dc228b205817555aab5778fdaa133
METHOD = POST
path = /api/taskscenicpoint/update
ts = 1784609850
nonce = nonce-xyz
signingString(以换行连接) = "POST\n/api/taskscenicpoint/update\n1784609850\nnonce-xyz"
X-Moguard-Sig = HMAC-SHA256(secret, signingString) 的小写 hex另外建议同时带上:Accept: application/json(确保被识别为 API 客户端,被挑战时返回 JSON 而不是 HTML 页);若还配了 API 画像绑定,则按画像要求携带 User-Agent / Referer。
#用现成的 JS SDK
JS 客户端不用自己写:下载 SDK(sdk.zip),解开后把 js/moguard-sdk.js 拷进你的项目即可。零依赖、UMD 单文件,App / 小程序 / 浏览器 / Node 通用,已内置签名、PoW 自动求解与重试、pass 持久化复用、Accept: application/json。
uni-app(App / H5)
import MoguardSDK from '@/utils/moguard-sdk.js'
const mg = MoguardSDK.createClient({
secret: '369da13a...同服务端...',
baseURL: 'https://api.example.com',
signPrefixes: ['/api/'],
request: MoguardSDK.uniAdapter(uni), // 注入 uni 的网络能力
storage: { get: k => uni.getStorageSync(k) || '', set: (k, v) => uni.setStorageSync(k, v) },
defaultHeaders: { // 若配了画像绑定
'User-Agent': 'yourapp/1.0 (android) MicroMessenger/8.0',
'Referer': 'https://servicewechat.com/',
},
})
// 自动签名 + PoW 兜底 + pass 复用
const res = await mg.post('/api/taskscenicpoint/update', { name: 'x' })
console.log(res.statusCode, res.data)微信小程序
const MoguardSDK = require('../../utils/moguard-sdk.js')
const mg = MoguardSDK.createClient({
secret: '369da13a...',
baseURL: 'https://api.example.com',
request: MoguardSDK.wxAdapter(wx),
storage: { get: k => wx.getStorageSync(k) || '', set: (k, v) => wx.setStorageSync(k, v) },
})
mg.get('/api/list', { page: 1 }).then(r => console.log(r.data))浏览器 / Node 18+
const mg = MoguardSDK.createClient({
secret: '369da13a...',
baseURL: 'https://api.example.com',
request: MoguardSDK.fetchAdapter(), // Node 18+ 传 fetchAdapter(globalThis.fetch)
})
const r = await mg.post('/api/foo', { a: 1 })只想要签名头、自己发请求
const headers = mg.sign('POST', '/api/foo') // → { X-Moguard-Ts, X-Moguard-Nonce, X-Moguard-Sig }
// 或 mg.buildHeaders('POST', url, extraHeaders) 拿到含 pass + 画像头的完整头#各语言签名实现
每段都是一个 sign(secret, method, path) → 返回三个头,发请求时加到 header 即可。
Python
import time, hmac, hashlib, secrets
def moguard_sign(secret: str, method: str, path: str):
ts = str(int(time.time()))
nonce = secrets.token_hex(8)
msg = f"{method.upper()}\n{path}\n{ts}\n{nonce}"
sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
return {"X-Moguard-Ts": ts, "X-Moguard-Nonce": nonce, "X-Moguard-Sig": sig}
# requests 示例
import requests
h = moguard_sign(SECRET, "POST", "/api/foo")
h["Accept"] = "application/json"
requests.post("https://api.x.com/api/foo", json={"a": 1}, headers=h)PHP
function moguard_sign(string $secret, string $method, string $path): array {
$ts = (string) time();
$nonce = bin2hex(random_bytes(8));
$msg = strtoupper($method) . "\n" . $path . "\n" . $ts . "\n" . $nonce;
$sig = hash_hmac('sha256', $msg, $secret); // 默认输出小写 hex
return [
'X-Moguard-Ts' => $ts,
'X-Moguard-Nonce' => $nonce,
'X-Moguard-Sig' => $sig,
];
}Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Map;
static Map<String, String> moguardSign(String secret, String method, String path) throws Exception {
String ts = String.valueOf(System.currentTimeMillis() / 1000);
byte[] nb = new byte[8]; new SecureRandom().nextBytes(nb);
StringBuilder n = new StringBuilder();
for (byte b : nb) n.append(String.format("%02x", b));
String nonce = n.toString();
String msg = method.toUpperCase() + "\n" + path + "\n" + ts + "\n" + nonce;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));
StringBuilder sig = new StringBuilder();
for (byte b : raw) sig.append(String.format("%02x", b));
return Map.of("X-Moguard-Ts", ts, "X-Moguard-Nonce", nonce, "X-Moguard-Sig", sig.toString());
}Go
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func MoguardSign(secret, method, path string) map[string]string {
ts := strconv.FormatInt(time.Now().Unix(), 10)
nb := make([]byte, 8)
_, _ = rand.Read(nb)
nonce := hex.EncodeToString(nb)
msg := fmt.Sprintf("%s\n%s\n%s\n%s", strings.ToUpper(method), path, ts, nonce)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(msg))
return map[string]string{
"X-Moguard-Ts": ts,
"X-Moguard-Nonce": nonce,
"X-Moguard-Sig": hex.EncodeToString(mac.Sum(nil)),
}
}C#
using System;
using System.Security.Cryptography;
using System.Text;
static (string ts, string nonce, string sig) MoguardSign(string secret, string method, string path) {
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nb = RandomNumberGenerator.GetBytes(8);
var nonce = Convert.ToHexString(nb).ToLowerInvariant();
var msg = $"{method.ToUpperInvariant()}\n{path}\n{ts}\n{nonce}";
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var sig = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(msg))).ToLowerInvariant();
return (ts, nonce, sig);
// header: X-Moguard-Ts=ts, X-Moguard-Nonce=nonce, X-Moguard-Sig=sig
}Android(Kotlin)
import java.security.SecureRandom
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun moguardSign(secret: String, method: String, path: String): Map<String, String> {
val ts = (System.currentTimeMillis() / 1000).toString()
val nb = ByteArray(8).also { SecureRandom().nextBytes(it) }
val nonce = nb.joinToString("") { "%02x".format(it) }
val msg = "${method.uppercase()}\n$path\n$ts\n$nonce"
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
}
val sig = mac.doFinal(msg.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
return mapOf("X-Moguard-Ts" to ts, "X-Moguard-Nonce" to nonce, "X-Moguard-Sig" to sig)
}
// OkHttp 拦截器里对每个请求 addHeader 即可(path 用 request.url.encodedPath)iOS(Swift)
import Foundation
import CryptoKit
func moguardSign(secret: String, method: String, path: String) -> [String: String] {
let ts = String(Int(Date().timeIntervalSince1970))
var nb = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, nb.count, &nb)
let nonce = nb.map { String(format: "%02x", $0) }.joined()
let msg = "\(method.uppercased())\n\(path)\n\(ts)\n\(nonce)"
let key = SymmetricKey(data: Data(secret.utf8))
let mac = HMAC<SHA256>.authenticationCode(for: Data(msg.utf8), using: key)
let sig = mac.map { String(format: "%02x", $0) }.joined()
return ["X-Moguard-Ts": ts, "X-Moguard-Nonce": nonce, "X-Moguard-Sig": sig]
}#签名不过时会怎样
墨守直接拒绝这个请求:返回 403,带响应头 X-Mo-Guard: bot-blocked;非浏览器客户端拿到的是 JSON(error / reason / ref),详见 API 客户端响应处理。同时写一条攻击日志,规则 字段是 api-sig:<原因>——到 攻击日志 按这个前缀筛,能直接看出是哪一类失败。
| 日志原因 | 含义 |
|---|---|
api-sig:missing | 三个头没带全,或路径没匹配到签名前缀以外的例外情况 |
api-sig:bad-ts | 时间戳不是合法的十进制整数 |
api-sig:stale | 时间戳与服务器偏差超过 ±300 秒 |
api-sig:bad-sig | HMAC 对不上:报文拼错或密钥不一致 |
api-sig:replay | nonce 在窗口内重复使用(真被重放,或重试时没换新 nonce) |
#灰度上线顺序(很重要)
- 1先发带签名的客户端App 新版本 / 小程序 / Web 先铺开。此时服务端先不配
api_signing——签名头会被忽略,无害。 - 2等覆盖率足够高看后台统计,确认老版本客户端基本升完。
- 3盘点所有调用方App、小程序、Web、内部服务、第三方回调……逐个确认要么已接签名,要么已进
exclude。 - 4最后加签名规则在控制台或 yaml 里添加规则强制。出问题随时删掉规则即可回滚,即时生效。
#常见问题
| 现象 | 原因 / 处理 |
|---|---|
一直被拒,missing | 没带 X-Moguard-Sig / Ts / Nonce 三个头,或请求根本没走到签名前缀 |
一直被拒,bad-sig | signingString 拼错:METHOD 没大写、path 带了 query 或域名、用了字面量 \n 两个字符、secret 与服务端不一致 |
偶发被拒,stale | 客户端设备时钟与服务器偏差超过 300 秒,校准设备时间 |
偶发被拒,replay | nonce 被复用——重试时必须重新生成,不能沿用上一次的 |
| 收到 HTML 而不是 JSON | 请求没带 Accept: application/json,被当成浏览器;补上该头 |
| 支付回调不通了 | 回调路径落在签名前缀下且没进 exclude,见上文 |
相关:人机验证 · API 客户端响应处理。
