大概上个月接了一个外包项目,对方要求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 .= '×tamp=' . $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的接口开发,可以直接拿去用。有细节问题可以多试试,毕竟每个项目的前后端约定都不完全一样。

