| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- <?php
- namespace App\Http\Middleware;
- use App\Consts\ErrorConst;
- use App\Libs\Utils;
- use Closure;
- use App\Exceptions\ApiException;
- use Illuminate\Support\Facades\Redis;
- use App\Facade\Site;
- class CheckSign
- {
- /**
- * 接口验签
- *
- * 新版签名(推荐):
- * X-Time : 当前时间戳(秒)
- * X-Nonce : 前端生成的随机串(时间窗内一次有效)
- * X-Sign : HMAC-SHA256( METHOD|PATH|X-Time|X-Nonce|d-token=xxx, SIGN_SALT ),小写 hex
- *
- * 签名串示例:POST|/api/anime/detail|1737280000|a1b2c3d4e5|d-token=3f2a...
- *
- * 旧版签名(过渡期兼容,可由 sign.allow_legacy 关闭):
- * sign / nonce / timestamp 请求头 + md5(strtoupper(http_build_query(...)) . '&key=SALT')
- *
- * @param $request
- * @param Closure $next
- * @return mixed
- * @throws ApiException
- */
- public function handle($request, Closure $next)
- {
- $token = $request->header('d-token', '');
- if (!$token) {
- $token = $request->input('d_token', '');
- if (!$token) Utils::throwError(ErrorConst::NOT_LOGIN);
- }
- // 非本地模式需要验签
- if (!config('sign.enabled')) {
- return $next($request);
- }
- // 白名单:指定的 uid / cpid 跳过验签
- if ($this->inSkipList()) {
- return $next($request);
- }
- $time = trim((string)$request->header('X-Time', ''));
- $nonce = trim((string)$request->header('X-Nonce', ''));
- $sign = trim((string)$request->header('X-Sign', ''));
- if ($time !== '' && $nonce !== '' && $sign !== '') {
- $this->checkSign($request, $time, $nonce, $sign, $token);
- } else {
- $this->fail('缺少签名请求头', ['path' => $request->path()]);
- }
- return $next($request);
- }
- /**
- * 新版签名校验:HMAC-SHA256 + 时间窗 + nonce 一次性
- *
- * @param $request
- * @param string $time
- * @param string $nonce
- * @param string $sign
- * @param string $token
- * @return void
- * @throws ApiException
- */
- private function checkSign($request, string $time, string $nonce, string $sign, string $token): void
- {
- $ttl = (int)config('sign.ttl', 120);
- if (!ctype_digit($time)) {
- $this->fail('X-Time 格式不正确', ['time' => $time]);
- }
- // 允许轻微时钟偏差,因此取绝对值
- if (abs(time() - (int)$time) > $ttl) {
- $this->fail('签名已过期', ['time' => $time, 'ttl' => $ttl]);
- }
- $str = $this->buildSignString($request, $time, $nonce, $token);
- $expected = hash_hmac((string)config('sign.algo', 'sha256'), $str, (string)config('sign.salt'));
- if (!hash_equals($expected, strtolower($sign))) {
- $this->fail('签名不正确', [
- 'check_str' => $str,
- 'sign' => $sign,
- 'expected' => $expected,
- ]);
- }
- // 防重放:签名通过后再占用 nonce,避免无效请求刷满缓存
- if (config('sign.nonce_unique')) {
- $nonceKey = Utils::getCacheKey('sign.nonce', [md5($nonce)]);
- $ok = Redis::set($nonceKey, 1, 'EX', max($ttl, 60), 'NX');
- if (!$ok) {
- $this->fail('请求重复(nonce 已使用)', ['nonce' => $nonce]);
- }
- }
- }
- /**
- * 构造签名串:METHOD|PATH|X-Time|X-Nonce|d-token=xxx
- *
- * PATH 已归一化为以 / 开头、不含域名与 query string 的形式,例如 /api/anime/detail
- *
- * @param $request
- * @param string $time
- * @param string $nonce
- * @param string $token
- * @return string
- */
- private function buildSignString($request, string $time, string $nonce, string $token): string
- {
- $method = strtoupper($request->getMethod());
- $path = '/' . ltrim($request->path(), '/');
- return $method . '|' . $path . '|' . $time . '|' . $nonce . '|d-token=' . $token;
- }
- /**
- * 是否命中免验签白名单(按 uid 或 cpid)
- *
- * @return bool
- */
- private function inSkipList(): bool
- {
- $uid = (int)Site::getUid();
- $cpid = (int)Site::getCpid();
- if ($uid > 0 && in_array($uid, (array)config('sign.skip_uids', []), true)) {
- return true;
- }
- if ($cpid > 0 && in_array($cpid, (array)config('sign.skip_cpids', []), true)) {
- return true;
- }
- return false;
- }
- /**
- * 旧版签名校验:md5(strtoupper(http_build_query(d-token,nonce,timestamp)) . '&key=SALT')
- *
- * 仅用于前端升级过渡期,全部切换完成后可关闭 sign.allow_legacy
- *
- * @param $request
- * @param string $token
- * @return void
- * @throws ApiException
- */
- private function checkLegacySign($request, string $token): void
- {
- $param_sign = $request->header('sign', '');
- $nonce = $request->header('nonce', '');
- $timestamp = $request->header('timestamp', '');
- $refererUrl = $request->input('_url', '');
- $checkParams = [
- 'd-token' => $token,
- 'nonce' => $nonce,
- 'timestamp' => $timestamp,
- ];
- if (!$nonce || !$timestamp) {
- $this->fail('请求参数不正确', $checkParams + ['_url' => $refererUrl]);
- }
- if (time() - (int)$timestamp > 300) {
- $this->fail('签名5分钟内有效', $checkParams + ['_url' => $refererUrl]);
- }
- ksort($checkParams);
- $str = strtoupper(http_build_query($checkParams));
- $sign = md5($str . '&key=' . (string)config('sign.salt'));
- if ($param_sign != $sign) {
- $this->fail('签名不正确', $checkParams + [
- '_url' => $refererUrl,
- 'sign' => $param_sign,
- 'check_sign' => $sign,
- 'check_str' => $str . '&key=' . (string)config('sign.salt'),
- ]);
- }
- }
- /**
- * 验签失败统一处理:记日志并抛出异常
- *
- * @param string $msg
- * @param array $context
- * @return void
- * @throws ApiException
- */
- private function fail(string $msg, array $context = []): void
- {
- dLog('checkSign')->info('验签失败, ' . $msg . ';传参: ' . json_encode($context, 256));
- Utils::throwError(ErrorConst::SIGN_ERROR);
- }
- }
|