ThinkPHP 8 接口防重放实战:签名、时间戳与 nonce 中间件一次写透

2026-11-04 0 474

事情的起点是一笔重复订单。合作方那边网络抖动,客户端把同一个下单请求重发了一次,我们这边照单全收,用户被扣了两份钱。翻日志的时候发现更糟的问题:那个开放接口压根没做任何校验,只要知道 URL,谁都能调。

补漏洞的方案不复杂,加签、校验时间戳、校验随机串,三件套。难的是把它写对——我在中间件里前后改了三版才稳定下来,其中有两处是把逻辑顺序搞错了才会出现“看起来没问题但偶尔失效”的情况。这里把最终版本和踩坑记录都摆出来。

先把签名规则定死

规则定了就不能随便改,所以第一步是写清楚,而不是先写代码。

  1. 请求头带四个字段:X-App-Id、X-Timestamp(秒级)、X-Nonce(32 位随机串)、X-Sign。
  2. 参与签名的内容:所有 GET 和表单参数,剔除 sign 本身,按参数名 ASCII 升序排序。
  3. 拼接格式为 k1=v1&k2=v2,不做 URL 编码,空字符串参数不参与。
  4. 数组参数统一用 json_encode 序列化,序列化前对数组内部按 key 排序。
  5. 末尾追加密钥:...&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')。如果你们项目也有类似的历史遗留,改中间件的时候顺手一起做了,不然漏洞还在,只是从签名这一层转移到了参数读取那一层。

ThinkPHP 8 接口防重放实战:签名、时间戳与 nonce 中间件一次写透
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 接口防重放实战:签名、时间戳与 nonce 中间件一次写透 https://www.taomawang.com/server/thinkphp/3011.html

常见问题

相关文章

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

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