事情起因很简单。我们给合作方开放了一批查询接口,走的是 AppId + AppSecret 的认证方式。原本只是内部用,跑了大半年没事。等到接进来的合作方多了,有天运维在 Nginx 日志里发现某个 AppId 的请求量突然翻了几十倍,而且 User-Agent 全是空的。
扒了一下日志,发现对方把我们文档里的示例代码直接抄过去跑压测了,AppSecret 明文写在客户端里,签名逻辑形同虚设——反正只要 AppSecret 对,参数怎么改都能签过。更麻烦的是他拿一份合法的请求重复发了上万次,每次都当新请求处理,后端一点察觉都没有。
这就是我们今天要解决的三个问题:怎么确认请求确实来自持有 AppSecret 的合法方、怎么确保同一个请求不会被重复消费、怎么在异常流量下保护后端。对应到实现上,就是签名验签、nonce 防重放、滑动窗口限流。
下面把整套东西的落地过程写下来,包括我在其中踩的七个坑。
先把签名规则定死
签名这件事最怕的就是规则含糊。一旦双方对”参数怎么排序””空值参不参与””编码用什么”理解不一致,就会出现”我这边算出来是对的,对方算出来就是对不上”的扯皮。
所以第一件事是把规则写清楚,写到文档里,写成可执行的测试用例。
我们定的规则是这样:
客户端每次请求带四个头:X-App-Id、X-Timestamp(秒级 Unix 时间戳)、X-Nonce(32 位随机字符串)、X-Sign(签名结果)。
签名串的构造方式是:把所有参与签名的参数(业务参数 + X-App-Id + X-Timestamp + X-Nonce)按 key 的字典序升序排列,用 k=v 的形式以 & 连接,得到一个待签名字符串。然后用 AppSecret 作为密钥,对这个字符串做 HMAC-SHA256,输出小写十六进制。
参与签名的业务参数范围要明确:URL 查询串里的参数、JSON body 里的顶层字段。数组要按 key 递归排序后再展开。空字符串和 null 一律不参与签名。文件流不参与签名(后面单独说为什么)。
这个规则听着不复杂,但每一个字都要抠。下面代码里我会把每个决定都解释一遍。
中间件骨架
ThinkPHP 8 的中间件放在 app/middleware/ 目录下,通过 app/middleware.php 全局注册,或者在路由分组上注册。我们这个签名校验只针对 /api/open/* 开头的接口,所以走路由分组注册更合适。
<?php
declare(strict_types=1);
namespace appmiddleware;
use Closure;
use thinkRequest;
use thinkResponse;
use thinkfacadeCache;
use appcommonserviceAppCredentialService;
class ApiSignature
{
// 时间戳允许的偏差窗口,单位秒
private const TIME_WINDOW = 300;
// nonce 在 Redis 里的存活时间,应该略大于时间窗口
private const NONCE_TTL = 360;
private AppCredentialService $credentialService;
public function __construct(AppCredentialService $credentialService)
{
$this->credentialService = $credentialService;
}
public function handle(Request $request, Closure $next): Response
{
$appId = (string) $request->header('X-App-Id', '');
$timestamp = (string) $request->header('X-Timestamp', '');
$nonce = (string) $request->header('X-Nonce', '');
$sign = (string) $request->header('X-Sign', '');
if ($appId === '' || $timestamp === '' || $nonce === '' || $sign === '') {
return json(['code' => 40001, 'msg' => '缺少签名参数'])->code(401);
}
// 1. AppId 是否存在
$credential = $this->credentialService->findByAppId($appId);
if ($credential === null) {
return json(['code' => 40002, 'msg' => 'AppId 无效'])->code(401);
}
if ((int) $credential['status'] !== 1) {
return json(['code' => 40003, 'msg' => '应用已停用'])->code(403);
}
// 2. 时间戳窗口
$now = time();
$ts = (int) $timestamp;
if (abs($now - $ts) > self::TIME_WINDOW) {
return json(['code' => 40004, 'msg' => '请求时间戳超出允许范围'])->code(401);
}
// 3. 计算签名并比对
$params = $this->collectSignParams($request);
$expect = $this->makeSign($params, $appId, $timestamp, $nonce, $credential['app_secret']);
if (!hash_equals($expect, strtolower($sign))) {
return json(['code' => 40005, 'msg' => '签名校验失败'])->code(401);
}
// 4. nonce 防重放
if (!$this->checkNonce($appId, $nonce)) {
return json(['code' => 40006, 'msg' => '请求已失效,请勿重复提交'])->code(401);
}
// 5. 把身份信息塞进 request,后续控制器直接用
$request->appId = $appId;
$request->appInfo = $credential;
return $next($request);
}
private function collectSignParams(Request $request): array
{
// 查询串 + body 里的参数
$params = $request->param();
// 去掉文件字段和框架注入的额外字段
unset($params['file'], $params['__token__']);
return $this->normalize($params);
}
// 递归清理 + 排序,保证双方算出来一模一样
private function normalize(array $params): array
{
$result = [];
foreach ($params as $key => $value) {
if ($value === null) {
continue;
}
if (is_string($value) && $value === '') {
continue;
}
if (is_array($value)) {
$value = $this->normalize($value);
if ($value === []) {
continue;
}
}
// 布尔值统一转成 "1" / "0",避免 true/false 与 1/0 不一致
if (is_bool($value)) {
$value = $value ? '1' : '0';
}
$result[$key] = $value;
}
ksort($result);
return $result;
}
private function makeSign(
array $params,
string $appId,
string $timestamp,
string $nonce,
string $appSecret
): string {
$params['X-App-Id'] = $appId;
$params['X-Timestamp'] = $timestamp;
$params['X-Nonce'] = $nonce;
ksort($params);
// 关键:必须用 RFC3986 编码,不能用默认的 urlencode
$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
return hash_hmac('sha256', $query, $appSecret);
}
private function checkNonce(string $appId, string $nonce): bool
{
$key = 'api:nonce:' . $appId . ':' . md5($nonce);
// SET NX EX,返回 true 表示这个 nonce 之前没出现过
return (bool) Cache::store('redis')->handler()
->set($key, 1, ['nx', 'ex' => self::NONCE_TTL]);
}
}
这段代码里几个地方是刻意的。
hash_equals 而不是 ===。普通字符串比较在第一个字符不同时就会返回,比较耗时会随时间泄露信息。理论上攻击者可以通过大量请求测量响应时间,逐字节猜出正确的签名。虽然实际利用门槛很高,但这是一行代码的事,没必要省。
strtolower($sign)。十六进制大小写要统一。有的客户端用 hash_hmac 的输出(小写),有的用 strtoupper。服务端统一转小写再做比对,双向兼容。
PHP_QUERY_RFC3986。这个是重灾区。PHP 的 http_build_query 默认会把空格编码成 +,而绝大部分语言的 URL 编码标准是把空格编码成 %20。Java 客户端、Go 客户端、JS 客户端算出来的签名都会和 PHP 默认行为对不上。这个坑我见过太多次,每次都要重新排查一遍,加上这个参数就一劳永逸。
注册中间件,顺序是天大的事
路由分组注册大概长这样:
<?php
// route/app.php
use thinkfacadeRoute;
Route::group('api/open', function () {
Route::get('product/:id', 'api.Product/detail');
Route::post('order/create', 'api.Order/create');
// ...
})->middleware([
appmiddlewareCors::class, // 必须排第一
appmiddlewareApiRateLimit::class, // 粗粒度限流
appmiddlewareApiSignature::class, // 验签 + 防重放
appmiddlewareApiRateLimitFine::class, // 细粒度限流(验签后才知道 appid)
]);
这里我要重点说说第一个坑。
坑一:CORS 中间件必须排在最前面,否则 OPTIONS 预检直接挂。
浏览器在发送跨域的 POST 请求之前,会先发一个 OPTIONS 预检请求。这个预检请求不会带业务参数,也不会带我们的自定义头(X-App-Id 那一套)。如果你把验签中间件放在 CORS 前面,预检请求会在验签那一步被拦住返回 401。
后果是什么?浏览器看到预检失败,直接不发送真正的请求,控制台报一个含糊的 CORS 错误。你会以为是跨域配置写错了,去翻 Access-Control-Allow-Origin,翻半天没发现问题。实际上罪魁祸首是验签中间件。
我当时被这个坑卡了大半天。后来在 CORS 中间件里加了 OPTIONS 短路:
<?php
declare(strict_types=1);
namespace appmiddleware;
use Closure;
use thinkRequest;
use thinkResponse;
class Cors
{
public function handle(Request $request, Closure $next): Response
{
$headers = [
'Access-Control-Allow-Origin' => $request->header('origin', '*'),
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, X-App-Id, X-Timestamp, X-Nonce, X-Sign',
'Access-Control-Max-Age' => '86400',
'Access-Control-Allow-Credentials' => 'true',
];
// 预检请求直接返回,不要往下走
if ($request->method() === 'OPTIONS') {
return response('', 204)->header($headers);
}
$response = $next($request);
return $response->header($headers);
}
}
ThinkPHP 的中间件是洋葱模型,注册顺序就是进入顺序,返回顺序相反。Cors 排在最前面,就能保证无论后面哪个中间件返回什么响应,CORS 头都能加上去。
坑二:粗粒度限流和验签的顺序。
有人会问,为什么限流要拆成两个:ApiRateLimit 和 ApiRateLimitFine?
因为验签这一步本身是有成本的——两次 Redis 查询(查 AppId 和查 nonce)加上一次 HMAC 计算。如果攻击者用随机 AppId 大量刷请求,这些请求虽然都会被拒绝,但每次都要消耗一次 Redis 查询。
所以粗粒度限流要在最前面,按 IP 和 AppId(如果带了的话)限。它是纯计数的,成本最低。超过阈值直接返回 429,连验签都不用做。
细粒度限流放在验签之后,按路径和方法限。因为这时候已经能确认请求方是合法的,可以给他一个相对宽松的额度。
滑动窗口限流,用 ZSET 而不是计数器
固定窗口计数器有个经典问题:假设限额是每分钟 100 次。攻击者在第 59 秒发 100 次,第 61 秒再发 100 次。这两百次请求都落在各自的窗口内,都合法,但实际上一秒钟内发了两百次。这就是窗口边界的突刺问题。
滑动窗口能解决这个问题。用 Redis 的 ZSET:member 是请求的唯一标识,score 是请求的毫秒时间戳。每次请求进来,先删掉窗口外的成员,再看剩下的数量是否超过限额。
这三步如果分开做,中间会有并发窗口。必须写成 Lua 脚本让 Redis 原子执行:
<?php
declare(strict_types=1);
namespace appmiddleware;
use Closure;
use thinkRequest;
use thinkResponse;
use thinkfacadeCache;
use thinkfacadeLog;
class ApiRateLimit
{
// 窗口大小 60 秒,最多 600 次
private const WINDOW_MS = 60000;
private const MAX_REQ = 600;
private const LUA = <<<'LUA'
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window + 1000)
return 1
LUA;
public function handle(Request $request, Closure $next): Response
{
$appId = (string) $request->header('X-App-Id', '');
$ip = $request->ip();
// 已认证的按 appId 限,没认证的按 IP 限
$scope = $appId !== '' ? 'app:' . $appId : 'ip:' . $ip;
$key = 'api:rate:' . md5($scope);
$now = (int) (microtime(true) * 1000);
$member = $now . ':' . bin2hex(random_bytes(6));
try {
$allowed = Cache::store('redis')->handler()->eval(
self::LUA,
[$key, $now, self::WINDOW_MS, self::MAX_REQ, $member],
1
);
} catch (Throwable $e) {
// Redis 挂了不能把业务一起拖死,放行并记日志
Log::error('rate limiter failed: ' . $e->getMessage());
return $next($request);
}
if (!$allowed) {
return json([
'code' => 42901,
'msg' => '请求过于频繁,请稍后再试',
])->code(429);
}
return $next($request);
}
}
这里有个设计决定值得讨论:Redis 挂掉时是放行还是拒绝?
我选的是放行,同时打 error 日志。理由是这个限流是为了防刷,不是为了防 DDoS。真被 DDoS 了,Redis 和这套限流本身也拦不住,得靠 WAF 和 CDN。业务可用性比限流本身更重要。但如果你做的是金融或者风控场景,那就该反过来——宁可拒绝也不能放行。这个决定要结合业务做,没有标准答案。
另外 ZSET 的 member 一定不能用纯时间戳。microtime(true) 乘以 1000 取整之后,同一毫秒内多个请求会拿到同样的值,ZADD 是去重的,就会导致计数偏少,限流不准。所以上面加了随机字节做后缀。
坑三:时间戳的时区问题
文档里写的是”秒级 Unix 时间戳”,但还是有合作方那边算错。
最常见的情况是 Go 客户端。Go 的 time.Now().Unix() 返回的确实是对的,但有人用了 time.Now().Format("20060102150405"),那就是 20250108120000 这样一个 14 位数字,服务端解析的时候会当成一个超大的时间戳,直接判定超出窗口,返回”时间戳超出允许范围”。
还有一种情况是 JS 客户端。JS 的 Date.now() 返回的是毫秒,有人直接把毫秒传过来了。服务端 (int) $timestamp 解析出来是 1736300000000,abs($now - $ts) 差出来天文数字,一样报错。
我们的处理方式是在错误信息里把当前服务端时间戳返回给对方,让他们自己对比:
return json([
'code' => 40004,
'msg' => '请求时间戳超出允许范围',
'server_ts' => $now,
])->code(401);
这样对方一对比就能发现问题。另外文档里明确写清楚”秒级、10 位数字、必须用 UTC 时间戳”,能少一半沟通成本。
顺便说一句,时间窗口设 300 秒是有讲究的。太小了客户端时钟稍微不准就挂,太大了防重放的窗口期变长,nonce 的 Redis 存储压力也大。300 秒是我们试出来的平衡点。
坑四:文件上传为什么不能参与签名
我们有个接口允许合作方上传对账单文件。一开始是把文件字段也塞进签名里的,试了几个客户端全对不上。
原因在于不同语言对 multipart/form-data 的编码方式不一样。PHP 的 $request->param() 拿到的是文件对象的一部分信息,而 JS 的 FormData、Java 的 HttpClient,它们传给服务端的是完全不同的字节流。你拿服务端解析后的值去签名,和客户端签名时用的值根本不是一回事。
正确的做法是把文件排除在签名之外,改为在请求参数里加一个 file_md5 字段,客户端先把文件内容算出 MD5 放进参数里,服务端收到文件后再算一遍,比对 MD5 是否一致。这样既能保证文件没被中间篡改,又避开了编码差异。
下面这个 normalize 方法里的 unset($params['file']),就是干这个的。
坑五:JSON body 里的嵌套数组
我们有个创建批量订单的接口,body 是这样的:
{
"shop_id": 1001,
"items": [
{"sku_id": 2001, "num": 2},
{"sku_id": 2002, "num": 1}
],
"remark": "尽快发货"
}
如果直接把 items 当成一个普通字段拼进签名串,PHP 里 (string)$array 会变成 “Array”,客户端那边可能是 JSON.stringify 后的字符串,两边永远对不上。
我的处理是递归展开。items[0][sku_id]=2001&items[0][num]=2&items[1][sku_id]=2002&items[1][num]=1 这样拼。规则写在文档里,客户端照着实现一遍就行。
不过说实话,这种深层嵌套的签名计算对客户端不太友好。如果有得选,我会建议把复杂结构拆成独立的接口,让参与签名的参数保持扁平。这不是技术上的限制,是协作成本上的考量。
坑六:同一个 nonce 到底该缓存多久
nonce 的 TTL 设多少,初看是个小问题,实际上有两种思路。
第一种是 TTL 等于时间窗口,比如 300 秒。理论上 300 秒之前的请求已经因为时间戳过期被拒绝了,所以 nonce 也没必要留更久。缓存更小,成本更低。
第二种是 TTL 比时间窗口长一点,比如 360 秒。这样即使因为时钟漂移、网络延迟,某个请求的时间戳刚好卡在窗口边缘,也不会因为 nonce 缓存提前失效而被误判为重放。
我选的是第二种。多出的那 60 秒,Redis 存储成本可以忽略,但能避免一些边缘情况下的误伤。
另外一个细节:nonce 的缓存 key 要不要包含路径?
我的做法是不包含。也就是同一个 AppId 下,nonce 全局唯一。这样更安全——攻击者不能通过换一个路径来复用同一个 nonce。代价是合作方如果要做多请求并发,就必须保证每个请求的 nonce 都不一样。这个要求在文档里明确写出来就行,本身就是 nonce 的定义。
坑七:ThinkPHP 8 里请求头拿不到
Nginx 默认会把带下划线的请求头丢掉,因为下划线在 HTTP 头里是不标准的。虽然我们用的是 X-App-Id 这种带连字符的头,问题不大,但如果你自己或者合作方那边用了 X_App_Id 这种命名,就会在一个莫名其妙的地方丢掉。
这个坑我在另一个项目里遇到过,排查了很久才发现是 Nginx 的 underscores_in_headers 配置项。如果确实要支持下划线,得在 nginx.conf 里加上 underscores_in_headers on;,或者统一改用连字符。
还有一个相关的:如果你用了 CDN,某些 CDN 会对自定义请求头做处理,比如忽略大小写、或者过滤掉不认识的头。这种情况只能通过抓包确认,没法靠代码解决。
客户端示例,用来对齐协议
文档写清楚之后,我给合作方写了一份 PHP 的参考实现。虽然实际用的语言五花八门,但有一份能跑的参考代码,沟通成本会低很多。
<?php
function buildSignedRequest(string $url, array $params, string $appId, string $appSecret): array
{
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$signParams = $params;
$signParams['X-App-Id'] = $appId;
$signParams['X-Timestamp'] = $timestamp;
$signParams['X-Nonce'] = $nonce;
ksort($signParams);
$query = http_build_query($signParams, '', '&', PHP_QUERY_RFC3986);
$sign = hash_hmac('sha256', $query, $appSecret);
return [
'headers' => [
'X-App-Id: ' . $appId,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Sign: ' . $sign,
'Content-Type: application/json',
],
'url' => $url,
'body' => json_encode($params, JSON_UNESCAPED_UNICODE),
];
}
注意这个函数是给合作方看的,不是我们自己的内部代码,重点是清晰地展示规则,而不是写得多么优雅。这种”给外人看”的代码,可读性永远排在第一位。
上线之后的效果
改造上线大概跑了一个月,几个数据可以分享一下。
签名校验失败率稳定在 0.03% 左右。这部分基本都是合作方那边新接入时的调试问题,或者时钟没同步导致的。我们把失败请求的详情(不含敏感信息)记到日志里,出了问题是能直接甩给对方的。
nonce 重复拒绝的次数一周大概几十次。全都是合作方那边重试逻辑写得有问题——请求超时之后直接重发原请求,nonce 没换。这种情况我们给他们的建议是:重试必须重新生成 nonce 和 timestamp,重新签名。重试的语义是”再发一次新的请求”,不是”把刚才那个请求再发一遍”。
限流拦截的请求峰值得说一下。上线第一周拦截量很大,因为合作方那边有个定时任务在凌晨批量拉数据,没有加退避。我们主动联系他们改了任务调度,把请求分散到全天。之后拦截量降到了每天几十次,基本都是异常情况。
性能方面,这套中间件给每个请求增加的耗时大概是 1.5 毫秒。其中 Redis 的两次调用(nonce 检查和限流)占了大头。这个开销在我们能接受的范围内,因为核心业务接口本身就要几十毫秒。
如果对延迟特别敏感,能做的优化有两件事:一是把 nonce 检查合并到限流的那次 Redis 调用里,用一个 Lua 脚本一次做完;二是用本地缓存挡一层幂等判断,把 Redis 的调用频次降下来。不过这两件事都会增加复杂度,我们暂时没做。
写在最后
这套东西写完回头看,其实没有什么高深的技术。签名是 HMAC,防重放是 Redis SETNX,限流是 ZSET。难点全在细节上——编码格式、参数排序、中间件顺序、时区、边界窗口。
我的经验是:接口安全相关的代码,一定要写测试。而且要写”跨语言”的测试。不只是 PHP 端自己签名自己验,而是拿 Java、Go、JS 各写一个最小客户端,跑一遍完整的签名流程,看能否通过。这样能提前发现那些”在 PHP 里跑得好好的,换一种语言就不对”的问题。
最后一个建议:不要自己造密码学的轮子。HMAC-SHA256 已经足够安全,别想着搞什么自定义的混淆算法。安全靠的是密钥管理和协议设计,不是算法的复杂度。前面提到那个被刷的接口,问题从来不是算法不够复杂,而是签名之外没有任何其他防护。

