必须放在中间件。控制器校验属事后防御,易致恶意数据触发业务逻辑;中间件可统一拦截、避免重复代码,并确保使用 rawInput 等原始数据精准验签。

签名验证该放在中间件还是控制器里
必须放中间件。控制器里做校验等于把门锁装在屋子里——请求已经进来了,恶意数据可能已触发业务逻辑或写入数据库。app/middleware/SignCheckMiddleware.php 是唯一合理位置。ThinkPHP 6+ 的中间件支持 beforeAction 钩子,能拦截所有带 sign 参数的接口请求;若放在控制器,每个接口都得重复写校验逻辑,漏一个就等于开后门。
常见错误现象:Invalid signature 报错但参数看起来完全正确——八成是控制器里取参用了 $this->request->param(),它会自动过滤/转换值(比如把空格转为加号),而签名原文必须和客户端原始请求体字节完全一致。
- 签名前原始数据必须用
$this->request->rawInput()(POST JSON)或$this->request->get()+$this->request->post()分离获取(表单),再按约定顺序拼接 - 不要用
input()或param(),它们会触发默认过滤和类型转换 - 中间件中需提前终止请求:
return json(['code'=>401,'msg'=>'sign invalid'])->send();exit;,避免后续执行
sign生成规则:时间戳、随机串、私钥缺一不可
只用 API key + 参数 MD5 是裸奔。必须包含动态因子:timestamp(精确到秒)、nonce(16位随机字符串)、secret(服务端独有密钥)。三者缺一,重放攻击立刻生效。
使用场景:App 端调用下单接口时,前端 JS 生成 nonce 并缓存 5 分钟内不重复;服务端收到后检查 timestamp 是否在当前时间 ±300 秒内,超时直接拒掉。
立即学习“PHP免费学习笔记(深入)”;
-
timestamp必须是客户端本地时间戳(非服务端生成),否则时钟不同步会导致批量失效 -
nonce要存 Redis 做去重,key 为"nonce:{$nonce}",过期时间设为 600 秒,防止重放 - 拼接顺序必须固定,推荐:
ksort()排序所有业务参数 → 拼成key1=value1&key2=value2→ 再追加×tamp=xxx&nonce=yyy→ 最后拼secret
PHP 实现 HMAC-SHA256 签名比对要注意字符编码
客户端用 UTF-8 生成签名,PHP 服务端却用 GBK 解析请求体?结果必然失败。ThinkPHP 默认编码是 UTF-8,但某些 Nginx 配置或历史遗留模块可能偷偷转码。
错误现象:hash_hmac('sha256', $data, $secret) 算出来的值和客户端对不上,逐字节打印发现中文部分多出 \xc2\xa0 这类 BOM 或空格。
- 强制统一编码:
$data = mb_convert_encoding($data, 'UTF-8', 'UTF-8');(看似冗余,实为防坑) - 禁用自动 trim:
trim()会吃掉末尾换行或空格,而签名原文可能含空格(如 base64 字段) - URL decode 要谨慎:GET 参数已由框架自动 decode,再调用
urldecode()会导致二次解码,把%2B变成+再变成空格 - 示例关键行:
$expected = hash_hmac('sha256', $raw_string, Config::get('api.secret'));,其中$raw_string必须是未 decode、未 trim、未转码的原始拼接串
调试阶段如何快速定位签名失败原因
别猜。把客户端传来的完整原始请求体、服务端拼出的待签名字符串、最终计算出的 sign 全部打日志,三者逐字节比对。90% 的问题出在“以为一样,其实差一个空格或换行”。
性能影响:HMAC-SHA256 在 PHP 中耗时约 0.02ms,不影响 QPS;但 Redis 查 nonce 会增加一次网络 RTT,高并发下建议用本地内存缓存(如 Swoole\Table)做二级去重。
- 临时开启 debug 日志:
Log::write("RAW: {$raw_string}", 'sign');和Log::write("CLIENT SIGN: {$clientSign}", 'sign'); - 确保客户端和服务端 secret 完全一致:注意前后空格、换行、大小写(secret 建议全小写+数字,避免混淆)
- 测试时用 curl 直接构造请求,绕过前端 JS 封装,排除 JS 编码干扰:
curl -X POST http://api.xxx.com/v1/order -d 'amount=100&uid=123×tamp=1717023456&nonce=abc123xyz789&sign=xxx'
最易被忽略的是:签名算法里是否对参数值做了 URL encode。ThinkPHP 默认不 encode,但很多 Android SDK 会自动 encode,这时候服务端拼串前就得先 urldecode() 所有值——但仅限于从 $_GET/$_POST 拿到的值,不能动 rawInput。



















