ThinkPHP 8 开放接口加固:签名验签、防重放、滑动窗口限流三件套

2026-10-17 0 429

事情起因很简单。我们给合作方开放了一批查询接口,走的是 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 已经足够安全,别想着搞什么自定义的混淆算法。安全靠的是密钥管理和协议设计,不是算法的复杂度。前面提到那个被刷的接口,问题从来不是算法不够复杂,而是签名之外没有任何其他防护。

ThinkPHP 8 开放接口加固:签名验签、防重放、滑动窗口限流三件套
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 开放接口加固:签名验签、防重放、滑动窗口限流三件套 https://www.taomawang.com/server/thinkphp/2954.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务