必须将签名验证放在中间件中,因为它是唯一能在路由匹配、参数解析和控制器实例化前介入的环节;若在控制器中验签,攻击者已触发日志、数据库查询等敏感操作。

要在ThinkPHP中真正拦截恶意请求、阻止重放攻击并确保接口调用合法,必须在路由解析前完成签名与时间戳+nonce校验——控制器里写验签逻辑等于开门揖盗,因为日志已打、数据库已查、业务方法已执行。
为什么必须把签名验证放在中间件里
ThinkPHP的请求生命周期中,中间件是唯一能在路由匹配、参数解析、控制器实例化之前介入的环节。一旦进入控制器,说明请求已通过路由层、参数已被自动urldecode()、$_POST/$_GET已被合并、甚至模型查询都可能触发。此时再验签,攻击者早就能靠一次非法请求刷出错误日志、拖垮数据库连接、或触发敏感操作。
【必须在app/middleware.php中注册中间件,并确保它排在ValidateRequest之后、但远在所有业务中间件和控制器之前】。若注册位置靠后,比如放在日志中间件之后,那非法请求的日志就已落盘;若放在跨域中间件之后,攻击者还能伪造Origin头绕过前置检查。
构造签名原文:三步对齐客户端
验签失败90%源于服务端拼出的字符串和服务端不一致。不是算法错,是参数没对齐。
第一步:统一取参路径。不要用$request->param()——它对JSON请求体返回空数组,对GET+POST混合请求又会自动合并丢失原始编码。改用:$input = $request->getInput(); 一次性读取原始请求体。
第二步:按Content-Type分流解析。若header中content-type含application/json,则$params = json_decode($input, true) ?: [];否则用parse_str($input, $params)。再显式合并array_merge($request->get(), $params),确保URL参数不丢失。
第三步:剔除签名字段并排序。先unset(sign、signature、sign_type、timestamp、nonce),再ksort($params)升序排列。对每个键值对单独rawurlencode()编码:$str .= rawurlencode($k) . '=' . rawurlencode((string)$v) . '&'。最后追加rawurlencode($secretKey)——漏掉密钥或用urlencode()代替rawurlencode(),签名必不匹配。
防重放核心:timestamp与nonce双校验
单纯校验时间戳毫无意义。攻击者截获请求后把timestamp改成当前时间再发一遍,服务端一验就过。必须绑定nonce且强制去重。
方法一:timestamp校验不可妥协。取$serverTime = time(),要求abs($timestamp - $serverTime) ,且<code>$timestamp (拒绝未来时间)。超时直接throw HttpException(401)。
方法二:nonce必须Redis原子存。别用SETNX+EXPIRE两步——高并发下必然竞态。改用:$redis->set("sign:nonce:{$appId}:" . substr(md5($nonce), 0, 8), 1, ['nx', 'ex' => 300])。返回false即说明该nonce已存在,立即拒收。
注意:Redis不可用时不能静默跳过nonce校验,必须记录告警并返回401,否则防线彻底失效。
安全响应与密钥管理
验签失败时,只返回HTTP状态码401和固定JSON:{"code":401,"msg":"Unauthorized"}。绝不能暴露“timestamp超时”“nonce重复”“签名不匹配”等细节——攻击者靠错误提示就能反推你的校验顺序,进而针对性绕过。
密钥严禁硬编码。在config/api_sign.php中定义:return ['app_secrets' => ['app_a' => env('APP_A_SECRET'), 'app_b' => env('APP_B_SECRET')]];,文件需被Web服务器禁止直接访问。中间件中根据$params['app_id']查表获取对应密钥,不同渠道密钥隔离,单点泄露不影响全局。
调试阶段可加debug_sign=1参数,仅当env('APP_DEBUG')为true时输出服务端拼接的完整签名原文,上线前必须删除该分支逻辑。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











