WebhookController.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Services\WebhookService;
  4. use Illuminate\Http\JsonResponse;
  5. use Illuminate\Http\Request;
  6. use Illuminate\Support\Facades\Log;
  7. class WebhookController extends Controller
  8. {
  9. public function __construct(
  10. private readonly WebhookService $webhookService,
  11. ) {}
  12. public function pagarme(Request $request): JsonResponse
  13. {
  14. if (! $this->validPagarmeCredentials($request)) {
  15. return $this->errorResponse(message: __('http.unauthorized_token'), code: 401);
  16. }
  17. $this->webhookService->handlePagarme($request->all());
  18. return $this->successResponse(message: __('http.webhook_received'));
  19. }
  20. public function didit(Request $request): JsonResponse
  21. {
  22. $payload = $request->all();
  23. Log::channel('didit')->info('Webhook recebido do Didit', [
  24. 'event_id' => data_get($payload, 'event_id'),
  25. 'webhook_type' => data_get($payload, 'webhook_type'),
  26. 'session_id' => data_get($payload, 'session_id'),
  27. 'status' => data_get($payload, 'status'),
  28. 'vendor_data' => data_get($payload, 'vendor_data'),
  29. 'environment' => data_get($payload, 'environment'),
  30. 'is_test' => $request->hasHeader('X-Didit-Test-Webhook'),
  31. ]);
  32. if (! $this->validDiditSignature($request)) {
  33. return $this->errorResponse(message: __('http.unauthorized_token'), code: 401);
  34. }
  35. $this->webhookService->handleDidit($payload);
  36. return $this->successResponse(message: __('http.webhook_received'));
  37. }
  38. //
  39. private function validPagarmeCredentials(Request $request): bool
  40. {
  41. $configuredUser = config('services.pagarme.webhook_user');
  42. $configuredPassword = config('services.pagarme.webhook_password');
  43. if (empty($configuredUser) || empty($configuredPassword)) {
  44. return false;
  45. }
  46. return is_string($configuredUser)
  47. && is_string($configuredPassword)
  48. && $this->validBasicAuthCredentials($request, $configuredUser, $configuredPassword);
  49. }
  50. private function validBasicAuthCredentials(Request $request, string $configuredUser, string $configuredPassword): bool
  51. {
  52. $receivedUser = $request->getUser();
  53. $receivedPassword = $request->getPassword();
  54. return is_string($receivedUser)
  55. && is_string($receivedPassword)
  56. && hash_equals($configuredUser, $receivedUser)
  57. && hash_equals($configuredPassword, $receivedPassword);
  58. }
  59. private function validDiditSignature(Request $request): bool
  60. {
  61. $secrets = $this->diditWebhookSecrets();
  62. if ($secrets === []) {
  63. Log::channel('didit')->error('DIDIT_WEBHOOK_SECRET nao configurado; webhook rejeitado');
  64. return false;
  65. }
  66. if (! $this->validDiditTimestamp($request)) {
  67. return false;
  68. }
  69. $rawBody = $request->getContent();
  70. $candidates = array_filter([
  71. 'X-Signature-V2' => $this->diditCanonicalBody($rawBody),
  72. 'X-Signature' => $rawBody,
  73. ], static fn ($body) => is_string($body));
  74. foreach ($candidates as $header => $body) {
  75. $received = (string) $request->header($header, '');
  76. if ($received === '') {
  77. continue;
  78. }
  79. foreach ($secrets as $secret) {
  80. if (hash_equals(hash_hmac('sha256', $body, $secret), $received)) {
  81. return true;
  82. }
  83. }
  84. }
  85. Log::channel('didit')->warning('Assinatura do webhook Didit invalida', [
  86. 'secrets_testados' => count($secrets),
  87. 'received_v2' => substr((string) $request->header('X-Signature-V2', ''), 0, 12),
  88. 'body_length' => strlen($rawBody),
  89. ]);
  90. return false;
  91. }
  92. /**
  93. * @return list<string>
  94. */
  95. private function diditWebhookSecrets(): array
  96. {
  97. $configured = config('services.didit.webhook_secret');
  98. if (! is_string($configured)) {
  99. return [];
  100. }
  101. return array_values(array_filter(
  102. array_map('trim', explode(',', $configured)),
  103. static fn (string $secret) => $secret !== '',
  104. ));
  105. }
  106. private function validDiditTimestamp(Request $request): bool
  107. {
  108. $timestamp = $request->header('X-Timestamp');
  109. if (! is_numeric($timestamp)) {
  110. Log::channel('didit')->warning('Webhook Didit sem X-Timestamp valido');
  111. return false;
  112. }
  113. $tolerance = (int) config('services.didit.webhook_tolerance', 300);
  114. if (abs(time() - (int) $timestamp) > $tolerance) {
  115. Log::channel('didit')->warning('Webhook Didit fora da janela de tolerancia', [
  116. 'timestamp' => (int) $timestamp,
  117. 'now' => time(),
  118. ]);
  119. return false;
  120. }
  121. return true;
  122. }
  123. private function diditCanonicalBody(string $rawBody): ?string
  124. {
  125. try {
  126. $decoded = json_decode($rawBody, false, 512, JSON_THROW_ON_ERROR);
  127. } catch (\JsonException) {
  128. return null;
  129. }
  130. $canonical = json_encode(
  131. $this->canonicalizeDiditValue($decoded),
  132. JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  133. );
  134. return $canonical === false ? null : $canonical;
  135. }
  136. private function canonicalizeDiditValue(mixed $value): mixed
  137. {
  138. if (is_array($value)) {
  139. return array_map(fn ($item) => $this->canonicalizeDiditValue($item), $value);
  140. }
  141. if ($value instanceof \stdClass) {
  142. $data = get_object_vars($value);
  143. ksort($data, SORT_STRING);
  144. foreach ($data as $key => $item) {
  145. $data[$key] = $this->canonicalizeDiditValue($item);
  146. }
  147. return (object) $data;
  148. }
  149. if (is_float($value) && is_finite($value) && floor($value) === $value && abs($value) < PHP_INT_MAX) {
  150. return (int) $value;
  151. }
  152. return $value;
  153. }
  154. }