?php/** * 案例056API签名验证 * 说明HMAC-SHA256签名防请求篡改适合开放API对接第三方 * 需要安装的包hyperf/http-server */declare(strict_types1);namespaceApp\Middleware;usePsr\Http\Message\ResponseInterface;usePsr\Http\Message\ServerRequestInterface;usePsr\Http\Server\MiddlewareInterface;usePsr\Http\Server\RequestHandlerInterface;/** * API签名验证中间件 * * 签名流程 * 1. 客户端准备参数app_id timestamp nonce 业务参数 * 2. 把所有参数排序后拼成字符串 * 3. 用HMAC-SHA256和AppSecret对字符串签名得到sign * 4. 请求时携带app_id timestamp nonce sign * 5. 服务端用相同的方法重新计算sign对比是否一致 * * 防重放攻击timestamp超过5分钟或nonce已使用过拒绝请求 */classSignatureMiddlewareimplementsMiddlewareInterface{privateint$timestampTolerance300;// 时间戳容忍5分钟偏差防重放publicfunctionprocess(ServerRequestInterface$request,RequestHandlerInterface$handler):ResponseInterface{// 从Header里取鉴权信息也可以放在Query参数里$appId$request-getHeaderLine(X-App-Id);$timestamp$request-getHeaderLine(X-Timestamp);$nonce$request-getHeaderLine(X-Nonce);// 随机串防重放$sign$request-getHeaderLine(X-Signature);// 基本参数检查if(empty($appId)||empty($timestamp)||empty($nonce)||empty($sign)){return$this-error(400,缺少签名参数);}// 时间戳范围检查超过5分钟的请求拒绝防重放if(abs(time()-(int)$timestamp)$this-timestampTolerance){return$this-error(400,请求已过期请检查系统时间);}// 检查nonce是否已使用过防重放攻击同一nonce只能用一次if($this-isNonceUsed($appId,$nonce)){return$this-error(400,nonce已使用疑似重放攻击);}// 根据appId查询对应的AppSecret$appSecret$this-getAppSecret($appId);if(!$appSecret){return$this-error(401,AppId不存在);}// 获取请求参数GET用queryPOST用body$paramsarray_merge($request-getQueryParams(),(array)$request-getParsedBody());// 重新计算签名$expectedSign$this-generateSign($appId,$timestamp,$nonce,$params,$appSecret);// 用hash_equals比较防止时序攻击if(!hash_equals($expectedSign,$sign)){return$this-error(401,签名验证失败);}// 签名验证通过记录nonce防重放有效期和时间戳容忍时间一样$this-markNonceUsed($appId,$nonce);return$handler-handle($request);}/** * 生成签名 * 算法参数字典排序 - 拼字符串 - HMAC-SHA256 */publicstaticfunctiongenerateSign(string$appId,string$timestamp,string$nonce,array$params,string$appSecret):string{// 系统参数也加入签名防止这些参数被篡改$allParamsarray_merge($params,[app_id$appId,timestamp$timestamp,nonce$nonce,]);// 去掉空值参数没值的不参与签名$allParamsarray_filter($allParams,fn($v)$v!$v!null);// 字典序排序按key的ASCII码升序ksort($allParams);// 拼成 keyvaluekeyvalue 格式$queryStringhttp_build_query($allParams,,);// HMAC-SHA256签名用AppSecret作为密钥returnhash_hmac(sha256,$queryString,$appSecret);}privatefunctiongetAppSecret(string$appId):?string{// 实际从数据库或配置中心取$apps[app_001secret_key_for_app_001_change_this,app_002secret_key_for_app_002_change_this,];return$apps[$appId]??null;}privatefunctionisNonceUsed(string$appId,string$nonce):bool{$redis\Hyperf\Utils\ApplicationContext::getContainer()-get(\Hyperf\Redis\Redis::class);return(bool)$redis-exists(api_nonce:{$appId}:{$nonce});}privatefunctionmarkNonceUsed(string$appId,string$nonce):void{$redis\Hyperf\Utils\ApplicationContext::getContainer()-get(\Hyperf\Redis\Redis::class);// nonce记录的过期时间要大于时间戳容忍时间确保在容忍期内不会重复使用$redis-setex(api_nonce:{$appId}:{$nonce},$this-timestampTolerance60,1);}privatefunctionerror(int$status,string$message):ResponseInterface{$response\Hyperf\Utils\ApplicationContext::getContainer()-get(\Hyperf\HttpServer\Contract\ResponseInterface::class);return$response-json([code$status,message$message])-withStatus($status);}}/** * 客户端签名示例给接入方参考 * 这是PHP版本的其他语言逻辑一样 */classApiClient{publicfunction__construct(privatestring$appId,privatestring$appSecret,privatestring$baseUrl,){}publicfunctionrequest(string$method,string$path,array$params[]):array{$timestamp(string)time();$noncebin2hex(random_bytes(8));// 16位随机串// 计算签名$signSignatureMiddleware::generateSign($this-appId,$timestamp,$nonce,$params,$this-appSecret);// 发请求把签名信息放在请求头里$clientnew\GuzzleHttp\Client([base_uri$this-baseUrl]);$resp$client-request($method,$path,[headers[X-App-Id$this-appId,X-Timestamp$timestamp,X-Nonce$nonce,X-Signature$sign,Content-Typeapplication/json,],json$params,]);returnjson_decode($resp-getBody()-getContents(),true);}}