diff --git a/addons/payment_buckaroo/tests/test_buckaroo.py b/addons/payment_buckaroo/tests/test_buckaroo.py index b105a54b2d2..b4dec515497 100644 --- a/addons/payment_buckaroo/tests/test_buckaroo.py +++ b/addons/payment_buckaroo/tests/test_buckaroo.py @@ -73,6 +73,21 @@ class BuckarooTest(BuckarooCommon, PaymentHttpCommon): self._make_http_post_request(url, data=self.async_notification_data) self.assertEqual(tx.state, 'done') + @mute_logger('odoo.addons.payment_buckaroo.controllers.main') + def test_webhook_notification_triggers_signature_check(self): + """ Test that receiving a webhook notification triggers a signature check. """ + self.create_transaction('redirect') + url = self._build_url(BuckarooController._return_url) + with patch( + 'odoo.addons.payment_buckaroo.controllers.main.BuckarooController' + '._verify_notification_signature' + ) as signature_check_mock, patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ): + self._make_http_post_request(url, data=self.async_notification_data) + self.assertEqual(signature_check_mock.call_count, 1) + def test_accept_notification_with_valid_signature(self): """ Test the verification of a notification with a valid signature. """ tx = self.create_transaction('redirect') diff --git a/addons/payment_payulatam/controllers/main.py b/addons/payment_payulatam/controllers/main.py index fce44ee736a..59bf31b781e 100644 --- a/addons/payment_payulatam/controllers/main.py +++ b/addons/payment_payulatam/controllers/main.py @@ -3,17 +3,115 @@ import logging import pprint +from werkzeug.exceptions import Forbidden + from odoo import http +from odoo.exceptions import ValidationError from odoo.http import request +from odoo.tools import consteq _logger = logging.getLogger(__name__) class PayuLatamController(http.Controller): _return_url = '/payment/payulatam/return' + _webhook_url = '/payment/payulatam/webhook' @http.route(_return_url, type='http', auth='public', methods=['GET']) - def payulatam_return(self, **data): + def payulatam_return_from_checkout(self, **data): + """ Process the notification data sent by PayU Latam after redirection from checkout. + + See http://developers.payulatam.com/latam/en/docs/integrations/webcheckout-integration/response-page.html. + + :param dict data: The notification data + """ _logger.info("handling redirection from PayU Latam with data:\n%s", pprint.pformat(data)) - request.env['payment.transaction'].sudo()._handle_notification_data('payulatam', data) + + # Check the integrity of the notification + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( + 'payulatam', data + ) + self._verify_notification_signature(data, tx_sudo) + + # Handle the notification data + tx_sudo._handle_notification_data('payulatam', data) return request.redirect('/payment/status') + + @http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False) + def payulatam_webhook(self, **raw_data): + """ Process the notification data sent by PayU Latam to the webhook. + + See http://developers.payulatam.com/latam/en/docs/integrations/webcheckout-integration/confirmation-page.html. + + :param dict raw_data: The un-formatted notification data + :return: An empty string to acknowledge the notification + :rtype: str + """ + _logger.info( + "notification received from PayU Latam with data:\n%s", pprint.pformat(raw_data) + ) + data = self._normalize_data_keys(raw_data) + + try: + # Check the origin and integrity of the notification + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( + 'payulatam', data + ) + self._verify_notification_signature(data, tx_sudo) # Use the normalized data. + + # Handle the notification data + tx_sudo._handle_notification_data('payulatam', data) + except ValidationError: # Acknowledge the notification to avoid getting spammed + _logger.exception("unable to handle the notification data; skipping to acknowledge") + + return '' + + @staticmethod + def _normalize_data_keys(webhook_notification_data): + """ Reshape the webhook notification data to process them as redirect notification data. + + :param dict webhook_notification_data: The webhook notification data + :return: The normalized notification data + :rtype: dict + """ + state_pol = webhook_notification_data.get('state_pol') + if state_pol == '4': + lap_transaction_state = 'APPROVED' + elif state_pol == '6': + lap_transaction_state = 'DECLINED' + elif state_pol == '5': + lap_transaction_state = 'EXPIRED' + else: + lap_transaction_state = f'INVALID state_pol {state_pol}' + return { + 'lapTransactionState': lap_transaction_state, + 'transactionState': webhook_notification_data.get('state_pol'), + 'TX_VALUE': webhook_notification_data.get('value'), + 'currency': webhook_notification_data.get('currency'), + 'referenceCode': webhook_notification_data.get('reference_sale'), + 'transactionId': webhook_notification_data.get('transaction_id'), + 'message': webhook_notification_data.get('response_message_pol'), + 'signature': webhook_notification_data.get('sign'), + } + + @staticmethod + def _verify_notification_signature(notification_data, tx_sudo): + """ Check that the received signature matches the expected one. + + :param dict notification_data: The notification data + :param recordset tx_sudo: The sudoed transaction referenced by the notification data, as a + `payment.transaction` record + :return: None + :raise: :class:`werkzeug.exceptions.Forbidden` if the signatures don't match + """ + # Retrieve the received signature from the data + received_signature = notification_data.get('signature') + if not received_signature: + _logger.warning("received notification with missing signature") + raise Forbidden() + + # Compare the received signature with the expected signature computed from the data + expected_signature = tx_sudo.acquirer_id._payulatam_generate_sign(notification_data) + if not consteq(received_signature, expected_signature): + _logger.warning("received notification with invalid signature") + raise Forbidden() diff --git a/addons/payment_payulatam/models/payment_transaction.py b/addons/payment_payulatam/models/payment_transaction.py index b0193b2ca07..d857ce8a14c 100644 --- a/addons/payment_payulatam/models/payment_transaction.py +++ b/addons/payment_payulatam/models/payment_transaction.py @@ -63,6 +63,7 @@ class PaymentTransaction(models.Model): api_url = 'https://checkout.payulatam.com/ppp-web-gateway-payu/' \ if self.acquirer_id.state == 'enabled' \ else 'https://sandbox.checkout.payulatam.com/ppp-web-gateway-payu/' + base_url = self.get_base_url() payulatam_values = { 'merchantId': self.acquirer_id.payulatam_merchant_id, 'referenceCode': self.reference, @@ -74,7 +75,8 @@ class PaymentTransaction(models.Model): 'accountId': self.acquirer_id.payulatam_account_id, 'buyerFullName': self.partner_name, 'buyerEmail': self.partner_email, - 'responseUrl': urls.url_join(self.get_base_url(), PayuLatamController._return_url), + 'responseUrl': urls.url_join(base_url, PayuLatamController._return_url), + 'confirmationUrl': urls.url_join(base_url, PayuLatamController._webhook_url), 'api_url': api_url, } if self.acquirer_id.state != 'enabled': @@ -91,40 +93,19 @@ class PaymentTransaction(models.Model): :param dict notification_data: The notification data sent by the provider :return: The transaction if found :rtype: recordset of `payment.transaction` - :raise: ValidationError if inconsistent data were received :raise: ValidationError if the data match no transaction - :raise: ValidationError if the signature can not be verified """ tx = super()._get_tx_from_notification_data(provider, notification_data) if provider != 'payulatam' or len(tx) == 1: return tx reference = notification_data.get('referenceCode') - sign = notification_data.get('signature') - if not reference or not sign: - raise ValidationError( - "PayU Latam: " + _( - "Received data with missing reference (%(ref)s) or sign (%(sign)s).", - ref=reference, sign=sign - ) - ) - tx = self.search([('reference', '=', reference), ('provider', '=', 'payulatam')]) if not tx: raise ValidationError( "PayU Latam: " + _("No transaction found matching reference %s.", reference) ) - # Verify signature - sign_check = tx.acquirer_id._payulatam_generate_sign(notification_data, incoming=True) - if not hmac.compare_digest(sign_check, sign): - raise ValidationError( - "PayU Latam: " + _( - "Invalid sign: received %(sign)s, computed %(check)s.", - sign=sign, check=sign_check - ) - ) - return tx def _process_notification_data(self, notification_data): diff --git a/addons/payment_payulatam/tests/common.py b/addons/payment_payulatam/tests/common.py index b26a2507ad2..c06eef0e0e0 100644 --- a/addons/payment_payulatam/tests/common.py +++ b/addons/payment_payulatam/tests/common.py @@ -18,3 +18,13 @@ class PayULatamCommon(PaymentCommon): # Override default values cls.acquirer = cls.payulatam cls.currency = cls.currency_euro + + cls.async_notification_data = { + 'currency': cls.currency.name, + 'reference_sale': cls.reference, + 'response_message_pol': 'APPROVED', + 'sign': '6b4728ddb01317af58f92b8accdb4a42', + 'state_pol': '4', + 'transaction_id': '7008bc34-8258-4857-b866-7d4d7982bd73', + 'value': str(cls.amount) + } diff --git a/addons/payment_payulatam/tests/test_payulatam.py b/addons/payment_payulatam/tests/test_payulatam.py index b8449f9062c..7d4cfe1a7d5 100644 --- a/addons/payment_payulatam/tests/test_payulatam.py +++ b/addons/payment_payulatam/tests/test_payulatam.py @@ -1,19 +1,23 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from unittest.mock import patch + from freezegun import freeze_time +from werkzeug.exceptions import Forbidden from odoo.exceptions import ValidationError from odoo.fields import Command from odoo.tests import tagged from odoo.tools import mute_logger -from .common import PayULatamCommon -from ..controllers.main import PayuLatamController -from ..models.payment_acquirer import SUPPORTED_CURRENCIES +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.payment_payulatam.controllers.main import PayuLatamController +from odoo.addons.payment_payulatam.models.payment_acquirer import SUPPORTED_CURRENCIES +from odoo.addons.payment_payulatam.tests.common import PayULatamCommon @tagged('post_install', '-at_install') -class PayULatamTest(PayULatamCommon): +class PayULatamTest(PayULatamCommon, PaymentHttpCommon): def test_compatibility_with_supported_currencies(self): """ Test that the PayULatam acquirer is compatible with all supported currencies. """ @@ -67,6 +71,7 @@ class PayULatamTest(PayULatamCommon): 'buyerEmail': self.partner.email, 'buyerFullName': self.partner.name, 'responseUrl': self._build_url(PayuLatamController._return_url), + 'confirmationUrl': self._build_url(PayuLatamController._webhook_url), 'test': str(1), # testing is always performed in test mode } expected_values['signature'] = self.payulatam._payulatam_generate_sign( @@ -147,6 +152,63 @@ class PayULatamTest(PayULatamCommon): self.assertEqual(tx.state, 'done', 'Payulatam: wrong state after receiving a valid pending notification') self.assertEqual(tx.acquirer_reference, 'b232989a-4aa8-42d1-bace-153236eee791', 'Payulatam: wrong txn_id after receiving a valid pending notification') + @mute_logger('odoo.addons.payment_payulatam.controllers.main') + def test_webhook_notification_confirms_transaction(self): + """ Test the processing of a webhook notification. """ + tx = self.create_transaction('redirect') + url = self._build_url(PayuLatamController._webhook_url) + with patch( + 'odoo.addons.payment_payulatam.controllers.main.PayuLatamController' + '._verify_notification_signature' + ): + self._make_http_post_request(url, data=self.async_notification_data) + self.assertEqual(tx.state, 'done') + + @mute_logger('odoo.addons.payment_payulatam.controllers.main') + def test_webhook_notification_triggers_signature_check(self): + """ Test that receiving a webhook notification triggers a signature check. """ + self.create_transaction('redirect') + url = self._build_url(PayuLatamController._webhook_url) + with patch( + 'odoo.addons.payment_payulatam.controllers.main.PayuLatamController' + '._verify_notification_signature' + ) as signature_check_mock, patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ): + self._make_http_post_request(url, data=self.async_notification_data) + self.assertEqual(signature_check_mock.call_count, 1) + + def test_accept_notification_with_valid_signature(self): + """ Test the verification of a notification with a valid signature. """ + tx = self.create_transaction('redirect') + payload = PayuLatamController._normalize_data_keys(self.async_notification_data) + self._assert_does_not_raise( + Forbidden, PayuLatamController._verify_notification_signature, payload, tx + ) + + @mute_logger('odoo.addons.payment_payulatam.controllers.main') + def test_reject_notification_with_missing_signature(self): + """ Test the verification of a notification with a missing signature. """ + tx = self.create_transaction('redirect') + payload = PayuLatamController._normalize_data_keys( + dict(self.async_notification_data, sign=None) + ) + self.assertRaises( + Forbidden, PayuLatamController._verify_notification_signature, payload, tx + ) + + @mute_logger('odoo.addons.payment_payulatam.controllers.main') + def test_reject_notification_with_invalid_signature(self): + """ Test the verification of a notification with an invalid signature. """ + tx = self.create_transaction('redirect') + payload = PayuLatamController._normalize_data_keys( + dict(self.async_notification_data, sign='dummy') + ) + self.assertRaises( + Forbidden, PayuLatamController._verify_notification_signature, payload, tx + ) + def test_payulatam_neutralize(self): self.env['payment.acquirer']._neutralize() diff --git a/addons/payment_payulatam/views/payment_payulatam_templates.xml b/addons/payment_payulatam/views/payment_payulatam_templates.xml index 2a34ae4520e..070fb945490 100644 --- a/addons/payment_payulatam/views/payment_payulatam_templates.xml +++ b/addons/payment_payulatam/views/payment_payulatam_templates.xml @@ -18,6 +18,7 @@ +