签名验证必须放在中间件而非控制器中,因控制器执行时请求已进入业务层,验签失效;需统一提取原始参数、归一化处理、严格按客户端逻辑拼串并校验,密钥须按子域名隔离管理。

签名验证必须放在中间件里,不能写在控制器中
控制器执行时,路由已匹配、参数已解析、日志可能已写入,甚至数据库操作都已触发——验签放在这里等于门锁装在屋子里。中间件是请求进入业务逻辑前的唯一可控拦截点。
常见错误现象:Invalid signature 但参数看起来完全一致,根源往往是控制器里用了 $this->request->param():它会自动 urldecode()、过滤空格、转义特殊字符,而客户端签名用的是原始 URL 编码字节流,哈希值天然不等。
- 新建中间件
app/middleware/ApiSignVerify.php,在app/middleware.php中注册,位置应排在ValidateRequest之后、业务中间件之前 - 中间件
handle()方法开头就调用验签逻辑,失败直接return json(['code'=>401, 'msg'=>'Unauthorized'], 401),不放行 - 别在中间件里调用
$request->get()或$request->post()分开取参——顺序和编码不可控,统一走原始输入流处理
GET/POST/JSON 请求参数必须统一提取并归一化
不同请求方式参数藏的位置不同:GET 在 query string,POST form 在 body,JSON 在 raw input。不统一提取,必然漏字段或重复拼接。
服务端构造签名原文时,必须还原客户端原始拼串逻辑。90% 验签失败源于这里没对齐。
立即学习“PHP免费学习笔记(深入)”;
- 先用
$request->getInput()读取原始输入流(php://input) - 判断
$request->header('content-type')是否含application/json:是则json_decode($raw, true);否则用parse_str($raw, $body) - 显式合并:
array_merge($request->get(), $body),确保 query + body 全覆盖 - 对每个值做
trim()和str_replace(["\r", "\n"], '', $v),清除隐藏字符和 BOM - 剔除
sign、signature、nonce、timestamp等非业务字段前,确认它们真实存在于数组中(避免unset()报 Notice)
签名原文拼接规则必须严格一致
客户端和服务端拼出来的字符串哪怕只差一个空格或编码方式不同,hash_hmac('sha256', ...) 结果就完全不同。路径、参数顺序、编码、密钥接入方式,缺一不可。
典型错误:用 http_build_query() 拼参但没指定 PHP_QUERY_RFC3986,导致空格编码成 + 而不是 %20;或对整个数组 rawurlencode(),而不是对每个 $k 和 $v 单独编码。
- 路径取
$request->url(true)(如/api/v1/user),不含域名、query、index.php - 剩余参数用
ksort()按 key 字典序升序排列 - 遍历参数,对每个
$k和$v分别调用rawurlencode()(不是urlencode()) - 拼成
k1=v1&k2=v2格式,末尾不加& - 追加
&sign_version=1(若启用版本控制),再拼rawurlencode($secretKey) - 最终传给
hash_hmac('sha256', $stringToSign, $secretKey),结果转小写
密钥管理与风控必须按子域名或渠道隔离
所有业务共用一个密钥,等于把所有房门钥匙焊死在同一把锁上。一旦泄露,全线沦陷。ThinkPHP 本身不提供密钥轮换机制,得靠配置和路由结构实现物理隔离。
二级域名不只是“换个地址”,它是独立安全上下文的载体——api.example.com 和 pay.example.com 应该加载完全不同的密钥、限流策略和验签中间件。
- 在
config/app.php开启'url_domain_deploy' => true,Nginx/Apache 必须透传Host头 - 路由中显式注册子域名:
Route::domain('api.example.com', function () { /* API 路由 */ }); - 密钥存独立配置文件(如
config/api_sign.php),禁止 Web 直接访问;或按子域名建config/api.api.example.com.php - 中间件内通过
$request->host()提取子域名,动态加载:Config::get('api.sign_key_' . str_replace('.', '_', $host)) -
nonce去重必须用 Redis 的原子操作:cache()->handler()->setNx("sign:nonce:{$nonce}", 1, 300),不能依赖 session 或本地缓存
最易被忽略的点:开发环境常加 if (env('APP_DEBUG')) return true;,但上线后容易忘记删。更稳妥的做法是配置开关项 config('api.sign_enabled'),上线前统一设为 true,而非靠 debug 状态判断。



















