事情的起点是一笔重复订单。合作方那边网络抖动,客户端把同一个下单请求重发了一次,我们这边照单全收,用户被扣了两份钱。翻日志的时候发现更糟的问题:那个开放接口压根没做任何校验,只要知道 URL,谁都能调。
补漏洞的方案不复杂,加签、校验时间戳、校验随机串,三件套。难的是把它写对——我在中间件里前后改了三版才稳定下来,其中有两处是把逻辑顺序搞错了才会出现“看起来没问题但偶尔失效”的情况。这里把最终版本和踩坑记录都摆出来。
先把签名规则定死
规则定了就不能随便改,所以第一步是写清楚,而不是先写代码。
- 请求头带四个字段:
X-App-Id、X-Timestamp(秒级)、X-Nonce(32 位随机串)、X-Sign。 - 参与签名的内容:所有 GET 和表单参数,剔除
sign本身,按参数名 ASCII 升序排序。 - 拼接格式为
k1=v1&k2=v2,不做 URL 编码,空字符串参数不参与。 - 数组参数统一用
json_encode序列化,序列化前对数组内部按 key 排序。 - 末尾追加密钥:
...&key={app_secret},然后strtoupper(md5())。
最后一步为什么先用 MD5 而不是 HMAC-SHA256?因为这个接口的调用方是对方的 PHP 后端,环境比较老,MD5 是他们那边唯一能稳定算出来的。安全强度确实弱一些,但配上时间戳窗口和 nonce,防重放这个目标能达到,这也是我用它的原因。如果对接方能升级,直接换 hash_hmac('sha256', $str, $secret) 就行,中间件里改一行。
中间件实现
文件放在 app/middleware/SignVerify.php。这里我先贴完整代码,几个关键点放在后面单独说。
<?php
namespace appmiddleware;
use Closure;
use thinkfacadeCache;
use thinkfacadeLog;
use thinkRequest;
use thinkResponse;
class SignVerify
{
// 时间戳允许的最大偏差,秒
protected const WINDOW = 300;
// nonce 的保留时长,要略大于 WINDOW
protected const NONCE_TTL = 360;
// 不参与校验的路径,比如服务端取时间
protected array $except = [
'api/time',
'api/notify/*',
];
public function handle(Request $request, Closure $next): Response
{
if ($this->inExcept($request->pathinfo())) {
return $next($request);
}
$appId = (string) $request->header('X-App-Id', '');
$timestamp = (int) $request->header('X-Timestamp', 0);
$nonce = (string) $request->header('X-Nonce', '');
$sign = (string) $request->header('X-Sign', '');
if ($appId === '' || $timestamp === 0 || $nonce === '' || $sign === '') {
return $this->fail('缺少签名参数', 401);
}
if (!preg_match('/^[A-Za-z0-9]{16,64}$/', $nonce)) {
return $this->fail('nonce 格式不正确', 401);
}
// 1. 时间戳窗口
$drift = abs(time() - $timestamp);
if ($drift > self::WINDOW) {
return $this->fail('请求已过期,请校准本地时间', 401);
}
// 2. 取密钥
$secret = $this->secretOf($appId);
if ($secret === null) {
return $this->fail('app_id 无效', 401);
}
// 3. 先算签名,算对了再占用 nonce
$expect = $this->makeSign($request, $secret);
if (!hash_equals($expect, strtoupper($sign))) {
Log::warning('sign mismatch', [
'app_id' => $appId,
'path' => $request->pathinfo(),
'drift' => $drift,
]);
return $this->fail('签名校验失败', 401);
}
// 4. 占用 nonce,重复即视为重放
$cacheKey = 'api:nonce:' . $appId . ':' . $nonce;
$ok = Cache::store('redis')->set($cacheKey, 1, self::NONCE_TTL, 'nx');
if (!$ok) {
return $this->fail('请求重复提交', 429);
}
return $next($request);
}
protected function makeSign(Request $request, string $secret): string
{
$params = array_merge($request->get(), $request->post());
unset($params['sign']);
$filtered = [];
foreach ($params as $k => $v) {
if (is_array($v)) {
$this->ksortDeep($v);
$filtered[$k] = json_encode($v, JSON_UNESCAPED_UNICODE);
} elseif ($v !== '' && $v !== null) {
$filtered[$k] = (string) $v;
}
}
ksort($filtered, SORT_STRING);
$pairs = [];
foreach ($filtered as $k => $v) {
$pairs[] = $k . '=' . $v;
}
return strtoupper(md5(implode('&', $pairs) . '&key=' . $secret));
}
protected function ksortDeep(array &$arr): void
{
ksort($arr, SORT_STRING);
foreach ($arr as &$v) {
if (is_array($v)) {
$this->ksortDeep($v);
}
}
}
protected function secretOf(string $appId): ?string
{
// 实际项目查库或查配置,这里示意
$map = [
'partner_demo' => 'e10adc3949ba59abbe56e057f20f883e',
];
return $map[$appId] ?? null;
}
protected function inExcept(string $path): bool
{
foreach ($this->except as $pattern) {
if ($path === $pattern) {
return true;
}
if (str_ends_with($pattern, '*')
&& str_starts_with($path, rtrim($pattern, '*'))) {
return true;
}
}
return false;
}
protected function fail(string $msg, int $status): Response
{
return json(['code' => $status, 'msg' => $msg], $status);
}
}
为什么先算签名再占用 nonce
这是第一版里写错的地方,也是整个实现里最值得单独拎出来讲的一点。
最初我把 nonce 的 SETNX 放在了最前面,逻辑是“先挡住重复请求,再校验签名”。测试时一切正常,上线后第三天合作方来投诉,说偶尔会收到“请求重复提交”。查了半天才发现原因:对方的重试机制会在超时后重发同一份请求,nonce 不变。第一次请求的签名是错的(他们那边某个参数拼接顺序不一致),我们把 nonce 消费掉了,等他们修正之后再发,nonce 已经在缓存里躺着了,直接被判重放。
换句话说,校验逻辑如果先消耗资源再判断合法性,就等于让非法请求有能力影响合法请求。虽然 nonce 是随机串,正常情况下撞不上,但重试场景下是完全一样的 nonce,这个问题必然出现。
改法很简单,把 hash_equals 挪到 SETNX 前面。代价是错误签名的请求会多做一次 MD5 计算,但这个开销可以忽略。这里用 hash_equals 而不是 ==,是因为字符串比较在 PHP 里有类型转换的坑,比如 "0e123" == "0e456" 会被当成两个科学计数法的 0 而相等,签名比较必须用这个函数,别图省事。
一个配套的取时间接口
合作方那边反馈最多的问题不是签名,是“请求已过期”。他们的服务器没有同步 NTP,本地时间比我们快了两分多钟。这种情况让对端去调系统时间不现实,通常的做法是我们提供一个接口,让他们拿到服务器时间之后算偏移量。
<?php
// app/controller/Api/Time.php
namespace appcontrollerApi;
class Time
{
public function index()
{
return json([
'code' => 0,
'data' => [
'timestamp' => time(),
'window' => 300,
],
]);
}
}
这个路径放进了中间件的白名单,否则签名都过不了就来不了这。对方客户端在他们启动时先调一次,算出 offset = server_ts - local_ts,之后每次请求都用 time() + offset 作为时间戳。这个改动之后,时间戳相关的告警基本消失了。
客户端怎么签
把规则翻译成代码,对方照着抄就行。用 PHP 写一份示例,他们那边也是 PHP,直接贴过去能跑:
function buildHeaders(array $params, string $appId, string $secret): array
{
$sign = $params;
unset($sign['sign']);
$filtered = [];
foreach ($sign as $k => $v) {
if (is_array($v)) {
ksort($v, SORT_STRING);
$filtered[$k] = json_encode($v, JSON_UNESCAPED_UNICODE);
} elseif ($v !== '' && $v !== null) {
$filtered[$k] = (string) $v;
}
}
ksort($filtered, SORT_STRING);
$pairs = [];
foreach ($filtered as $k => $v) {
$pairs[] = $k . '=' . $v;
}
$plain = implode('&', $pairs) . '&key=' . $secret;
return [
'X-App-Id' => $appId,
'X-Timestamp' => (string) (time() + $GLOBALS['time_offset']),
'X-Nonce' => bin2hex(random_bytes(16)),
'X-Sign' => strtoupper(md5($plain)),
];
}
注意这里用了 random_bytes(16) 生成 32 位十六进制串,而不是 uniqid() 或者 mt_rand()。后者在并发场景下产生重复的概率比想象中高,尤其是 uniqid 基于微秒时间,同一毫秒内多台机器并发调用很容易撞。nonce 一旦重复,第二次请求就会被判重放,很难排查。
用 curl 验一遍
写完中间件之后别急着让对接方来试,自己先跑通,省掉至少两轮来回。
php -r '
$secret = "e10adc3949ba59abbe56e057f20f883e";
$params = ["order_no" => "T20250107001", "amount" => "99.00"];
$filtered = $params;
ksort($filtered, SORT_STRING);
$pairs = [];
foreach ($filtered as $k => $v) { $pairs[] = "$k=$v"; }
$plain = implode("&", $pairs) . "&key=" . $secret;
echo "sign=" . strtoupper(md5($plain)) . "n";
'
拿到签名之后用 curl 发:
curl -X POST 'https://example.com/api/order'
-H 'X-App-Id: partner_demo'
-H 'X-Timestamp: 1736234567'
-H 'X-Nonce: 8f14e45fceea167a5a36dedd4bea2543'
-H 'X-Sign: 计算出来的签名'
-d 'order_no=T20250107001'
-d 'amount=99.00'
验证顺序建议这样:先拿正确的请求跑一次,应该通;然后原样再发一次,应该返回 429;改一个参数但不改签名,应该返回 401;把时间戳改成十分钟前,应该返回“请求已过期”。四条都对了,基本功能就没问题。
四个实际踩过的坑
一、请求体读两次会变成空的
中间件里如果为了打日志写了 file_get_contents('php://input'),后面再用 $request->post() 就会发现参数是空的。原因是 php://input 在某些 SAPI 下只能读一次。正确做法是用 ThinkPHP 的 $request->getContent(),它内部有缓存,随便读几次都行。这个问题在本地用 PHP 内置服务器测试时不一定复现,一上 Nginx + PHP-FPM 就必现,很容易被坑。
二、数组参数的序列化必须两边一致
第一版中间件里,数组参数我直接用了 http_build_query,但客户端用的是 json_encode,两边算出来的签名自然对不上。而且更麻烦的是,http_build_query 会对键做 URL 编码,中文键名直接被转义,跟明文完全不一样。
最终约定是:数组一律 json_encode,序列化前递归 ksort,JSON 不加空格。第三条容易被忽略,PHP 默认的 json_encode 不带空格,但某些语言的实现默认会带,比如 Python 的 json.dumps 默认 separators 是 ', ' 和 ': ',出来的字符串就不一样了。如果对接方不是 PHP,一定要提醒他们设置紧凑格式。
三、GET 和 POST 同名参数会被静默覆盖
array_merge($request->get(), $request->post()) 这个写法,POST 里同名的参数会把 GET 里的盖掉。绝大多数情况下没问题,但如果恶意请求构造了 ?amount=1 加上表单里的 amount=1000,中间件算签名用的是 1000,而业务代码某处如果只读了 $request->get('amount'),就会出现签名通过但金额不对的情况。
我最后的处理是:检测到同名键直接拒绝,返回 400。这个判断只有三行代码,却能堵住一整类绕过手法。业务代码那边也顺手统一了,参数一律走 $request->param(),避免再出现读错了取值来源的情况。
四、Redis 挂掉的时候不能放行
nonce 存在 Redis 里,Redis 一挂,Cache::set() 会抛异常。这时候有两种选择:fail open(放行)和 fail closed(拒绝)。
我选了拒绝,理由是这套校验本身就是为了防刷,一旦在故障时放行,攻击者只要把 Redis 打挂就能绕过所有限制,等于白做。拒绝的代价是 Redis 故障期间开放接口不可用,这个影响面是可接受的,毕竟核心业务走的是内部接口,不走这套签名。
但拒绝不能是抛一个 500 给对端,那样他们会以为是我们的 bug。我在中间件里包了一层 try/catch,捕获 Redis 异常之后返回 503 和一句明确的提示,同时打一条 error 日志。对端看到 503 会走重试,而不是去调整签名逻辑,这个区分很重要。
一个必须说清楚的局限
这套签名机制适合服务端对服务端的调用,不适合直接放在浏览器里用。原因很简单:签名需要 secret,只要 secret 下发到前端,不管是混淆进 JS 还是塞进 localStorage,都是可以被逆向出来的。一旦 secret 泄露,整套校验就形同虚设。
如果是自己的 H5 或小程序需要防重放,应该用登录态 token 加服务端的幂等键,幂等键建议用业务唯一字段组合(比如 user_id + order_no)来做缓存占位,思路和这里的 nonce 是一样的,只是不需要签名。这两套东西不要混着用,混起来反而两头都做不干净。
收个尾
中间件上线到现在跑了两个多月,重复订单的问题解决了,中间还顺手拦下过几次异常流量——有个 IP 一直在用错误签名试探,日志里一天几百条 sign mismatch,加了个简单的按 app_id 计数告警之后能提前发现这类事情。
这套代码总共不到两百行,改动量最大的其实不是中间件本身,而是把业务代码里那些 $request->get('xxx') 统一换成了 $request->param('xxx')。如果你们项目也有类似的历史遗留,改中间件的时候顺手一起做了,不然漏洞还在,只是从签名这一层转移到了参数读取那一层。

