diff --git a/app/Features/Invoice/UpdateInvoice/BaseUpdateInvoiceValidator.php b/app/Features/Invoice/UpdateInvoice/BaseUpdateInvoiceValidator.php index 5a856a6..9f1814e 100644 --- a/app/Features/Invoice/UpdateInvoice/BaseUpdateInvoiceValidator.php +++ b/app/Features/Invoice/UpdateInvoice/BaseUpdateInvoiceValidator.php @@ -13,7 +13,7 @@ final class BaseUpdateInvoiceValidator implements UpdateInvoiceValidator { - public function execute(?array $data, ?BitPayInvoice $bitPayInvoice): void + public function execute(?array $data, ?BitPayInvoice $bitPayInvoice, array $headers): void { if (!$data) { throw new ValidationFailed('Missing data'); diff --git a/app/Features/Invoice/UpdateInvoice/BitPaySignatureValidator.php b/app/Features/Invoice/UpdateInvoice/BitPaySignatureValidator.php new file mode 100644 index 0000000..1b744ce --- /dev/null +++ b/app/Features/Invoice/UpdateInvoice/BitPaySignatureValidator.php @@ -0,0 +1,50 @@ +bitpayConfiguration = $bitpayConfiguration; + } + + public function execute(array $data, array $headers): void + { + $token = $this->bitpayConfiguration->getToken(); + + if (!$token) { + throw new RuntimeException(self::MISSING_TOKEN_MESSAGE); + } + + $sigHeader = $headers['x-signature'][0] ?? null; + + if (!$sigHeader) { + throw new SignatureVerificationFailed(self::MISSING_SIGNATURE_MESSAGE); + } + + $hmac = base64_encode(hash_hmac( + 'sha256', + json_encode($data), + $token, + true + )); + + if ($sigHeader !== $hmac) { + throw new SignatureVerificationFailed(self::INVALID_SIGNATURE_MESSAGE); + } + } +} diff --git a/app/Features/Invoice/UpdateInvoice/BitPaySignatureValidatorInterface.php b/app/Features/Invoice/UpdateInvoice/BitPaySignatureValidatorInterface.php new file mode 100644 index 0000000..1b0512a --- /dev/null +++ b/app/Features/Invoice/UpdateInvoice/BitPaySignatureValidatorInterface.php @@ -0,0 +1,19 @@ +updateInvoiceValidator = $updateInvoiceValidator; $this->logger = $logger; + $this->bitPaySignatureValidator = $bitPaySignatureValidator; } - public function execute(?array $data, ?BitPayInvoice $bitPayInvoice): void + public function execute(?array $data, ?BitPayInvoice $bitPayInvoice, ?array $headers): void { try { - $this->updateInvoiceValidator->execute($data, $bitPayInvoice); + $this->bitPaySignatureValidator->execute($data, $headers); + $this->updateInvoiceValidator->execute($data, $bitPayInvoice, $headers); if (!$bitPayInvoice) { throw new ValidationFailed(self::MISSING_BITPAY_MESSAGE); diff --git a/app/Features/Invoice/UpdateInvoice/UpdateInvoiceUsingBitPayIpn.php b/app/Features/Invoice/UpdateInvoice/UpdateInvoiceUsingBitPayIpn.php index 4543884..52e46c1 100644 --- a/app/Features/Invoice/UpdateInvoice/UpdateInvoiceUsingBitPayIpn.php +++ b/app/Features/Invoice/UpdateInvoice/UpdateInvoiceUsingBitPayIpn.php @@ -16,6 +16,7 @@ use App\Models\Invoice\InvoicePayment; use App\Models\Invoice\InvoicePaymentCurrency; use App\Models\Invoice\InvoiceRepositoryInterface; +use App\Shared\Exceptions\SignatureVerificationFailed; class UpdateInvoiceUsingBitPayIpn { @@ -45,7 +46,7 @@ public function __construct( $this->bitPayConfiguration = $bitPayConfiguration; } - public function execute(string $uuid, array $data): void + public function execute(string $uuid, array $data, array $headers): void { $invoice = $this->invoiceRepository->findOneByUuid($uuid); if (!$invoice) { @@ -54,6 +55,7 @@ public function execute(string $uuid, array $data): void try { $client = $this->bitPayClientFactory->create(); + $bitPayInvoice = $client->getInvoice( $invoice->getBitpayId(), $this->bitPayConfiguration->getFacade(), @@ -61,11 +63,13 @@ public function execute(string $uuid, array $data): void ); $updateInvoiceData = $this->bitPayUpdateMapper->execute($data)->toArray(); - $this->updateInvoiceValidator->execute($data, $bitPayInvoice); + $this->updateInvoiceValidator->execute($data, $bitPayInvoice, $headers); $this->updateInvoice($invoice, $updateInvoiceData); $this->sendUpdateInvoiceNotification->execute($invoice, $data['event'] ?? null); + } catch (SignatureVerificationFailed $e) { + throw $e; } catch (\Exception | \TypeError $e) { $this->logger->error('INVOICE_UPDATE_FAIL', 'Failed to update invoice', [ 'id' => $invoice->id diff --git a/app/Features/Invoice/UpdateInvoice/UpdateInvoiceValidator.php b/app/Features/Invoice/UpdateInvoice/UpdateInvoiceValidator.php index 87b8cf1..6221bec 100644 --- a/app/Features/Invoice/UpdateInvoice/UpdateInvoiceValidator.php +++ b/app/Features/Invoice/UpdateInvoice/UpdateInvoiceValidator.php @@ -16,5 +16,5 @@ interface UpdateInvoiceValidator /** * @throws ValidationFailed */ - public function execute(?array $data, ?BitPayInvoice $bitPayInvoice): void; + public function execute(?array $data, ?BitPayInvoice $bitPayInvoice, array $headers): void; } diff --git a/app/Http/Controllers/Invoice/UpdateInvoiceController.php b/app/Http/Controllers/Invoice/UpdateInvoiceController.php index 2c2c3f7..331b154 100644 --- a/app/Http/Controllers/Invoice/UpdateInvoiceController.php +++ b/app/Http/Controllers/Invoice/UpdateInvoiceController.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; +use App\Shared\Exceptions\SignatureVerificationFailed; class UpdateInvoiceController extends Controller { @@ -30,17 +31,19 @@ public function execute(Request $request, string $uuid): Response { $this->logger->info('IPN_RECEIVED', 'Received IPN', $request->request->all()); - /** @var array $data */ - $data = $request->request->get('data'); - $event = $request->request->get('event'); + $payload = json_decode($request->getContent(), true); + $data = $payload['data']; + $event = $payload['event']; $data['uuid'] = $uuid; $data['event'] = $event['name'] ?? null; try { - $this->updateInvoice->execute($uuid, $data); + $this->updateInvoice->execute($uuid, $data, $request->headers->all()); } catch (MissingInvoice $e) { return response(null, Response::HTTP_NOT_FOUND); + } catch (SignatureVerificationFailed $e) { + return response($e->getMessage(), Response::HTTP_UNAUTHORIZED); } catch (\Exception $e) { return response('Unable to process update', Response::HTTP_BAD_REQUEST); } diff --git a/app/Infrastructure/Laravel/AppServiceProvider.php b/app/Infrastructure/Laravel/AppServiceProvider.php index 5c645cb..a9bd422 100644 --- a/app/Infrastructure/Laravel/AppServiceProvider.php +++ b/app/Infrastructure/Laravel/AppServiceProvider.php @@ -17,6 +17,8 @@ use App\Features\Invoice\UpdateInvoice\SendUpdateInvoiceEventStream; use App\Features\Invoice\UpdateInvoice\UpdateInvoiceIpnValidator; use App\Features\Invoice\UpdateInvoice\UpdateInvoiceValidator; +use App\Features\Invoice\UpdateInvoice\BitPaySignatureValidator; +use App\Features\Invoice\UpdateInvoice\BitPaySignatureValidatorInterface; use App\Features\Shared\Logger; use App\Features\Shared\SseConfiguration; use App\Features\Shared\UrlProvider; @@ -87,12 +89,18 @@ function () { SseMercureConfiguration::class ); + $this->app->bind( + BitPaySignatureValidatorInterface::class, + BitPaySignatureValidator::class + ); + $this->app->bind( UpdateInvoiceIpnValidator::class, function () { return new UpdateInvoiceIpnValidator( $this->app->make(BaseUpdateInvoiceValidator::class), - $this->app->make(Logger::class) + $this->app->make(Logger::class), + $this->app->make(BitPaySignatureValidator::class) ); } ); diff --git a/app/Shared/Exceptions/SignatureVerificationFailed.php b/app/Shared/Exceptions/SignatureVerificationFailed.php new file mode 100644 index 0000000..3a9a611 --- /dev/null +++ b/app/Shared/Exceptions/SignatureVerificationFailed.php @@ -0,0 +1,13 @@ +name('createInvoice'); Route::get('/invoices/{id}', [GetInvoiceViewController::class, 'execute'])->name('invoiceView'); Route::post('/invoices/{uuid}', [UpdateInvoiceController::class, 'execute'])->name('updateInvoice'); - diff --git a/tests/Integration/Features/Invoice/UpdateInvoice/UpdateInvoiceTest.php b/tests/Integration/Features/Invoice/UpdateInvoice/UpdateInvoiceTest.php new file mode 100644 index 0000000..c54c2b9 --- /dev/null +++ b/tests/Integration/Features/Invoice/UpdateInvoice/UpdateInvoiceTest.php @@ -0,0 +1,233 @@ +mock(BitPayClientFactory::class, function (MockInterface $mock) { + $mock->shouldReceive('create')->andReturn(new class ('', '') extends PosClient { + public function getInvoice( + string $invoiceId, + string $facade = Facade::Merchant, + bool $signRequest = true + ): Invoice { + $invoice = new Invoice(); + $invoice->setId(ExampleInvoice::BITPAY_ID); + $invoice->setOrderId(ExampleInvoice::BITPAY_ORDER_ID); + + return $invoice; + } + }); + }); + $this->mock(SendUpdateInvoiceEventStream::class, function (MockInterface $mock) { + $mock->shouldReceive('execute')->times(1); + }); + $this->mock(BitPayConfigurationInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getToken')->andReturn(ExampleInvoice::TOKEN); + $mock->shouldReceive('getDesign')->andReturn($this->createStub(Design::class)); + $mock->shouldReceive('getFacade')->andReturn(Facade::MERCHANT); + $mock->shouldReceive('isSignRequest')->andReturn(true); + }); + + $testedClass = $this->getTestedClass(); + $signature = base64_encode(hash_hmac('sha256', json_encode($data), ExampleInvoice::TOKEN, true)); + $testedClass->execute(ExampleInvoice::UUID, $data, ['x-signature' => [$signature]]); + + $invoice = $this->app->make(InvoiceRepositoryInterface::class)->findOne(1); + + Assert::assertEquals(ExampleInvoice::TOKEN, $invoice->token); + Assert::assertEquals('someBitpayId', $invoice->bitpay_id); + Assert::assertEquals('https://test.bitpay.com/invoice?id=MV9fy5iNDkqrg4qrfYpw75', $invoice->bitpay_url); + // phpcs:disable Generic.Files.LineLength.TooLong + Assert::assertEquals("{\"store\":\"store-1\",\"register\":\"2\",\"reg_transaction_no\":\"87678\",\"price\":\"76.70\"}", $invoice->pos_data_json); + Assert::assertEquals('expired', $invoice->status); + Assert::assertEquals(76.7, $invoice->price); + Assert::assertEquals('USD', $invoice->currency_code); + Assert::assertEquals('false', $invoice->exception_status); + + $eth = $invoice->getInvoicePayment()->paymentCurrencies()->where('currency_code', 'ETH')->first(); + $btc = $invoice->getInvoicePayment()->paymentCurrencies()->where('currency_code', 'BTC')->first(); + Assert::assertEquals(48312000000000000, $eth->total); + Assert::assertEquals(48312000000000000, $eth->subtotal); + Assert::assertEquals(347100, $btc->total); + Assert::assertEquals(342800, $btc->subtotal); + Assert::assertEquals(0, $invoice->getInvoicePayment()->amount_paid); + Assert::assertEquals('someBitpayOrderId', $invoice->bitpay_order_id); + } + + /** + * @test + */ + public function it_should_fail_updating_invoice_with_invalid_webhook_signature(): void + { + // given + $fileData = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'bitPayUpdate.json'); + $data = json_decode($fileData, true, 512, JSON_THROW_ON_ERROR); + + ExampleInvoice::createSaved(); + + $this->mock(BitPayClientFactory::class, function (MockInterface $mock) { + $mock->shouldReceive('create')->andReturn(new class ('', '') extends PosClient { + public function getInvoice( + string $invoiceId, + string $facade = Facade::Merchant, + bool $signRequest = true + ): Invoice { + $invoice = new Invoice(); + $invoice->setId(ExampleInvoice::BITPAY_ID); + $invoice->setOrderId(ExampleInvoice::BITPAY_ORDER_ID); + + return $invoice; + } + }); + }); + + $this->mock(SendUpdateInvoiceEventStream::class, function (MockInterface $mock) { + $mock->shouldReceive('execute')->never(); + }); + + $this->mock(BitPayConfigurationInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getToken')->andReturn(ExampleInvoice::TOKEN); + $mock->shouldReceive('getDesign')->andReturn($this->createStub(Design::class)); + $mock->shouldReceive('getFacade')->andReturn(Facade::MERCHANT); + $mock->shouldReceive('isSignRequest')->andReturn(true); + }); + + $headers = ['x-signature' => ['invalid-signature']]; + + $testedClass = $this->getTestedClass(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(BitPaySignatureValidator::INVALID_SIGNATURE_MESSAGE); + $testedClass->execute(ExampleInvoice::UUID, $data, $headers); + } + + /** + * @test + */ + public function it_should_fail_updating_invoice_with_missing_configuration_token(): void + { + // given + $fileData = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'bitPayUpdate.json'); + $data = json_decode($fileData, true, 512, JSON_THROW_ON_ERROR); + + ExampleInvoice::createSaved(); + + $this->mock(BitPayClientFactory::class, function (MockInterface $mock) { + $mock->shouldReceive('create')->andReturn(new class ('', '') extends PosClient { + public function getInvoice( + string $invoiceId, + string $facade = Facade::Merchant, + bool $signRequest = true + ): Invoice { + $invoice = new Invoice(); + $invoice->setId(ExampleInvoice::BITPAY_ID); + $invoice->setOrderId(ExampleInvoice::BITPAY_ORDER_ID); + + return $invoice; + } + }); + }); + + $this->mock(SendUpdateInvoiceEventStream::class, function (MockInterface $mock) { + $mock->shouldReceive('execute')->never(); + }); + + $this->mock(BitPayConfigurationInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getToken')->andReturn(null); + $mock->shouldReceive('getFacade')->andReturn(Facade::MERCHANT); + $mock->shouldReceive('isSignRequest')->andReturn(true); + }); + + // when + $testedClass = $this->getTestedClass(); + + // then + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(BitPaySignatureValidator::MISSING_TOKEN_MESSAGE); + $testedClass->execute(ExampleInvoice::UUID, $data, [ + 'x-signature' => 'signature' + ]); + } + + /** + * @test + */ + public function it_should_fail_updating_invoice_with_missing_sig_header(): void + { + // given + $fileData = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'bitPayUpdate.json'); + $data = json_decode($fileData, true, 512, JSON_THROW_ON_ERROR); + + ExampleInvoice::createSaved(); + + $this->mock(BitPayClientFactory::class, function (MockInterface $mock) { + $mock->shouldReceive('create')->andReturn(new class ('', '') extends PosClient { + public function getInvoice( + string $invoiceId, + string $facade = Facade::Merchant, + bool $signRequest = true + ): Invoice { + $invoice = new Invoice(); + $invoice->setId(ExampleInvoice::BITPAY_ID); + $invoice->setOrderId(ExampleInvoice::BITPAY_ORDER_ID); + + return $invoice; + } + }); + }); + + $this->mock(SendUpdateInvoiceEventStream::class, function (MockInterface $mock) { + $mock->shouldReceive('execute')->never(); + }); + + $this->mock(BitPayConfigurationInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getToken')->andReturn(ExampleInvoice::TOKEN); + $mock->shouldReceive('getFacade')->andReturn(Facade::MERCHANT); + $mock->shouldReceive('isSignRequest')->andReturn(true); + }); + + $testedClass = $this->getTestedClass(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(BitPaySignatureValidator::MISSING_SIGNATURE_MESSAGE); + $testedClass->execute(ExampleInvoice::UUID, $data, []); // Empty headers array + } + + private function getTestedClass(): UpdateInvoiceUsingBitPayIpn + { + return $this->app->make(UpdateInvoiceUsingBitPayIpn::class); + } +} diff --git a/tests/Integration/Features/Invoice/UpdateInvoice/UpdateInvoiceTestCase.php b/tests/Integration/Features/Invoice/UpdateInvoice/UpdateInvoiceTestCase.php deleted file mode 100644 index 6600449..0000000 --- a/tests/Integration/Features/Invoice/UpdateInvoice/UpdateInvoiceTestCase.php +++ /dev/null @@ -1,83 +0,0 @@ -mock(BitPayClientFactory::class, function (MockInterface $mock) { - $mock->shouldReceive('create')->andReturn(new class ('', '') extends PosClient { - public function getInvoice( - string $invoiceId, - string $facade = Facade::Merchant, - bool $signRequest = true - ): Invoice { - $invoice = new Invoice(); - $invoice->setId(ExampleInvoice::BITPAY_ID); - $invoice->setOrderId(ExampleInvoice::BITPAY_ORDER_ID); - - return $invoice; - } - }); - }); - $this->mock(SendUpdateInvoiceEventStream::class, function (MockInterface $mock) { - $mock->shouldReceive('execute')->times(1); - }); - - $testedClass = $this->getTestedClass(); - $testedClass->execute(ExampleInvoice::UUID, $data); - - $invoice = $this->app->make(InvoiceRepositoryInterface::class)->findOne(1); - - Assert::assertEquals(ExampleInvoice::TOKEN, $invoice->token); - Assert::assertEquals('someBitpayId', $invoice->bitpay_id); - Assert::assertEquals('https://test.bitpay.com/invoice?id=MV9fy5iNDkqrg4qrfYpw75', $invoice->bitpay_url); - // phpcs:disable Generic.Files.LineLength.TooLong - Assert::assertEquals("{\"store\":\"store-1\",\"register\":\"2\",\"reg_transaction_no\":\"87678\",\"price\":\"76.70\"}", $invoice->pos_data_json); - Assert::assertEquals('expired', $invoice->status); - Assert::assertEquals(76.7, $invoice->price); - Assert::assertEquals('USD', $invoice->currency_code); - Assert::assertEquals('false', $invoice->exception_status); - - $eth = $invoice->getInvoicePayment()->paymentCurrencies()->where('currency_code', 'ETH')->first(); - $btc = $invoice->getInvoicePayment()->paymentCurrencies()->where('currency_code', 'BTC')->first(); - Assert::assertEquals(48312000000000000, $eth->total); - Assert::assertEquals(48312000000000000, $eth->subtotal); - Assert::assertEquals(347100, $btc->total); - Assert::assertEquals(342800, $btc->subtotal); - Assert::assertEquals(0, $invoice->getInvoicePayment()->amount_paid); - Assert::assertEquals('someBitpayOrderId', $invoice->bitpay_order_id); - } - - private function getTestedClass(): UpdateInvoiceUsingBitPayIpn - { - return $this->app->make(UpdateInvoiceUsingBitPayIpn::class); - } -} diff --git a/tests/Unit/Features/Invoice/UpdateInvoice/ValidateBitPayWebhookTest.php b/tests/Unit/Features/Invoice/UpdateInvoice/ValidateBitPayWebhookTest.php new file mode 100644 index 0000000..36ef579 --- /dev/null +++ b/tests/Unit/Features/Invoice/UpdateInvoice/ValidateBitPayWebhookTest.php @@ -0,0 +1,78 @@ +createMock(BitPayConfigurationInterface::class); + $bitpayConfig->expects($this->once()) + ->method('getToken') + ->willReturn(null); + + $validator = new BitPaySignatureValidator($bitpayConfig); + $this->expectException(\RuntimeException::class); + $validator->execute([], [ + 'x-signature' => 'test-signature', + ]); + } + + /** + * @test + */ + public function it_should_return_error_when_signature_header_is_missing(): void + { + $bitpayConfig = $this->createMock(BitPayConfigurationInterface::class); + $validator = new BitPaySignatureValidator($bitpayConfig); + $this->expectException(\RuntimeException::class); + + $validator->execute([], []); + } + + /** + * @test + */ + public function it_should_return_error_when_signature_does_not_match(): void + { + $bitpayConfig = $this->createMock(BitPayConfigurationInterface::class); + $bitpayConfig->expects($this->once()) + ->method('getToken') + ->willReturn('test-token'); + + $headers = ['x-signature' => 'invalid-signature']; + + $validator = new BitPaySignatureValidator($bitpayConfig); + $this->expectException(\RuntimeException::class); + $validator->execute([], $headers); + } + + /** + * @test + */ + public function it_should_allow_request_when_signature_is_valid(): void + { + $token = 'test-token'; + $testContent = ['test-content']; + + $bitpayConfig = $this->createMock(BitPayConfigurationInterface::class); + $bitpayConfig->expects($this->once()) + ->method('getToken') + ->willReturn($token); + + $expectedSignature = base64_encode(hash_hmac('sha256', json_encode($testContent), $token, true)); + + $validator = new BitPaySignatureValidator($bitpayConfig); + + $validator->execute($testContent, ['x-signature' => [$expectedSignature]]); + } +}