ThinkPHP 8中间件实战:为API接口加签名校验,我踩了哪些坑

2026-08-17 0 920

最近公司把老项目升级到ThinkPHP 8,顺便给开放API加了个签名校验。本来想用现成的扩展包,但看了几个都不太贴合自己的路由规则,于是干脆用中间件自己写了一个。整个流程写下来也不复杂,但是里面有些细节确实容易栽跟头,今天把实现过程分享出来。

为什么要用中间件做签名校验

签名校验说白了,就是客户端请求时带上几个参数,再根据一个密钥生成一段签名,服务端接收到之后,按照同样的规则计算一遍签名,比对是否一致。如果一致就放行,否则拒绝请求。

这个逻辑如果写在每个控制器里,那会重复到吐。放在中间件里就清爽多了,一个中间件统一处理,只要在路由上挂载一下,指定哪些接口需要校验签名就行。

路由文件里的设置

我的项目在 route/app.php 里定义API路由,结构大概是这样:

Route::group('api', function () {
    Route::post('order/create', 'api/Order/create');
    Route::post('order/detail', 'api/Order/detail');
})->middleware(ApiSignMiddleware::class);

只有 api 分组下的接口才需要校验签名,其它的正常访问。中间件类我放在了 app/middleware/ApiSignMiddleware.php

中间件基本框架

ThinkPHP 8中间件必须实现 handle($request, Closure $next) 方法,返回一个 Response。我写的初版长这样:

namespace appmiddleware;

use Closure;
use thinkRequest;
use thinkResponse;

class ApiSignMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // 获取所有请求参数
        $params = $request->param();
        $sign = $params['sign'] ?? '';

        // 校验签名
        if (!$this->verifySign($params, $sign)) {
            return json(['code' => 4, 'msg' => '签名验证失败']);
        }

        return $next($request);
    }
}

然后这个 verifySign 方法就是核心。老项目之前用 MD5 做签名,我这次依旧沿用 MD5,但加了一个时间戳防重放攻击。

签名算法设计

我的规则是:客户端把所有非 sign 参数,按照参数名字母升序拼接成字符串,在末尾拼接上密钥,然后做 MD5。为了防重放,还要求带一个 timestamp 参数,并且服务端只接受与当前时间相差不超过5分钟的请求。

private function verifySign(array $params, string $sign): bool
{
    // 必须要有timestamp字段
    if (empty($params['timestamp'])) {
        return false;
    }

    // 时间戳不能超过5分钟
    if (abs(time() - intval($params['timestamp'])) > 300) {
        return false;
    }

    // 把sign移除,剩下参数都要参与签名
    unset($params['sign']);
    // 按字母升序排序
    ksort($params);
    // 拼接成 http_build_query 会带urlencode,用http_build_query更规范
    $queryString = http_build_query($params);
    // 加上密钥
    $key = 'my-secret-key';
    $calcSign = md5($queryString . $key);

    return hash_equals($calcSign, $sign);
}

这里有个小坑:http_build_query 会把数组值做 urlencode,并且把空格变成 +,所以客户端用同样的方式生成签名才不会出错。为了避免多余的“意外”,我要求客户端拼参数字符串时,直接用 http_build_query 来拼接。

中间件里怎么拿到原始请求参数

有人可能用 $request->get() 或者 $request->post(),但这样漏掉一部分参数。我干脆用 $request->param(),它是 GET、POST、路由参数合并后的结果。

但有一个坑:如果客户端传了一个数组参数,比如 ids[]=1&ids[]=2http_build_query 默认会变成 ids%5B0%5D=1&ids%5B1%5D=2,和客户端的 ids[0]=1&ids[1]=2 不一样。所以需要给 http_build_query 传一个参数:

$queryString = http_build_query($params, '', '&');

不过PHP_QUERY_RFC3986 还是RFC1738,这个得跟客户端统一。我最后用了最原始的方法:自己遍历数组拼接。

我就踩了这个坑:客户端拿Java写的,他们用 HashMap 转 query string,没有做 urlencode,签名就是拼原始字符串。我这边 http_build_query 自动把中文和空格转义了,两边签名永远对不上。后来我跟客户端约定:不要用框架自带的拼接方式,自己写一个简单的递归函数把所有参数拼成 k1=v1&k2=v2 这种格式,并且不对值进行编码。

改成自定义拼接,更不容易出幺蛾子

private function buildQueryString(array $params): string
{
    ksort($params);
    $parts = [];
    foreach ($params as $key => $value) {
        if (is_array($value)) {
            // 对于数组,按key索引递归拼接
            foreach ($value as $k => $v) {
                $parts[] = $key . '[' . $k . ']=' . $v;
            }
        } else {
            $parts[] = $key . '=' . $value;
        }
    }
    return implode('&', $parts);
}

这样客户端用同样的逻辑拼接就完全一致。这种方式虽然原始,但胜在可控,双方只要约定好规则,就不会被HTTP库的编码坑到。

签名校验通过后,把数据传给控制器

有时候控制器里还需要使用客户端传递的某个参数,比如用户ID。由于中间件里已经对参数做了处理,可以直接修改请求参数:

// 通过中间件后,给请求注入一个额外参数
$request->withParams(['client_id' => $params['client_id'] ?? '']);

我实际用的地方不多,但如果有需要,可以在校验成功后用 $request->withParams() 把一个你处理过的数据传给控制器,控制器通过 $request->param('client_id') 获取。

和路由/控制器的配合

这里有一点要注意:中间件里如果抛出异常或者返回Response,后面的控制器就不会执行。这一点非常好用,但也会带来一个小问题:如果签名校验失败,这算是一次正常的业务请求还是异常?我返回的是 json(['code' => 4, 'msg' => '签名验证失败']),不过这种形式在日志记录里看不出来。后来我在中间件里加了一个日志记录,方便追踪谁在调用。

thinkfacadeLog::warning('签名校验失败', ['params' => $params]);

到了生产环境,这个日志记得关掉,不然日志量大得吓人。

在全局中间件里排除某些路由

后来我又加了一个需求:后台管理接口不需要签名,但前台API需要。所以不能把它注册成全局中间件。我在路由分组上挂载这个中间件,这样只对 api 分组生效。

还有一种情况:某些接口是公开的,比如用户登录、获取验证码,不需要签名。可以在中间件里做一个白名单,检测到当前请求的路由地址在名单里就直接放行。

private $except = [
    'api/login',
    'api/captcha',
];

if (in_array($request->pathinfo(), $this->except)) {
    return $next($request);
}

但这样如果以后新增开放接口,容易忘往白名单里加。我更倾向于把这些不用校验的接口放在另一个路由分组里,单独不挂中间件。这样结构清晰,不用在中间件里维护一堆名单。

ThinkPHP中间件的一个小吐槽

中间件可以对请求进行预处理,但是如果是通过容器注入的依赖对象,在不同中间件里可能是同一个实例,这容易造成状态污染。我在 handle 方法里用到了 verifySign 里的自定义拼接函数,那是一个纯函数,不保存状态,所以没问题。但如果你在中间件里用了成员变量存数据,下次请求的时候这些变量还在,会引发很难排查的问题。建议中间件里避免使用成员变量存放请求相关的数据,除非是用 request 属性绑定。

后来我干脆把签名校验逻辑抽成了一个独立的类 ApiSignUtils,中间件只负责调用它,这样就不怕状态污染了。

测试阶段的一些细节

  • 用curl模拟签名请求时,参数顺序可能不对,但是 ksort 之后就没有顺序问题了。
  • 如果请求体是JSON,$request->param() 是拿不到内容的。实际上我的API接收的都是常规表单格式,所以没遇到这个问题。如果你要支持JSON,需要自己读取php://input然后解析。
  • 时间戳校验那里:我用的是 abs(time() - intval($timestamp)) > 300,如果客户端服务器时间差较大就会出错。建议把阈值放宽到10分钟,或者允许客户端传一个nonce来防重放,而不是死扣时间。

最终中间件长得挺普通

<?php
namespace appmiddleware;

use Closure;
use thinkRequest;
use thinkResponse;

class ApiSignMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $params = $request->param();
        $sign = $params['sign'] ?? '';

        if (!$this->verify($params, $sign)) {
            return json(['code' => 4, 'msg' => '签名验证失败']);
        }

        return $next($request);
    }

    private function verify(array $params, string $sign): bool
    {
        if (empty($params['timestamp']) || abs(time() - (int)$params['timestamp']) > 300) {
            return false;
        }

        unset($params['sign']);
        ksort($params);
        $str = $this->buildQueryString($params);
        $calcSign = md5($str . 'your-secret-key');

        return hash_equals($calcSign, $sign);
    }

    private function buildQueryString(array $params): string
    {
        $parts = [];
        foreach ($params as $key => $value) {
            if (is_array($value)) {
                foreach ($value as $k => $v) {
                    $parts[] = "{$key}[{$k}]={$v}";
                }
            } else {
                $parts[] = "{$key}={$value}";
            }
        }
        return implode('&', $parts);
    }
}

注意我用的密钥写在中间件里,实际上还可以放到 .env 或配置文件里。我这里图省事直接写死了。生产环境一定不要这样。

写在最后

用中间件做API签名校验,代码不多,逻辑也简单,真正烦人的是客户端联调时大家对于拼接规则的理解不一致。我的建议是:把签名规则写进接口文档,同时给出一个PHP示例和Java示例,让对方照着写。不要默认别人会跟你用同样的编码规则。如果你刚开始用ThinkPHP中间件,从一个签名校验入手是个不错的选择,很快就能体会到中间件在请求前置处理上的便利。

ThinkPHP 8中间件实战:为API接口加签名校验,我踩了哪些坑
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8中间件实战:为API接口加签名校验,我踩了哪些坑 https://www.taomawang.com/server/thinkphp/2556.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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