CheckSign.php 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. <?php
  2. namespace App\Http\Middleware;
  3. use App\Consts\ErrorConst;
  4. use App\Libs\Utils;
  5. use Closure;
  6. use App\Exceptions\ApiException;
  7. use Illuminate\Support\Facades\Redis;
  8. use App\Facade\Site;
  9. class CheckSign
  10. {
  11. /**
  12. * 接口验签
  13. *
  14. * 新版签名(推荐):
  15. * X-Time : 当前时间戳(秒)
  16. * X-Nonce : 前端生成的随机串(时间窗内一次有效)
  17. * X-Sign : HMAC-SHA256( METHOD|PATH|X-Time|X-Nonce|d-token=xxx, SIGN_SALT ),小写 hex
  18. *
  19. * 签名串示例:POST|/api/anime/detail|1737280000|a1b2c3d4e5|d-token=3f2a...
  20. *
  21. * 旧版签名(过渡期兼容,可由 sign.allow_legacy 关闭):
  22. * sign / nonce / timestamp 请求头 + md5(strtoupper(http_build_query(...)) . '&key=SALT')
  23. *
  24. * @param $request
  25. * @param Closure $next
  26. * @return mixed
  27. * @throws ApiException
  28. */
  29. public function handle($request, Closure $next)
  30. {
  31. $token = $request->header('d-token', '');
  32. if (!$token) {
  33. $token = $request->input('d_token', '');
  34. if (!$token) Utils::throwError(ErrorConst::NOT_LOGIN);
  35. }
  36. // 非本地模式需要验签
  37. if (!config('sign.enabled')) {
  38. return $next($request);
  39. }
  40. // 白名单:指定的 uid / cpid 跳过验签
  41. if ($this->inSkipList()) {
  42. return $next($request);
  43. }
  44. $time = trim((string)$request->header('X-Time', ''));
  45. $nonce = trim((string)$request->header('X-Nonce', ''));
  46. $sign = trim((string)$request->header('X-Sign', ''));
  47. if ($time !== '' && $nonce !== '' && $sign !== '') {
  48. $this->checkSign($request, $time, $nonce, $sign, $token);
  49. } else {
  50. $this->fail('缺少签名请求头', ['path' => $request->path()]);
  51. }
  52. return $next($request);
  53. }
  54. /**
  55. * 新版签名校验:HMAC-SHA256 + 时间窗 + nonce 一次性
  56. *
  57. * @param $request
  58. * @param string $time
  59. * @param string $nonce
  60. * @param string $sign
  61. * @param string $token
  62. * @return void
  63. * @throws ApiException
  64. */
  65. private function checkSign($request, string $time, string $nonce, string $sign, string $token): void
  66. {
  67. $ttl = (int)config('sign.ttl', 120);
  68. if (!ctype_digit($time)) {
  69. $this->fail('X-Time 格式不正确', ['time' => $time]);
  70. }
  71. // 允许轻微时钟偏差,因此取绝对值
  72. if (abs(time() - (int)$time) > $ttl) {
  73. $this->fail('签名已过期', ['time' => $time, 'ttl' => $ttl]);
  74. }
  75. $str = $this->buildSignString($request, $time, $nonce, $token);
  76. $expected = hash_hmac((string)config('sign.algo', 'sha256'), $str, (string)config('sign.salt'));
  77. if (!hash_equals($expected, strtolower($sign))) {
  78. $this->fail('签名不正确', [
  79. 'check_str' => $str,
  80. 'sign' => $sign,
  81. 'expected' => $expected,
  82. ]);
  83. }
  84. // 防重放:签名通过后再占用 nonce,避免无效请求刷满缓存
  85. if (config('sign.nonce_unique')) {
  86. $nonceKey = Utils::getCacheKey('sign.nonce', [md5($nonce)]);
  87. $ok = Redis::set($nonceKey, 1, 'EX', max($ttl, 60), 'NX');
  88. if (!$ok) {
  89. $this->fail('请求重复(nonce 已使用)', ['nonce' => $nonce]);
  90. }
  91. }
  92. }
  93. /**
  94. * 构造签名串:METHOD|PATH|X-Time|X-Nonce|d-token=xxx
  95. *
  96. * PATH 已归一化为以 / 开头、不含域名与 query string 的形式,例如 /api/anime/detail
  97. *
  98. * @param $request
  99. * @param string $time
  100. * @param string $nonce
  101. * @param string $token
  102. * @return string
  103. */
  104. private function buildSignString($request, string $time, string $nonce, string $token): string
  105. {
  106. $method = strtoupper($request->getMethod());
  107. $path = '/' . ltrim($request->path(), '/');
  108. return $method . '|' . $path . '|' . $time . '|' . $nonce . '|d-token=' . $token;
  109. }
  110. /**
  111. * 是否命中免验签白名单(按 uid 或 cpid)
  112. *
  113. * @return bool
  114. */
  115. private function inSkipList(): bool
  116. {
  117. $uid = (int)Site::getUid();
  118. $cpid = (int)Site::getCpid();
  119. if ($uid > 0 && in_array($uid, (array)config('sign.skip_uids', []), true)) {
  120. return true;
  121. }
  122. if ($cpid > 0 && in_array($cpid, (array)config('sign.skip_cpids', []), true)) {
  123. return true;
  124. }
  125. return false;
  126. }
  127. /**
  128. * 旧版签名校验:md5(strtoupper(http_build_query(d-token,nonce,timestamp)) . '&key=SALT')
  129. *
  130. * 仅用于前端升级过渡期,全部切换完成后可关闭 sign.allow_legacy
  131. *
  132. * @param $request
  133. * @param string $token
  134. * @return void
  135. * @throws ApiException
  136. */
  137. private function checkLegacySign($request, string $token): void
  138. {
  139. $param_sign = $request->header('sign', '');
  140. $nonce = $request->header('nonce', '');
  141. $timestamp = $request->header('timestamp', '');
  142. $refererUrl = $request->input('_url', '');
  143. $checkParams = [
  144. 'd-token' => $token,
  145. 'nonce' => $nonce,
  146. 'timestamp' => $timestamp,
  147. ];
  148. if (!$nonce || !$timestamp) {
  149. $this->fail('请求参数不正确', $checkParams + ['_url' => $refererUrl]);
  150. }
  151. if (time() - (int)$timestamp > 300) {
  152. $this->fail('签名5分钟内有效', $checkParams + ['_url' => $refererUrl]);
  153. }
  154. ksort($checkParams);
  155. $str = strtoupper(http_build_query($checkParams));
  156. $sign = md5($str . '&key=' . (string)config('sign.salt'));
  157. if ($param_sign != $sign) {
  158. $this->fail('签名不正确', $checkParams + [
  159. '_url' => $refererUrl,
  160. 'sign' => $param_sign,
  161. 'check_sign' => $sign,
  162. 'check_str' => $str . '&key=' . (string)config('sign.salt'),
  163. ]);
  164. }
  165. }
  166. /**
  167. * 验签失败统一处理:记日志并抛出异常
  168. *
  169. * @param string $msg
  170. * @param array $context
  171. * @return void
  172. * @throws ApiException
  173. */
  174. private function fail(string $msg, array $context = []): void
  175. {
  176. dLog('checkSign')->info('验签失败, ' . $msg . ';传参: ' . json_encode($context, 256));
  177. Utils::throwError(ErrorConst::SIGN_ERROR);
  178. }
  179. }