API 请求签名与 SDK

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 已用或时间戳过期被拒)。
先把预期摆正secret 内置在客户端(尤其 App / 小程序)可以被反编译提取。签名的价值是防重放 + 抬高门槛,不是绝对不可破。强身份仍然靠登录 token;签名与 token 是两件独立叠加的事。

#第一步:服务端开启签名

两种方式任选其一,都即时生效、无需重启。

方式 A:控制台(推荐)

  1. 1
    进「防护配置 → 人机验证 → API 画像」往下找到「API 请求签名 · 防抓包重放」。
  2. 2
    点「添加签名规则」,填路由前缀/api/前缀匹配,覆盖 /api/** 下所有请求。
  3. 3
    点「随机生成」生成密钥32 字节随机密钥(也可粘贴自有密钥,≥16 位)。生成后先点「复制」取走
  4. 4
    填免签例外每行下方的「免签例外」输入框,回车添加前缀。第三方回调必须放这里,见下一节。
  5. 5
    保存即时生效。
密钥只显示一次密钥仅存服务端、列表不回显明文(只显示「已设置 / 未设置」)。保存后就再也看不到原文了——生成时必须先复制走,同步配到客户端 SDK。编辑时留空表示保留原密钥。

方式 B:配置文件

moguard.yaml
challenge:
  api_signing:
    - path: /api/
      secret: "369da13a192373cc44c200cb069d2767733dc228b205817555aab5778fdaa133"
      exclude:                 # 免签例外:这些子路径虽在 path 下,但不要求签名
        - /api/pay/notify      # 微信 / 支付宝支付回调(第三方服务器发起,带不了你的签名)
        - /api/wx/callback     # 微信授权 / 消息回调
可以配多条规则,覆盖不同前缀、用不同密钥。pathexclude 都是前缀匹配。

#第三方回调必须放进免签例外

支付回调(微信、支付宝…)、OAuth 回调、各类 webhook 都是第三方服务器主动打你,它们只带各自的签名(如微信的 Wechatpay-Signature),永远不可能带你的 X-Moguard-Sig。若这些路径落在签名前缀下又没进 exclude,会被判 api-sig:missing 拒绝。

别把支付回调签死支付回调丢失 = 用户付了钱、订单状态不同步。规则很简单:凡不是「你自己已接入 SDK 的客户端」发起的请求,路径都要加进 exclude,或者本就别放在签名前缀下。这些请求的真实性由它们各自的签名在你的业务代码里验证,不归墨守管。
CORS 预检(OPTIONS)已由墨守自动放行,无需在 exclude 里配置——浏览器发预检时带不了自定义头,真正的请求随后才带签名。

#签名协议规范

任意语言实现,只需产出三个请求头:

请求头
X-Moguard-Ts当前 Unix 时间戳(十进制字符串),与服务器偏差须 ≤ ±300 秒
X-Moguard-Nonce一次性随机串(每个请求唯一,含重试;建议 ≥ 8 字符)
X-Moguard-Siglowerhex( HMAC-SHA256( secret, signingString ) )

signingString换行符连接,不是字面量的反斜杠加 n:

signingString = METHOD + "\n" + path + "\n" + ts + "\n" + nonce
  • METHOD: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 + 画像头的完整头
别把密钥放进公开代码secret 别提交进公开仓库,也别放在 H5 的明文 JS 里(浏览器端任何人都能看到)。浏览器场景更适合用画像 + 登录 token;签名主要面向 App / 小程序 / 服务端调用。

#各语言签名实现

每段都是一个 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-sigHMAC 对不上:报文拼错或密钥不一致
api-sig:replaynonce 在窗口内重复使用(真被重放,或重试时没换新 nonce)

#灰度上线顺序(很重要)

别先配规则再发客户端一旦某个路由前缀配了签名规则,未带签名或签名错误的请求会被直接拒绝。顺序搞反就是线上事故。
  1. 1
    先发带签名的客户端App 新版本 / 小程序 / Web 先铺开。此时服务端先不配 api_signing——签名头会被忽略,无害。
  2. 2
    等覆盖率足够高看后台统计,确认老版本客户端基本升完。
  3. 3
    盘点所有调用方App、小程序、Web、内部服务、第三方回调……逐个确认要么已接签名,要么已进 exclude
  4. 4
    最后加签名规则在控制台或 yaml 里添加规则强制。出问题随时删掉规则即可回滚,即时生效。

#常见问题

现象原因 / 处理
一直被拒,missing没带 X-Moguard-Sig / Ts / Nonce 三个头,或请求根本没走到签名前缀
一直被拒,bad-sigsigningString 拼错:METHOD 没大写、path 带了 query 或域名、用了字面量 \n 两个字符、secret 与服务端不一致
偶发被拒,stale客户端设备时钟与服务器偏差超过 300 秒,校准设备时间
偶发被拒,replaynonce 被复用——重试时必须重新生成,不能沿用上一次的
收到 HTML 而不是 JSON请求没带 Accept: application/json,被当成浏览器;补上该头
支付回调不通了回调路径落在签名前缀下且没进 exclude,见上文
调试顺序:先用上面的自检向量确认本地签名实现正确,再接服务端。服务端的拒绝原因永远能在攻击日志里看到。

相关:人机验证 · API 客户端响应处理