ApiCrypto.php 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Http\Middleware;
  4. use App\Exceptions\ApiCryptoException;
  5. use App\Models\User;
  6. use App\Models\UserSession;
  7. use App\Services\ApiCryptoService;
  8. use Closure;
  9. use Illuminate\Database\QueryException;
  10. use Illuminate\Http\Request;
  11. use JsonException;
  12. use RuntimeException;
  13. use Symfony\Component\HttpFoundation\JsonResponse;
  14. use Symfony\Component\HttpFoundation\Response;
  15. class ApiCrypto
  16. {
  17. private const HEADER = 'X-Api-Encrypted';
  18. public function handle(Request $request, Closure $next): Response
  19. {
  20. $method = $request->getRealMethod();
  21. if (!config('api_crypto.enabled') || $method === 'OPTIONS' || !$request->is(
  22. 'api/wallet/*',
  23. 'api/newPc', 'api/newPc/*',
  24. 'api/issue', 'api/issue/*',
  25. 'api/pcissue', 'api/pcissue/*',
  26. )) {
  27. return $next($request);
  28. }
  29. $isEncrypted = $request->headers->get(self::HEADER) === '1';
  30. $plaintextUser = $this->isPlaintextUser($request);
  31. if ($plaintextUser && !$isEncrypted) {
  32. return $this->markPlaintextResponse($next($request));
  33. }
  34. try {
  35. $crypto = new ApiCryptoService(
  36. (string) config('api_crypto.key'),
  37. (int) config('api_crypto.timestamp_tolerance', 300),
  38. (int) config('api_crypto.max_payload_bytes', 2097152),
  39. );
  40. } catch (RuntimeException) {
  41. $response = $this->error(5001, 'API 加密配置错误,请联系服务端管理员', 500)->prepare($request);
  42. return $plaintextUser ? $this->markPlaintextResponse($response) : $response;
  43. }
  44. if (!$isEncrypted) {
  45. if (config('api_crypto.allow_plaintext')) {
  46. return $next($request);
  47. }
  48. return $this->encryptResponse($this->error(4001, '请求必须使用加密协议', 400), $crypto, $request);
  49. }
  50. $queryMethod = in_array($method, ['GET', 'HEAD'], true);
  51. try {
  52. $payload = $queryMethod
  53. ? ($request->query->all()['payload'] ?? null)
  54. : ($request->isJson() ? $request->json('payload') : null);
  55. if (!is_string($payload) || $payload === '') {
  56. throw new ApiCryptoException('请求缺少加密 payload');
  57. }
  58. $parameters = $crypto->decrypt($payload, ApiCryptoService::DIRECTION_REQUEST);
  59. $this->replaceParameters($request, $parameters, $queryMethod);
  60. } catch (ApiCryptoException | JsonException $exception) {
  61. $response = $this->error(4002, $exception->getMessage(), 400);
  62. return $plaintextUser
  63. ? $this->markPlaintextResponse($response->prepare($request))
  64. : $this->encryptResponse($response, $crypto, $request);
  65. }
  66. $response = $next($request);
  67. return $plaintextUser
  68. ? $this->markPlaintextResponse($response)
  69. : $this->encryptResponse($response, $crypto, $request);
  70. }
  71. private function isPlaintextUser(Request $request): bool
  72. {
  73. $userIds = config('api_crypto.plaintext_user_ids', []);
  74. $token = $request->header('Authorization');
  75. if ($userIds === [] || !is_string($token) || $token === '') {
  76. return false;
  77. }
  78. try {
  79. $session = UserSession::findActiveToken($token, now()->getTimestamp());
  80. if (!$session || !hash_equals((string) $session->token, $token)
  81. || !in_array((string) $session->user_id, $userIds, true)) {
  82. return false;
  83. }
  84. $user = User::where('user_id', $session->user_id)->first();
  85. } catch (QueryException) {
  86. return false;
  87. }
  88. return $user
  89. && (string) $user->user_id === (string) $session->user_id
  90. && empty($user->getAttributes()['delete_time'])
  91. && (int) ($user->getAttributes()['is_banned'] ?? 0) !== 1;
  92. }
  93. private function markPlaintextResponse(Response $response): Response
  94. {
  95. $response->headers->set(self::HEADER, '0');
  96. $response->headers->set('Cache-Control', 'no-store');
  97. return $response;
  98. }
  99. private function replaceParameters(Request $request, array $parameters, bool $queryMethod): void
  100. {
  101. $headers = $request->headers->all();
  102. $server = $request->server->all();
  103. $server['REQUEST_URI'] = explode('?', $request->getRequestUri(), 2)[0];
  104. $server['QUERY_STRING'] = '';
  105. // The HTTP verb belongs to the transport, not to the wrapped parameters.
  106. unset($parameters['_method'], $headers['x-http-method-override'], $server['HTTP_X_HTTP_METHOD_OVERRIDE']);
  107. $headers['accept'] = ['application/json'];
  108. $server['HTTP_ACCEPT'] = 'application/json';
  109. $body = $queryMethod ? '' : json_encode((object) $parameters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
  110. unset($headers['content-length'], $server['CONTENT_LENGTH']);
  111. $request->initialize(
  112. $queryMethod ? $parameters : [],
  113. $queryMethod ? [] : $parameters,
  114. $request->attributes->all(),
  115. $request->cookies->all(),
  116. [],
  117. $server,
  118. $body,
  119. );
  120. $request->headers->replace($headers);
  121. $request->setJson($queryMethod ? $request->query : $request->request);
  122. }
  123. private function encryptResponse(Response $response, ApiCryptoService $crypto, Request $request): Response
  124. {
  125. $response->headers->set('Cache-Control', 'no-store');
  126. $response->headers->set(self::HEADER, '1');
  127. $response->headers->remove('Content-Length');
  128. $response->headers->remove('ETag');
  129. if ($request->getRealMethod() === 'HEAD' || $response->isEmpty()) {
  130. $response->setContent('');
  131. return $response;
  132. }
  133. try {
  134. $data = json_decode((string) $response->getContent(), false, 512, JSON_THROW_ON_ERROR);
  135. $envelope = ['encrypted' => true, 'payload' => $crypto->encrypt($data, ApiCryptoService::DIRECTION_RESPONSE)];
  136. if ($response instanceof JsonResponse) {
  137. $response->setData($envelope);
  138. } else {
  139. $response->setContent(json_encode($envelope, JSON_THROW_ON_ERROR));
  140. $response->headers->set('Content-Type', 'application/json');
  141. }
  142. return $response;
  143. } catch (ApiCryptoException | JsonException) {
  144. return $this->error(5002, 'API 响应加密失败', 500);
  145. }
  146. }
  147. private function error(int $code, string $message, int $status): JsonResponse
  148. {
  149. return new JsonResponse(['code' => $code, 'show' => 1, 'msg' => $message, 'data' => []], $status, [
  150. 'Cache-Control' => 'no-store',
  151. ]);
  152. }
  153. }