From 1a1a059eb60f4334591e8311ab04077d7f1bb880 Mon Sep 17 00:00:00 2001 From: Will Harris Date: Sun, 17 Jan 2016 14:57:42 +0100 Subject: [PATCH] Implement SHA256 support, to work with newer Adyen merchant accounts. --- .editorconfig | 15 +++ README.rst | 2 + adyen/config.py | 7 ++ adyen/facade.py | 19 +--- adyen/gateway.py | 222 +++++++++++++++++++++++++++++---------- adyen/settings_config.py | 13 ++- tests/test_config.py | 19 +++- tests/test_redirects.py | 25 ++++- tests/test_requests.py | 18 +++- tests/test_unit_tests.py | 10 +- 10 files changed, 268 insertions(+), 82 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6fadc39 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 100 + +[*.rst] +indent_size = 2 diff --git a/README.rst b/README.rst index 2cff176..370dbe0 100644 --- a/README.rst +++ b/README.rst @@ -56,6 +56,8 @@ Edit your ``settings.py`` to set the following settings: (e.g. 'https://test.adyen.com/hpp/select.shtml'). * ``ADYEN_IP_ADDRESS_HTTP_HEADER`` - Optional. The header in `META` to inspect to determine the IP address of the request. Defaults to `REMOTE_ADDR`. +* ``ADYEN_HMAC_ALGORITHM`` - Optional. The algorithm to use when calculating the HMAC for + the merchant signature. Supports `'SHA1'` and `'SHA256'`. Defaults to `'SHA1'`. You will likely need to specify different settings in your test environment as opposed to your production environment. diff --git a/adyen/config.py b/adyen/config.py index 36607c9..b35302d 100644 --- a/adyen/config.py +++ b/adyen/config.py @@ -1,6 +1,10 @@ from django.conf import settings from django.utils.module_loading import import_string +# Defines the accepted algorithms. Subclasses should verify that values for ADYEN_HMAC_ALGORITHM +# are contained in this list. +HMAC_ALGORITHMS = ('SHA1', 'SHA256') + def get_config(): """ @@ -33,3 +37,6 @@ def get_skin_secret(self, request): def get_ip_address_header(self): raise NotImplementedError + + def get_hmac_algorithm(self, request): + raise NotImplementedError diff --git a/adyen/facade.py b/adyen/facade.py index dd36b77..d781e72 100644 --- a/adyen/facade.py +++ b/adyen/facade.py @@ -5,7 +5,7 @@ from django.http import HttpResponse -from .gateway import Constants, Gateway, PaymentNotification, PaymentRedirection +from .gateway import Constants, Gateway from .models import AdyenTransaction from .config import get_config @@ -13,10 +13,11 @@ def get_gateway(request, config): - return Gateway({ + return Gateway(request, { Constants.IDENTIFIER: config.get_identifier(request), Constants.SECRET_KEY: config.get_skin_secret(request), Constants.ACTION_URL: config.get_action_url(request), + Constants.HMAC_ALGORITHM: config.get_hmac_algorithm(request), }) @@ -144,20 +145,8 @@ def handle_payment_feedback(self, request): Validate, process, optionally record audit trail and provide feedback about the current payment response. """ - # We must first find out whether this is a redirection or a notification. - - if request.method == 'GET': - params = request.GET - response_class = PaymentRedirection - elif request.method == 'POST': - params = request.POST - response_class = PaymentNotification - else: - raise RuntimeError("Only GET and POST requests are supported.") - - # Then we can instantiate the appropriate class from the gateway. gateway = get_gateway(request, self.config) - response = response_class(gateway, params) + response = gateway.get_response() # Note that this may raise an exception if the response is invalid. # For example: MissingFieldException, UnexpectedFieldException, ... diff --git a/adyen/gateway.py b/adyen/gateway.py index 61238ed..8e85354 100644 --- a/adyen/gateway.py +++ b/adyen/gateway.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import base64 +import binascii import hashlib import hmac import logging @@ -30,6 +31,7 @@ class Constants: EVENT_DATE = 'eventDate' FALSE = 'false' + HMAC_ALGORITHM = 'hmacAlgorithm' IDENTIFIER = 'identifier' LIVE = 'live' @@ -101,75 +103,82 @@ class Gateway: Constants.IDENTIFIER, Constants.SECRET_KEY, Constants.ACTION_URL, + Constants.HMAC_ALGORITHM, ) - def __init__(self, settings=None): + def __init__(self, request, settings=None): """ Initialize an Adyen gateway. """ if settings is None: settings = {} - self.identifier = settings.get(Constants.IDENTIFIER) - self.secret_key = settings.get(Constants.SECRET_KEY) - self.action_url = settings.get(Constants.ACTION_URL) - - if self.identifier is None or self.secret_key is None or self.action_url is None: + if not all([x in settings.keys() for x in self.MANDATORY_SETTINGS]): raise MissingParameterException( "You need to specify the following parameters to initialize " - "the Adyen gateway: identifier, secret_key, action_url. " - "Please check your configuration." + "the Adyen gateway: %s. Please check your configuration." % + ", ".join(self.MANDATORY_SETTINGS) ) - def _compute_hash(self, keys, params): + self.identifier = settings.get(Constants.IDENTIFIER) + self.secret_key = settings.get(Constants.SECRET_KEY) + self.action_url = settings.get(Constants.ACTION_URL) + self.hmac_algorithm = settings.get(Constants.HMAC_ALGORITHM) + + if request: + # We must first find out whether this is a redirection or a notification. + self.request = request + if request.method == 'GET': + self.response_class = globals()['PaymentRedirection%s' % self.hmac_algorithm] + elif request.method == 'POST': + self.response_class = globals()['PaymentNotification%s' % self.hmac_algorithm] + else: + raise RuntimeError("Only GET and POST requests are supported.") + else: + self.request = None + + def _compute_hash(self, signature): """ Compute a validation hash for Adyen transactions. General method: - The signature is computed using the HMAC algorithm with the SHA-1 - hashing function. The data passed, in the form fields, is concatenated - into a string, referred to as the “signing string”. The HMAC signature - is then computed over using a key that is specified in the Adyen Skin - settings. The signature is passed along with the form data and once - Adyen receives it, they use the key to verify that the data has not - been tampered with in transit. The signing string should be packed - into a binary format containing hex characters, and then base64-encoded + The signature is computed using the HMAC algorithm with a hashing function. + Adyen currenly supports SHA1 (deprecated) and SHA256 hashing. The default + hashing function is SHA1 for backward compatibility, but can be set using + the configuration system, e.g. by setting ADYEN_HMAC_ALGORITHM to 'SHA256' + in the Django settings when using settings-based configuration. + + The data passed, in the form fields, is concatenated into a string, referred + to as the “signing string”. The HMAC signature is then computed over using a + key that is specified in the Adyen Skin settings. The signature is passed along + with the form data and once Adyen receives it, they use the key to verify that + the data has not been tampered with in transit. The signing string should be + packed into a binary format containing hex characters, and then base64-encoded for transmission. - Payment Setup: - - When setting up a payment the signing string is as follows: - - paymentAmount + currencyCode + shipBeforeDate + merchantReference - + skinCode + merchantAccount + sessionValidity + shopperEmail - + shopperReference + recurringContract + allowedMethods - + blockedMethods + shopperStatement + merchantReturnData - + billingAddressType + deliveryAddressType + shopperType + offset - - The order of the fields must be exactly as described above. - If you are not using one of the fields, such as allowedMethods, - the value for this field in the signing string is an empty string. + The actual fields used for generating the both the signing string and the + hash are configured in the appropriate response classes. e.g. + `PaymentFormRequest`, `PaymentRedirectionSHA1` or `PaymentRedirectionSHA256`. + """ + hmac_ = getattr(self, '_get_%s_hmac' % self.hmac_algorithm.lower())(signature) + hash_ = base64.b64encode(hmac_.digest()) + return hash_.strip().decode('utf-8') - Payment Result: + def _get_sha1_hmac(self, signature): + return hmac.new(self.secret_key.encode(), signature.encode(), hashlib.sha1) - The payment result uses the following signature string: - - authResult + pspReference + merchantReference + skinCode - + merchantReturnData - """ - signature = ''.join(str(params.get(key, '')) for key in keys) - hm = hmac.new(self.secret_key.encode(), signature.encode(), hashlib.sha1) - hash_ = base64.encodebytes(hm.digest()).strip().decode('utf-8') - return hash_ + def _get_sha256_hmac(self, signature): + hmac_key = binascii.a2b_hex(self.secret_key) + return hmac.new(hmac_key, signature.encode(), hashlib.sha256) def _build_form_fields(self, adyen_request): - """ - Return the hidden fields of an HTML form allowing to perform this request. - """ return adyen_request.build_form_fields() def build_payment_form_fields(self, params): + """ + Return the hidden fields of an HTML form allowing to perform this request. + """ return self._build_form_fields(PaymentFormRequest(self, params)) def _process_response(self, adyen_response, params): @@ -178,6 +187,21 @@ def _process_response(self, adyen_response, params): """ return adyen_response.process() + def get_response(self): + """ + Retrieves an instance of a response class that can be used to process + an Adyen response. + + :return: an instance of a response class. + :rtype: BaseInteraction + """ + if not self.request: + raise RuntimeError("get_response can only be called in response to a request") + + # The constructor has already checked that the method is either POST or GET + params = self.request.POST if self.request.method == 'POST' else self.request.GET + return self.response_class(self, params) + class BaseInteraction: REQUIRED_FIELDS = () @@ -185,8 +209,22 @@ class BaseInteraction: HASH_KEYS = () HASH_FIELD = None + def _compute_signature(self, keys, params): + """ + Generate a signature string to be used in hash calculations. + + :param keys: Keys to be used in the signature. Also keys to the params dictionary. + :type keys: list + :param params: Values for the signature. + :type params: dict + :return: A string representing the hashing signature. + :rtype: str + """ + return ''.join(str(params.get(key, '')) for key in keys) + def hash(self): - return self.client._compute_hash(self.HASH_KEYS, self.params) + signature = self._compute_signature(self.HASH_KEYS, self.params) + return self.client._compute_hash(signature) def validate(self): self.check_fields() @@ -217,6 +255,24 @@ def check_fields(self): # ---[ FORM-BASED REQUESTS ]--- class PaymentFormRequest(BaseInteraction): + """ + Payment Setup: + + When setting up a payment the signing string is as follows: + + paymentAmount + currencyCode + shipBeforeDate + merchantReference + + skinCode + merchantAccount + sessionValidity + shopperEmail + + shopperReference + recurringContract + allowedMethods + + blockedMethods + shopperStatement + merchantReturnData + + billingAddressType + deliveryAddressType + shopperType + offset + + The order of the fields must be exactly as described above. + If you are not using one of the fields, such as allowedMethods, + the value for this field in the signing string is an empty string. + + For more information, please see: + https://docs.adyen.com/manuals/hpp-manual/hpp-hmac-calculation/hmac-payment-setup-sha-1-deprecated + """ REQUIRED_FIELDS = ( Constants.MERCHANT_ACCOUNT, Constants.MERCHANT_REFERENCE, @@ -293,7 +349,7 @@ def process(self): return NotImplemented -class PaymentNotification(BaseResponse): +class BasePaymentNotification(BaseResponse): """ Class used to process payment notifications (HTTPS POST from Adyen to our servers). @@ -303,6 +359,9 @@ class PaymentNotification(BaseResponse): - additional data: Can be included. Format is 'additionalData.VALUE' and we don't need the data at the moment, so it's ignored. - unexpected: We loudly complain. + + + Subclasses for specific HMAC algorithms will need to define HASH_KEYS as appropriate. """ REQUIRED_FIELDS = ( Constants.CURRENCY, @@ -349,10 +408,29 @@ def process(self): return accepted, status, self.params -class PaymentRedirection(BaseResponse): +class PaymentNotificationSHA1(BasePaymentNotification): + pass + + +class PaymentNotificationSHA256(BasePaymentNotification): + HASH_KEYS = ( + Constants.PSP_REFERENCE, + Constants.ORIGINAL_REFERENCE, + Constants.MERCHANT_ACCOUNT_CODE, + Constants.MERCHANT_REFERENCE, + Constants.VALUE, + Constants.CURRENCY, + Constants.EVENT_CODE, + Constants.SUCCESS + ) + + +class BasePaymentRedirection(BaseResponse): """ Class used to process payment notifications from the user; when they paid on Adyen and get redirected back to our site. HTTP GET from user's browser. + + Subclasses for specific HMAC algorithms will need to define HASH_KEYS as appropriate. """ REQUIRED_FIELDS = ( Constants.AUTH_RESULT, @@ -368,15 +446,6 @@ class PaymentRedirection(BaseResponse): ) HASH_FIELD = Constants.MERCHANT_SIG - # Note that the order of the keys matter to compute the hash! - HASH_KEYS = ( - Constants.AUTH_RESULT, - Constants.PSP_REFERENCE, - Constants.MERCHANT_REFERENCE, - Constants.SKIN_CODE, - Constants.MERCHANT_RETURN_DATA, - ) - def validate(self): super().validate() @@ -391,3 +460,46 @@ def process(self): payment_result = self.params[Constants.AUTH_RESULT] accepted = payment_result == Constants.PAYMENT_RESULT_AUTHORISED return accepted, payment_result, self.params + + +class PaymentRedirectionSHA1(BasePaymentRedirection): + """ + Payment Result: + + The payment result uses the following signature string: + + authResult + pspReference + merchantReference + skinCode + + merchantReturnData + """ + # Note that the order of the keys matter to compute the hash! + HASH_KEYS = ( + Constants.AUTH_RESULT, + Constants.PSP_REFERENCE, + Constants.MERCHANT_REFERENCE, + Constants.SKIN_CODE, + Constants.MERCHANT_RETURN_DATA, + ) + + +class PaymentRedirectionSHA256(BasePaymentRedirection): + """ + Hashing keys taken from: + https://docs.adyen.com/display/TD/Payment+Response+merchantSig+-+SHA+256 + """ + # Note that the order of the keys matter to compute the hash! + HASH_KEYS = ( + Constants.AUTH_RESULT, + Constants.MERCHANT_REFERENCE, + Constants.MERCHANT_RETURN_DATA, + Constants.PAYMENT_METHOD, + Constants.PSP_REFERENCE, + Constants.SHOPPER_LOCALE, + Constants.SKIN_CODE, + ) + + def _compute_signature(self, keys, params): + """ + Unlike the SHA1 signature, the SHA256 must also contain a prepended list of keys. + """ + signature_params = list(keys) + [str(params.get(key, '')) for key in keys] + return Constants.SEPARATOR.join(signature_params) diff --git a/adyen/settings_config.py b/adyen/settings_config.py index 21fa7fd..8a313a0 100644 --- a/adyen/settings_config.py +++ b/adyen/settings_config.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from .config import AbstractAdyenConfig +from .config import AbstractAdyenConfig, HMAC_ALGORITHMS class FromSettingsConfig(AbstractAdyenConfig): @@ -23,6 +23,11 @@ def __init__(self): raise ImproperlyConfigured( "You are using the FromSettingsConfig config class, but haven't set the " "the following required settings: %s" % missing_settings) + if hasattr(settings, 'ADYEN_HMAC_ALGORITHM'): + if settings.ADYEN_HMAC_ALGORITHM not in HMAC_ALGORITHMS: + raise ImproperlyConfigured( + "ADYEN_HMAC_ALGORITHM is not valid. Supported values are: %s." % + ", ".join(HMAC_ALGORITHMS)) def get_identifier(self, request): return settings.ADYEN_IDENTIFIER @@ -41,3 +46,9 @@ def get_ip_address_header(self): return settings.ADYEN_IP_ADDRESS_HTTP_HEADER except AttributeError: return 'REMOTE_ADDR' + + def get_hmac_algorithm(self, request): + try: + return settings.ADYEN_HMAC_ALGORITHM + except AttributeError: + return 'SHA1' diff --git a/tests/test_config.py b/tests/test_config.py index 96d47a8..94b0113 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -32,11 +32,28 @@ def test_value_passing_works(self): # Override settings is needed to let us delete settings on a per-test basis. @override_settings() def test_complains_when_not_fully_configured(self): - # If the setting is missing, a proper exception is raised + """ + If the setting is missing, a proper exception is raised. + """ del settings.ADYEN_ACTION_URL with self.assertRaises(ImproperlyConfigured): get_config() + @override_settings(ADYEN_HMAC_ALGORITHM='bogus_algo') + def test_invalid_hmac_algorithm(self): + """ + Test that an invalid HMAC algorithm value raises an exception. + """ + with self.assertRaises(ImproperlyConfigured): + get_config() + + @override_settings(ADYEN_HMAC_ALGORITHM='SHA256') + def test_sha256_hmac_algorithm(self): + """ + Test that SHA256 can be configured as an HMAC algorithm. + """ + assert get_config().get_hmac_algorithm(None) == 'SHA256' + class DummyConfigClass(AbstractAdyenConfig): diff --git a/tests/test_redirects.py b/tests/test_redirects.py index cfc63c3..20c6c30 100644 --- a/tests/test_redirects.py +++ b/tests/test_redirects.py @@ -1,5 +1,6 @@ from copy import copy from django.test import TestCase +from django.test.utils import override_settings from adyen.facade import Facade from adyen.gateway import MissingFieldException, InvalidTransactionException @@ -75,6 +76,16 @@ def test_handle_authorised_payment(self): assert AdyenTransaction.objects.filter(status='AUTHORISED').count() == 1 assert AdyenTransaction.objects.filter(status='REFUSED').count() == 0 + @override_settings(ADYEN_HMAC_ALGORITHM='SHA256', + ADYEN_SECRET_KEY='BE0FA52C9A1D2C7216C6C0EB682588DF83F04407325C152BEC0D93F47' + '2AD3BFB') + def test_handle_authorised_payment_SHA256(self): + params = AUTHORISED_PAYMENT_PARAMS_GET.copy() + params['merchantSig'] = 'Y5xWRpCrYZdS8HouaxMAh4Uv7PSfQOXO+fcCgnCsm0c=' + request = MockRequest(params) + success, status, details = Scaffold().handle_payment_feedback(request) + assert success + def test_handle_authorized_payment_if_no_ip_address_was_found(self): """ A slight variation on the previous test. @@ -132,7 +143,17 @@ def test_handle_refused_payment(self): assert AdyenTransaction.objects.filter(status='AUTHORISED').count() == 0 assert AdyenTransaction.objects.filter(status='REFUSED').count() == 1 - def test_signing_is_enforced(self): + @override_settings(ADYEN_HMAC_ALGORITHM='SHA1') + def test_signing_is_enforced_SHA1(self): + self._do_test_signing_is_enforced() + + @override_settings(ADYEN_HMAC_ALGORITHM='SHA256', + ADYEN_SECRET_KEY='BE0FA52C9A1D2C7216C6C0EB682588DF83F04407325C152BEC0D93F47' + '2AD3BFB') + def test_signing_is_enforced_SHA256(self): + self._do_test_signing_is_enforced() + + def _do_test_signing_is_enforced(self): """ Test that the supplied signature (in field merchantSig) is checked and notifications are ignored when the signature doesn't match. @@ -145,7 +166,7 @@ def test_signing_is_enforced(self): fake_signature = copy(AUTHORISED_PAYMENT_PARAMS_GET) fake_signature['merchantSig'] = '14M4N3V1LH4X0RZ' signature_none = copy(AUTHORISED_PAYMENT_PARAMS_GET) - signature_none ['merchantSig'] = None + signature_none['merchantSig'] = None signature_empty = copy(AUTHORISED_PAYMENT_PARAMS_GET) signature_empty['merchantSig'] = '' diff --git a/tests/test_requests.py b/tests/test_requests.py index ef4b1c2..e315db7 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -50,7 +50,7 @@ def test_form_action(self): """ assert 'foo' == Scaffold().get_form_action(request=None) - def test_form_fields_ok(self): + def _check_form_fields(self, expected): """ Test that the payment form fields list is properly built. """ @@ -58,9 +58,21 @@ def test_form_fields_ok(self): fields_list = Scaffold().get_form_fields(request=None, order_data=ORDER_DATA) # Order doesn't matter, so normally we'd use a set. But Python doesn't do # sets of dictionaries, so we compare individually. - assert len(fields_list) == len(EXPECTED_FIELDS_LIST) + assert len(fields_list) == len(expected) for field in fields_list: - assert field in EXPECTED_FIELDS_LIST + assert field in expected + + @override_settings(ADYEN_HMAC_ALGORITHM='SHA1') + def test_form_fields_ok_SHA1(self): + self._check_form_fields(EXPECTED_FIELDS_LIST) + + @override_settings(ADYEN_HMAC_ALGORITHM='SHA256', + ADYEN_SECRET_KEY='BE0FA52C9A1D2C7216C6C0EB682588DF83F04407325C152BEC0D93F47' + '2AD3BFB') + def test_form_fields_ok_SHA256(self): + expected = list(EXPECTED_FIELDS_LIST) + expected[4]['value'] = 'A4ojHW83DZyzgN+9wbvyd3r+XFao16/3qMBAwYGTR9g=' + self._check_form_fields(expected) def test_form_fields_with_missing_mandatory_field(self): """ diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 17958eb..ca088c2 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1,7 +1,7 @@ from django.test import override_settings, TestCase from adyen.facade import Facade -from adyen.gateway import PaymentNotification, Constants, MissingFieldException, \ - UnexpectedFieldException +from adyen.gateway import (BasePaymentNotification, Constants, MissingFieldException, + UnexpectedFieldException) from tests import MockRequest @@ -68,14 +68,14 @@ class PaymentNotificationTestCase(TestCase): def create_mock_notification(self, required=True, optional=False, additional=False): keys_to_set = [] if required: - keys_to_set += PaymentNotification.REQUIRED_FIELDS + keys_to_set += BasePaymentNotification.REQUIRED_FIELDS if optional: - keys_to_set += PaymentNotification.OPTIONAL_FIELDS + keys_to_set += BasePaymentNotification.OPTIONAL_FIELDS if additional: keys_to_set += [Constants.ADDITIONAL_DATA_PREFIX + 'foo'] params = {key: 'FOO' for key in keys_to_set} - return PaymentNotification(MockClient(), params) + return BasePaymentNotification(MockClient(), params) def test_required_fields_are_required(self): notification = self.create_mock_notification(