ThinkPHP8中间件实战:手写API签名验证与权限控制系统

2026-08-30 0 798

大概上个月接了一个外包项目,对方要求APP端所有接口都要做签名校验,防止有人抓包后拿着参数乱改。项目用的是ThinkPHP8,虽然框架自带了中间件功能,但网上的教程大多只讲了”怎么创建一个中间件”这种入门玩意,真正贴合业务场景的例子很少。这篇文章我把实际项目中总结出来的东西写出来,希望对你有用。

为啥不用框架自带的验证机制

ThinkPHP8的门禁(validate)和路由中间件都能做参数校验,但签名验证这事儿比较特殊:得先拿到完整请求参数,按规则排序拼接,再加上密钥生成签名,然后跟客户端传上来的签名比对。这套逻辑放在控制器里写有点浪费,放中间件里更干净。而且中间件可以先于控制器处理请求,签名不对直接拒绝,连控制器都不用进。

另外权限控制也是,有些接口登录就能访问,有些接口要特殊权限。如果每个控制器都写一遍权限判断的代码,那代码重复得没法看。用中间件统一处理这些横切关注点,是很合适的场景。

先建立两个中间件

在ThinkPHP8里,中间件可以放在 app/middleware.php 文件里注册,也可以在使用时单独指定。我习惯把中间件类放在 app/middleware 目录下,跟系统自带的中间件分开,看起来清爽一些。

第一个中间件处理签名验证,取名 SignVerify,第二个处理权限检查,取名 PermissionCheck。为啥分两个?因为签名验证是底线,只要调接口就必须验签;权限检查是看当前登录用户有没有资格访问某个接口,还得先知道是谁在访问。所以签名验证要排在权限检查前面。

创建SignVerify中间件

namespace appmiddleware;

class SignVerify
{
    public function handle($request, Closure $next)
    {
        // 获取所有请求参数(不包括路由参数)
        $params = $request->param();
        $sign = $request->header('X-Sign', '');
        $timestamp = $request->header('X-Timestamp', '');

        if (empty($sign) || empty($timestamp)) {
            return json(['code' => 401, 'msg' => '缺少签名参数']);
        }

        // 时间戳检查,防止重放攻击(允许5分钟误差)
        if (abs(time() - (int)$timestamp) > 300) {
            return json(['code' => 401, 'msg' => '请求已过期']);
        }

        // 计算服务端签名
        $serverSign = $this->generateSign($params, $timestamp);

        if (!hash_equals($serverSign, $sign)) {
            return json(['code' => 401, 'msg' => '签名验证失败']);
        }

        return $next($request);
    }

    private function generateSign(array $params, string $timestamp): string
    {
        // 去掉sign参数本身
        unset($params['sign']);

        // 按key排序
        ksort($params);

        // 拼接成字符串
        $str = urldecode(http_build_query($params));

        // 加上密钥和时间戳
        $str .= '&key=' . env('app.app_secret', 'default_secret');
        $str .= '&timestamp=' . $timestamp;

        return md5($str);
    }
}

有一点需要注意:http_build_query 默认会做urlencode,实际对接时客户端那边可能不编码或者编码方式不一样,容易导致签名对不上。我在这里用 urldecode 做了一层还原,就是为了尽量保证两边拼接出的原始字符串一致。如果你跟客户端约定好了全用原始值拼接,可以不用urldecode。但根据我踩过的坑,大部分客户端都会漏掉这一步,所以加上更安全。

另外 env('app.app_secret') 是我在 .env 文件里配置的密钥,不要硬编码在代码里。项目部署到不同的环境,密钥不同,用env读取方便切换。

创建PermissionCheck中间件

namespace appmiddleware;

class PermissionCheck
{
    public function handle($request, Closure $next)
    {
        // 先判断用户是否登录(这里假设登录后把用户ID放在请求头里)
        $userId = $request->header('X-User-Id', 0);
        if (empty($userId)) {
            return json(['code' => 401, 'msg' => '未登录']);
        }

        // 获取当前请求的控制器和方法
        $controller = strtolower($request->controller());
        $action = strtolower($request->action());

        // 定义权限映射:这个只是演示规则
        // 真实项目中建议从数据库或Redis读取
        $permissionMap = [
            'index' => ['User' => ['profile', 'orders']],
            'admin' => ['User' => ['list', 'delete'], 'Order' => ['list']],
        ];

        $access = false;

        if (array_key_exists($controller, $permissionMap)) {
            if (in_array($action, $permissionMap[$controller])) {
                $access = true;
            }
        }

        if (!$access) {
            return json(['code' => 403, 'msg' => '没有权限访问']);
        }

        return $next($request);
    }
}

这个权限判断的逻辑写得比较粗糙,实际项目里权限规则不会这么简单,多半要结合用户角色动态判断。但中间件这种”提前拦截”的思路是一样:拿当前请求的控制器和方法去匹配权限规则,匹配不上就返回403。

我见过有些项目把权限判断写在路由定义里,用 middleware 给不同路由分配不同的中间件。这么做也能用,但路由多了之后很难维护。不如在中间件里统一处理,反正控制器名称和方法名称都能从 $request 里拿到。

注册中间件

app/middleware.php 里注册这两个中间件,顺序很重要:签名验证先执行,权限检查后执行。

return [
    appmiddlewareSignVerify::class,
    appmiddlewarePermissionCheck::class,
];

这样注册是全局中间件,所有请求都会走一遍。如果有些接口不需要签名(比如获取时间戳)、有些接口不需要权限(比如登录接口),你可以在中间件里加白名单判断。

拿SignVerify来说,可以在规则前面加一个白名单数组:

private $whitelist = [
    'public/timestamp' => 1,
];

然后在handle的最前面判断一下:

$path = $request->baseUrl();
if (isset($this->whitelist[$path])) {
    return $next($request);
}

不过我不建议在中间件里硬编码白名单,更好的做法是把白名单放到配置项里,或者直接用路由中间件方式单独控制不需要签名的接口。看你项目习惯。

实际测试中出现的问题

问题1:获取不到请求头

开发时我用Postman测接口,发现 $request->header('X-Sign') 一直取不到值。查了半天才发现是PHP内置服务器的问题,用 php think run 启动的开发服务器有时候会过滤掉自定义请求头。后来改用宝塔的nginx环境测就好了。如果你也遇到这个问题,先确认是不是开发服务器的问题。

问题2:参数里含中文字符串导致签名不一致

客户端传了一个昵称”张三”过来,我用 http_build_query 拼出来的是 nickname=%E5%BC%A0%E4%B8%89,客户端那边拼出来的是 nickname=张三。两边签名自然对不上。后来客户端那边在传参前统一做了urlencode,我在服务端用 urldecode 还原,问题解决。如果你跟客户端都是自己人,这个坑可以提前沟通好,省得折腾。

问题3:排序规则不一致

我按k排序,客户端按ascll码排序,结果还是对不上。实际上ksort 默认就是按ascll码排序的,两边只要都用k排序就不会有这个问题。但如果你是手动写的排序逻辑,很容易出问题。建议直接用 http_build_query,它内部就是按ascll码排序的,还能自动排除空值,比自己拼强多了。

优化:把权限规则放到Redis里

上面的PermissionCheck中间件把权限映射写死在代码里,业务上线后想调整权限,得改代码重新部署,太不灵活了。我后来把它改成从Redis里读取规则,配置在后台管理系统里维护,方便很多。

public function handle($request, Closure $next)
{
    $userId = $request->header('X-User-Id', 0);
    if (empty($userId)) {
        return json(['code' => 401, 'msg' => '未登录']);
    }

    $controller = strtolower($request->controller());
    $action = strtolower($request->action());

    // 从Redis获取用户权限(这里用uid做缓存key)
    $cache = new thinkfacadeCache();
    $permissions = $cache->remember('user_permission_' . $userId, function() use ($userId) {
        // 实际项目中这里是查数据库获取用户的权限规则
        return ['index/user/profile', 'index/user/orders'];
    }, 3600);

    $fullPath = $controller . '/' . $action;

    if (!in_array($fullPath, $permissions)) {
        return json(['code' => 403, 'msg' => '没有权限访问']);
    }

    return $next($request);
}

这样改完之后,权限规则变化只需要更新Redis里的数据,不需要重新上线代码。特别是客户那边经常调整权限的,用这种方式能省不少事。

写在最后

中间件在ThinkPHP8里是个很灵活的东西,用了之后你会发现控制器的代码干净了很多,重复的验签、权限判断都不用写了,专心处理业务逻辑就行。不过中间件也别滥用,有些业务相关的逻辑如果只在个别几个控制器里用,还是写在对应对的控制器里或者用trait更好,搞一打中间件反而难维护。

以上这段代码和踩坑记录都是用真项目换来的,如果你也在搞ThinkPHP的接口开发,可以直接拿去用。有细节问题可以多试试,毕竟每个项目的前后端约定都不完全一样。

ThinkPHP8中间件实战:手写API签名验证与权限控制系统
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP8中间件实战:手写API签名验证与权限控制系统 https://www.taomawang.com/server/thinkphp/2669.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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