| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- <?php
- declare(strict_types=1);
- namespace App\Http\Middleware;
- use App\Exceptions\ApiCryptoException;
- use App\Models\User;
- use App\Models\UserSession;
- use App\Services\ApiCryptoService;
- use Closure;
- use Illuminate\Database\QueryException;
- use Illuminate\Http\Request;
- use JsonException;
- use RuntimeException;
- use Symfony\Component\HttpFoundation\JsonResponse;
- use Symfony\Component\HttpFoundation\Response;
- class ApiCrypto
- {
- private const HEADER = 'X-Api-Encrypted';
- public function handle(Request $request, Closure $next): Response
- {
- $method = $request->getRealMethod();
- if (!config('api_crypto.enabled') || $method === 'OPTIONS' || !$request->is(
- 'api/wallet/*',
- 'api/newPc', 'api/newPc/*',
- 'api/issue', 'api/issue/*',
- 'api/pcissue', 'api/pcissue/*',
- )) {
- return $next($request);
- }
- $isEncrypted = $request->headers->get(self::HEADER) === '1';
- $plaintextUser = $this->isPlaintextUser($request);
- if ($plaintextUser && !$isEncrypted) {
- return $this->markPlaintextResponse($next($request));
- }
- try {
- $crypto = new ApiCryptoService(
- (string) config('api_crypto.key'),
- (int) config('api_crypto.timestamp_tolerance', 300),
- (int) config('api_crypto.max_payload_bytes', 2097152),
- );
- } catch (RuntimeException) {
- $response = $this->error(5001, 'API 加密配置错误,请联系服务端管理员', 500)->prepare($request);
- return $plaintextUser ? $this->markPlaintextResponse($response) : $response;
- }
- if (!$isEncrypted) {
- if (config('api_crypto.allow_plaintext')) {
- return $next($request);
- }
- return $this->encryptResponse($this->error(4001, '请求必须使用加密协议', 400), $crypto, $request);
- }
- $queryMethod = in_array($method, ['GET', 'HEAD'], true);
- try {
- $payload = $queryMethod
- ? ($request->query->all()['payload'] ?? null)
- : ($request->isJson() ? $request->json('payload') : null);
- if (!is_string($payload) || $payload === '') {
- throw new ApiCryptoException('请求缺少加密 payload');
- }
- $parameters = $crypto->decrypt($payload, ApiCryptoService::DIRECTION_REQUEST);
- $this->replaceParameters($request, $parameters, $queryMethod);
- } catch (ApiCryptoException | JsonException $exception) {
- $response = $this->error(4002, $exception->getMessage(), 400);
- return $plaintextUser
- ? $this->markPlaintextResponse($response->prepare($request))
- : $this->encryptResponse($response, $crypto, $request);
- }
- $response = $next($request);
- return $plaintextUser
- ? $this->markPlaintextResponse($response)
- : $this->encryptResponse($response, $crypto, $request);
- }
- private function isPlaintextUser(Request $request): bool
- {
- $userIds = config('api_crypto.plaintext_user_ids', []);
- $token = $request->header('Authorization');
- if ($userIds === [] || !is_string($token) || $token === '') {
- return false;
- }
- try {
- $session = UserSession::findActiveToken($token, now()->getTimestamp());
- if (!$session || !hash_equals((string) $session->token, $token)
- || !in_array((string) $session->user_id, $userIds, true)) {
- return false;
- }
- $user = User::where('user_id', $session->user_id)->first();
- } catch (QueryException) {
- return false;
- }
- return $user
- && (string) $user->user_id === (string) $session->user_id
- && empty($user->getAttributes()['delete_time'])
- && (int) ($user->getAttributes()['is_banned'] ?? 0) !== 1;
- }
- private function markPlaintextResponse(Response $response): Response
- {
- $response->headers->set(self::HEADER, '0');
- $response->headers->set('Cache-Control', 'no-store');
- return $response;
- }
- private function replaceParameters(Request $request, array $parameters, bool $queryMethod): void
- {
- $headers = $request->headers->all();
- $server = $request->server->all();
- $server['REQUEST_URI'] = explode('?', $request->getRequestUri(), 2)[0];
- $server['QUERY_STRING'] = '';
- // The HTTP verb belongs to the transport, not to the wrapped parameters.
- unset($parameters['_method'], $headers['x-http-method-override'], $server['HTTP_X_HTTP_METHOD_OVERRIDE']);
- $headers['accept'] = ['application/json'];
- $server['HTTP_ACCEPT'] = 'application/json';
- $body = $queryMethod ? '' : json_encode((object) $parameters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
- unset($headers['content-length'], $server['CONTENT_LENGTH']);
- $request->initialize(
- $queryMethod ? $parameters : [],
- $queryMethod ? [] : $parameters,
- $request->attributes->all(),
- $request->cookies->all(),
- [],
- $server,
- $body,
- );
- $request->headers->replace($headers);
- $request->setJson($queryMethod ? $request->query : $request->request);
- }
- private function encryptResponse(Response $response, ApiCryptoService $crypto, Request $request): Response
- {
- $response->headers->set('Cache-Control', 'no-store');
- $response->headers->set(self::HEADER, '1');
- $response->headers->remove('Content-Length');
- $response->headers->remove('ETag');
- if ($request->getRealMethod() === 'HEAD' || $response->isEmpty()) {
- $response->setContent('');
- return $response;
- }
- try {
- $data = json_decode((string) $response->getContent(), false, 512, JSON_THROW_ON_ERROR);
- $envelope = ['encrypted' => true, 'payload' => $crypto->encrypt($data, ApiCryptoService::DIRECTION_RESPONSE)];
- if ($response instanceof JsonResponse) {
- $response->setData($envelope);
- } else {
- $response->setContent(json_encode($envelope, JSON_THROW_ON_ERROR));
- $response->headers->set('Content-Type', 'application/json');
- }
- return $response;
- } catch (ApiCryptoException | JsonException) {
- return $this->error(5002, 'API 响应加密失败', 500);
- }
- }
- private function error(int $code, string $message, int $status): JsonResponse
- {
- return new JsonResponse(['code' => $code, 'show' => 1, 'msg' => $message, 'data' => []], $status, [
- 'Cache-Control' => 'no-store',
- ]);
- }
- }
|