diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index 8d5f5bf65df..763a4d05a2a 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import json import logging +import pprint import werkzeug from odoo import http @@ -52,3 +54,9 @@ class StripeController(http.Controller): @http.route('/payment/stripe/s2s/process_payment_intent', type='json', auth='public', csrf=False) def stripe_s2s_process_payment_intent(self, **post): return request.env['payment.transaction'].sudo().form_feedback(post, 'stripe') + + @http.route('/payment/stripe/webhook', type='json', auth='public', csrf=False) + def stripe_webhook(self, **kwargs): + data = json.loads(request.httprequest.data) + request.env['payment.acquirer'].sudo()._handle_stripe_webhook(data) + return 'OK' \ No newline at end of file diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index ca19e288185..7e6a685c868 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -1,16 +1,22 @@ # coding: utf-8 +from collections import namedtuple +from datetime import datetime +from hashlib import sha256 +import hmac +import json import logging import requests import pprint from requests.exceptions import HTTPError from werkzeug import urls -from collections import namedtuple from odoo import api, fields, models, _ +from odoo.http import request from odoo.tools.float_utils import float_round +from odoo.tools import consteq +from odoo.exceptions import ValidationError -from odoo.addons.payment.models.payment_acquirer import ValidationError from odoo.addons.payment_stripe.controllers.main import StripeController _logger = logging.getLogger(__name__) @@ -20,6 +26,7 @@ INT_CURRENCIES = [ u'BIF', u'XAF', u'XPF', u'CLP', u'KMF', u'DJF', u'GNF', u'JPY', u'MGA', u'PYG', u'RWF', u'KRW', u'VUV', u'VND', u'XOF' ] +STRIPE_SIGNATURE_AGE_TOLERANCE = 600 # in seconds class PaymentAcquirerStripe(models.Model): @@ -30,6 +37,11 @@ class PaymentAcquirerStripe(models.Model): ], ondelete={'stripe': 'set default'}) stripe_secret_key = fields.Char(required_if_provider='stripe', groups='base.group_user') stripe_publishable_key = fields.Char(required_if_provider='stripe', groups='base.group_user') + stripe_webhook_secret = fields.Char( + string='Stripe Webhook Secret', groups='base.group_user', + help="If you enable webhooks, this secret is used to verify the electronic " + "signature of events sent by Stripe to Odoo. Failing to set this field in Odoo " + "will disable the webhook system for this acquirer entirely.") stripe_image_url = fields.Char( "Checkout Image URL", groups='base.group_user', help="A relative or absolute URL pointing to a square image of your " @@ -180,6 +192,90 @@ class PaymentAcquirerStripe(models.Model): res['tokenize'].append('stripe') return res + def _handle_stripe_webhook(self, data): + """Process a webhook payload from Stripe. + + Post-process a webhook payload to act upon the matching payment.transaction + record in Odoo. + """ + wh_type = data.get('type') + if wh_type != 'checkout.session.completed': + _logger.info('unsupported webhook type %s, ignored', wh_type) + return False + + _logger.info('handling %s webhook event from stripe', wh_type) + + stripe_object = data.get('data', {}).get('object') + if not stripe_object: + raise ValidationError('Stripe Webhook data does not conform to the expected API.') + if wh_type == 'checkout.session.completed': + return self._handle_checkout_webhook(stripe_object) + return False + + def _verify_stripe_signature(self): + """ + :return: true if and only if signature matches hash of payload calculated with secret + :raises ValidationError: if signature doesn't match + """ + if not self.stripe_webhook_secret: + raise ValidationError('webhook event received but webhook secret is not configured') + signature = request.httprequest.headers.get('Stripe-Signature') + body = request.httprequest.data + + sign_data = {k: v for (k, v) in [s.split('=') for s in signature.split(',')]} + event_timestamp = int(sign_data['t']) + if datetime.utcnow().timestamp() - event_timestamp > STRIPE_SIGNATURE_AGE_TOLERANCE: + _logger.error('stripe event is too old, event is discarded') + raise ValidationError('event timestamp older than tolerance') + + signed_payload = "%s.%s" % (event_timestamp, body.decode('utf-8')) + + actual_signature = sign_data['v1'] + expected_signature = hmac.new(self.stripe_webhook_secret.encode('utf-8'), + signed_payload.encode('utf-8'), + sha256).hexdigest() + + if not consteq(expected_signature, actual_signature): + _logger.error( + 'incorrect webhook signature from Stripe, check if the webhook signature ' + 'in Odoo matches to one in the Stripe dashboard') + raise ValidationError('incorrect webhook signature') + + return True + + def _handle_checkout_webhook(self, checkout_object: dir): + """ + Process a checkout.session.completed Stripe web hook event, + mark related payment successful + + :param checkout_object: provided in the request body + :return: True if and only if handling went well, False otherwise + :raises ValidationError: if input isn't usable + """ + tx_reference = checkout_object.get('client_reference_id') + data = {'reference': tx_reference} + try: + odoo_tx = self.env['payment.transaction']._stripe_form_get_tx_from_data(data) + except ValidationError as e: + _logger.info('Received notification for tx %s. Skipped it because of %s', tx_reference, e) + return False + + PaymentAcquirerStripe._verify_stripe_signature(odoo_tx.acquirer_id) + + url = 'payment_intents/%s' % odoo_tx.stripe_payment_intent + stripe_tx = odoo_tx.acquirer_id._stripe_request(url) + + if 'error' in stripe_tx: + error = stripe_tx['error'] + raise ValidationError("Could not fetch Stripe payment intent related to %s because of %s; see %s" % ( + odoo_tx, error['message'], error['doc_url'])) + + if stripe_tx.get('charges') and stripe_tx.get('charges').get('total_count'): + charge = stripe_tx.get('charges').get('data')[0] + data.update(charge) + + return odoo_tx.form_feedback(data, 'stripe') + class PaymentTransactionStripe(models.Model): _inherit = 'payment.transaction' @@ -187,7 +283,6 @@ class PaymentTransactionStripe(models.Model): stripe_payment_intent = fields.Char(string='Stripe Payment Intent ID', readonly=True) stripe_payment_intent_secret = fields.Char(string='Stripe Payment Intent Secret', readonly=True) - def _get_processing_info(self): res = super()._get_processing_info() if self.acquirer_id.provider == 'stripe': @@ -298,10 +393,11 @@ class PaymentTransactionStripe(models.Model): status = tree.get('status') tx_id = tree.get('id') tx_secret = tree.get("client_secret") + pi_id = tree.get('payment_intent') vals = { "date": fields.datetime.now(), "acquirer_reference": tx_id, - "stripe_payment_intent": tx_id, + "stripe_payment_intent": pi_id or tx_id, "stripe_payment_intent_secret": tx_secret } if status == 'succeeded': diff --git a/addons/payment_stripe/tests/stripe_mocks.py b/addons/payment_stripe/tests/stripe_mocks.py new file mode 100644 index 00000000000..6364fbf115a --- /dev/null +++ b/addons/payment_stripe/tests/stripe_mocks.py @@ -0,0 +1,31 @@ +checkout_session_signature = 't=1591264652,v1=1f0d3e035d8de956396b1d91727267fbbf483253e7702e46357b4d2bfa078ba4,v0=20d76342f4704d49f8f89db03acff7cf04afa48ca70a22d608b4649b332c1f51' +checkout_session_body = b'{\n "id": "evt_1GqFpHAlCFm536g8NYSLoccF",\n "object": "event",\n "api_version": "2019-05-16",\n "created": 1591264651,\n "data": {\n "object": {\n "id": "cs_test_SI8yz61JCZ4gxd7Z5oGfQSn9ZbubC6SZF3bJTxvy2PVqSd3dzbDV1kyd",\n "object": "checkout.session",\n "billing_address_collection": null,\n "cancel_url": "https://httpbin.org/post",\n "client_reference_id": null,\n "customer": "cus_HP3xLqXMIwBfTg",\n "customer_email": null,\n "display_items": [\n {\n "amount": 1500,\n "currency": "usd",\n "custom": {\n "description": "comfortable cotton t-shirt",\n "images": null,\n "name": "t-shirt"\n },\n "quantity": 2,\n "type": "custom"\n }\n ],\n "livemode": false,\n "locale": null,\n "metadata": {\n },\n "mode": "payment",\n "payment_intent": "pi_1GqFpCAlCFm536g8HsBSvSEt",\n "payment_method_types": [\n "card"\n ],\n "setup_intent": null,\n "shipping": null,\n "shipping_address_collection": null,\n "submit_type": null,\n "subscription": null,\n "success_url": "https://httpbin.org/post"\n }\n },\n "livemode": false,\n "pending_webhooks": 2,\n "request": {\n "id": null,\n "idempotency_key": null\n },\n "type": "checkout.session.completed"\n}' + +checkout_session_object = {'billing_address_collection': None, + 'cancel_url': 'https://httpbin.org/post', + 'client_reference_id': "tx_ref_test_handle_checkout_webhook", + 'customer': 'cus_HOgyjnjdgY6pmY', + 'customer_email': None, + 'display_items': [{'amount': 1500, + 'currency': 'usd', + 'custom': {'description': 'comfortable ' + 'cotton ' + 't-shirt', + 'images': None, + 'name': 't-shirt'}, + 'quantity': 2, + 'type': 'custom'}], + 'id': 'cs_test_sbTG0yGwTszAqFUP8Ulecr1bUwEyQEo29M8taYvdP7UA6Qr37qX6uA6w', + 'livemode': False, + 'locale': None, + 'metadata': {}, + 'mode': 'payment', + 'object': 'checkout.session', + 'payment_intent': 'pi_1GptaRAlCFm536g8AfCF6Zi0', + 'payment_method_types': ['card'], + 'setup_intent': None, + 'shipping': None, + 'shipping_address_collection': None, + 'submit_type': None, + 'subscription': None, + 'success_url': 'https://httpbin.org/post'} diff --git a/addons/payment_stripe/tests/test_stripe.py b/addons/payment_stripe/tests/test_stripe.py index 92b3a8d1be7..4102fd48319 100644 --- a/addons/payment_stripe/tests/test_stripe.py +++ b/addons/payment_stripe/tests/test_stripe.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- import odoo from odoo import fields +from odoo.exceptions import ValidationError from odoo.addons.payment.tests.common import PaymentAcquirerCommon +from unittest.mock import patch +from . import stripe_mocks +from ..models.payment import STRIPE_SIGNATURE_AGE_TOLERANCE from odoo.tools import mute_logger @@ -14,6 +18,7 @@ class StripeCommon(PaymentAcquirerCommon): cls.stripe.write({ 'stripe_secret_key': 'sk_test_KJtHgNwt2KS3xM7QJPr4O5E8', 'stripe_publishable_key': 'pk_test_QSPnimmb4ZhtkEy3Uhdm4S6J', + 'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprLVXT5jBLxB', 'state': 'test', }) cls.token = cls.env['payment.token'].create({ @@ -107,3 +112,145 @@ class StripeTest(StripeCommon): actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} self.assertEqual({'card'}, actual) + + def test_discarded_webhook(self): + with self.assertRaises(ValidationError): + self.env['payment.acquirer']._handle_stripe_webhook(dict(type='payment.intent.succeeded')) + + def test_handle_checkout_webhook_no_secret(self): + self.stripe.stripe_webhook_secret = None + + with self.assertRaises(ValidationError): + self.env['payment.acquirer']._handle_stripe_webhook(dict(type='checkout.session.completed')) + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_handle_checkout_webhook(self, dt, request): + # pass signature verification + dt.utcnow.return_value.timestamp.return_value = 1591264652 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body + # test setup + tx = self.env['payment.transaction'].create({ + 'reference': 'tx_ref_test_handle_checkout_webhook', + 'currency_id': self.currency_euro.id, + 'acquirer_id': self.stripe.id, + 'partner_id': self.buyer_id, + 'payment_token_id': self.token.id, + 'type': 'server2server', + 'amount': 30 + }) + res = tx.with_context(off_session=True)._stripe_create_payment_intent() + tx.stripe_payment_intent = res.get('payment_intent') + stripe_object = stripe_mocks.checkout_session_object + + actual = self.stripe._handle_checkout_webhook(stripe_object) + + self.assertTrue(actual) + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_handle_checkout_webhook_wrong_amount(self, dt, request): + # pass signature verification + dt.utcnow.return_value.timestamp.return_value = 1591264652 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body + # test setup + bad_tx = self.env['payment.transaction'].create({ + 'reference': 'tx_ref_test_handle_checkout_webhook_wrong_amount', + 'currency_id': self.currency_euro.id, + 'acquirer_id': self.stripe.id, + 'partner_id': self.buyer_id, + 'payment_token_id': self.token.id, + 'type': 'server2server', + 'amount': 10 + }) + wrong_amount_stripe_payment_intent = bad_tx.with_context(off_session=True)._stripe_create_payment_intent() + tx = self.env['payment.transaction'].create({ + 'reference': 'tx_ref_test_handle_checkout_webhook', + 'currency_id': self.currency_euro.id, + 'acquirer_id': self.stripe.id, + 'partner_id': self.buyer_id, + 'payment_token_id': self.token.id, + 'type': 'server2server', + 'amount': 30 + }) + tx.stripe_payment_intent = wrong_amount_stripe_payment_intent.get('payment_intent') + stripe_object = stripe_mocks.checkout_session_object + + actual = self.env['payment.acquirer']._handle_checkout_webhook(stripe_object) + + self.assertFalse(actual) + + def test_handle_checkout_webhook_no_odoo_tx(self): + stripe_object = stripe_mocks.checkout_session_object + + actual = self.stripe._handle_checkout_webhook(stripe_object) + + self.assertFalse(actual) + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_handle_checkout_webhook_no_stripe_tx(self, dt, request): + # pass signature verification + dt.utcnow.return_value.timestamp.return_value = 1591264652 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body + # test setup + self.env['payment.transaction'].create({ + 'reference': 'tx_ref_test_handle_checkout_webhook', + 'currency_id': self.currency_euro.id, + 'acquirer_id': self.stripe.id, + 'partner_id': self.buyer_id, + 'payment_token_id': self.token.id, + 'type': 'server2server', + 'amount': 30 + }) + stripe_object = stripe_mocks.checkout_session_object + + with self.assertRaises(ValidationError): + self.stripe._handle_checkout_webhook(stripe_object) + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_verify_stripe_signature(self, dt, request): + dt.utcnow.return_value.timestamp.return_value = 1591264652 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body + + actual = self.stripe._verify_stripe_signature() + + self.assertTrue(actual) + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_verify_stripe_signature_tampered_body(self, dt, request): + dt.utcnow.return_value.timestamp.return_value = 1591264652 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body.replace(b'1500', b'10') + + with self.assertRaises(ValidationError): + self.stripe._verify_stripe_signature() + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_verify_stripe_signature_wrong_secret(self, dt, request): + dt.utcnow.return_value.timestamp.return_value = 1591264652 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body + self.stripe.write({ + 'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprL_TAMPERED', + }) + + with self.assertRaises(ValidationError): + self.stripe._verify_stripe_signature() + + @patch('odoo.addons.payment_stripe.models.payment.request') + @patch('odoo.addons.payment_stripe.models.payment.datetime') + def test_verify_stripe_signature_too_old(self, dt, request): + dt.utcnow.return_value.timestamp.return_value = 1591264652 + STRIPE_SIGNATURE_AGE_TOLERANCE + 1 + request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} + request.httprequest.data = stripe_mocks.checkout_session_body + + with self.assertRaises(ValidationError): + self.stripe._verify_stripe_signature() diff --git a/addons/payment_stripe/views/payment_views.xml b/addons/payment_stripe/views/payment_views.xml index b66c75c71cc..74d9af04a24 100644 --- a/addons/payment_stripe/views/payment_views.xml +++ b/addons/payment_stripe/views/payment_views.xml @@ -9,6 +9,7 @@ +