Permalink
Browse files

Merge pull request #49 from kumar303/trans-sig

Sign/verify transaction UUIDs (bug 795143)
  • Loading branch information...
2 parents 42663db + 1c28a53 commit 218868c1e9ce8f5bb761a7274b2370b1b3832421 @kumar303 kumar303 committed Jan 9, 2013
View
@@ -82,6 +82,7 @@ example::
'sellerpaypal:token': 'foo.key',
'sellerpaypal:secret': 'foo.key',
'sellerproduct:secret': 'foo.key',
+ 'bango:signature': 'foo.key',
}
Then run::
View
@@ -1,11 +1,19 @@
-import uuid
+from datetime import datetime, timedelta
+
+import commonware.log
from django import forms
+from django.conf import settings
from lib.bango.constants import COUNTRIES, CURRENCIES, RATINGS, RATINGS_SCHEME
+from lib.bango.utils import verify_sig
from lib.sellers.models import SellerProductBango
+from lib.transactions.constants import STATUS_COMPLETED
+from lib.transactions.models import Transaction
from solitude.fields import ListField, URLField
+log = commonware.log.getLogger('s.bango')
+
class ProductForm(forms.ModelForm):
seller_bango = URLField(to='lib.bango.resources.package.PackageResource')
@@ -103,7 +111,7 @@ class CreateBillingConfigurationForm(SellerProductForm):
@property
def bango_data(self):
data = super(CreateBillingConfigurationForm, self).bango_data
- data['externalTransactionId'] = uuid.uuid4()
+ data['externalTransactionId'] = data.pop('transaction_uuid')
del data['prices']
return data
@@ -157,3 +165,55 @@ def bango_data(self):
result['packageId'] = result['seller_bango'].package_id
del result['seller_bango']
return result
+
+
+class PaymentNoticeForm(forms.Form):
+ # This is our own signature of the moz_transaction that we sent to
+ # the Billing Config API
+ moz_signature = forms.CharField()
+ # When passed into the form, this must be a valid transaction_uuid.
+ moz_transaction = forms.CharField()
+ # This is the Bango billing config ID we created with the API.
+ billing_config_id = forms.CharField()
+ # These parameters arrive in the query string.
+ bango_response_code = forms.CharField()
+ bango_response_message = forms.CharField()
+ bango_trans_id = forms.CharField()
+
+ def clean(self):
+ cleaned_data = super(PaymentNoticeForm, self).clean()
+ trans = cleaned_data.get('moz_transaction')
+ sig = cleaned_data.get('moz_signature')
+ if trans and sig:
+ # Both fields were non-empty so check the signature.
+ if not verify_sig(sig, trans.uuid):
+ log.info('Bango payment notice signature failed for '
+ 'billing_config_id %r'
+ % cleaned_data.get('billing_config_id'))
+ raise forms.ValidationError(
+ 'Signature %r of transaction UUID %r did not match'
+ % (sig, trans.uuid))
+ return cleaned_data
+
+ def clean_moz_transaction(self):
+ uuid = self.cleaned_data['moz_transaction']
+ try:
+ trans = Transaction.objects.get(uuid=uuid)
+ except Transaction.DoesNotExist:
+ log.info('Bango payment notice moz_transaction does not exist for '
+ 'billing_config_id %r'
+ % self.cleaned_data.get('billing_config_id'))
+ raise forms.ValidationError('Transaction by UUID %r does not exist'
+ % uuid)
+ if trans.status == STATUS_COMPLETED:
+ raise forms.ValidationError('Transaction UUID %r has already been '
+ 'completed' % uuid)
+ if trans.created < (datetime.now() -
+ timedelta(seconds=settings.TRANSACTION_EXPIRY)):
+ log.info('Bango payment notice moz_transaction expired for '
+ 'billing_config_id %r'
+ % self.cleaned_data.get('billing_config_id'))
+ raise forms.ValidationError('Transaction UUID %r cannot be completed '
+ 'because it expired.' % uuid)
+ # TODO(Kumar): check for a failed state per bug 828513.
+ return trans
@@ -1,12 +1,35 @@
-from cached import Resource
+import commonware.log
+from cached import Resource
from lib.bango.client import get_client
from lib.bango.constants import PAYMENT_TYPES
-from lib.bango.forms import CreateBillingConfigurationForm
+from lib.bango.forms import (CreateBillingConfigurationForm,
+ PaymentNoticeForm)
from lib.bango.signals import create
+from lib.bango.utils import sign
+from lib.transactions.constants import STATUS_COMPLETED
+
+log = commonware.log.getLogger('s.bango')
class CreateBillingConfigurationResource(Resource):
+ """
+ Call the Bango API to begin a payment transaction.
+
+ The resulting billingConfigId can be used on the query
+ string in a URL to initiate a user payment flow.
+
+ We are able to configure a few parameters that come
+ back to us on the Bango success URL query string.
+ Here are some highlights:
+
+ **config[REQUEST_SIGNATURE]**
+ This arrives as **MozSignature** in the redirect query string.
+
+ **externalTransactionId**
+ This is set to solitude's own transaction_uuid. It arrives
+ in the redirect query string as **MerchantTransactionId**.
+ """
class Meta(Resource.Meta):
resource_name = 'billing'
@@ -19,10 +42,7 @@ def obj_create(self, bundle, request, **kwargs):
client = get_client()
billing = client.client('billing')
-
data = form.bango_data
- # Exclude transaction from Bango but send it to the signal later.
- transaction_uuid = data.pop('transaction_uuid')
types = billing.factory.create('ArrayOfString')
for f in PAYMENT_TYPES:
@@ -45,6 +65,7 @@ def obj_create(self, bundle, request, **kwargs):
'BILLING_CONFIGURATION_TIME_OUT': 120,
'REDIRECT_URL_ONSUCCESS': data.pop('redirect_url_onsuccess'),
'REDIRECT_URL_ONERROR': data.pop('redirect_url_onerror'),
+ 'REQUEST_SIGNATURE': sign(data['externalTransactionId']),
}
for k, v in configs.items():
opt = billing.factory.create('BillingConfigurationOption')
@@ -58,9 +79,52 @@ def obj_create(self, bundle, request, **kwargs):
'responseMessage': resp.responseMessage,
'billingConfigurationId': resp.billingConfigurationId}
- # Uncomment this when bug 820198 lands.
- # Until then, transactions are managed in webpay not solitude.
create_data = data.copy()
- create_data['transaction_uuid'] = transaction_uuid
+ create_data['transaction_uuid'] = data.pop('externalTransactionId')
create.send(sender=self, bundle=bundle, data=create_data, form=form)
return bundle
+
+
+class PaymentNoticeResource(Resource):
+ """
+ Process a Bango payment notice.
+
+ Here is an example of a successful Bango redirect URL query string:
+
+ ?ResponseCode=OK&ResponseMessage=Success&BangoUserId=412448521
+ &MerchantTransactionId=86c8a8fa-d45a-43ff-8291-012ca1e26a51
+ &BangoTransactionId=668694391
+ &TransactionMethods=USA_TMOBILE%2cT-Mobile+USA%2cTESTPAY%2cTest+Pay
+ &BillingConfigurationId=2830&MozSignature
+ =0dfa157725e7f20f5928951154de919c347b1dbcf41b8f406b7a44d193a81bbb&P=
+ """
+
+ class Meta(Resource.Meta):
+ resource_name = 'payment_notice'
+ list_allowed_methods = ['post']
+
+ def obj_create(self, bundle, request, **kwargs):
+ form = PaymentNoticeForm(bundle.data)
+ bill_conf_id = form.data.get('billing_config_id')
+ log.info('Received Bango payment notice for billing_config_id %r: '
+ 'bango_response_code: %r; bango_response_message: %r; '
+ 'bango_trans_id: %r'
+ % (bill_conf_id,
+ form.data.get('bango_response_code'),
+ form.data.get('bango_response_message'),
+ form.data.get('bango_trans_id')))
+ if not form.is_valid():
+ log.info('Bango payment notice invalid for billing_config_id %r'
+ % bill_conf_id)
+ raise self.form_errors(form)
+
+ trans = form.cleaned_data['moz_transaction']
+ if form.cleaned_data['bango_response_code'] == 'OK':
+ log.info('Marking Bango transaction %r completed'
+ % trans.uuid)
+ trans.status = STATUS_COMPLETED
+ else:
+ raise NotImplementedError('Failures will be in bug 828513')
+ trans.save()
+
+ return bundle
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
+from datetime import datetime, timedelta
import json
from django.conf import settings
@@ -16,6 +17,7 @@
from ..client import ClientMock
from ..errors import BangoError
from ..resources.cached import SimpleResource
+from ..utils import sign
import samples
@@ -365,3 +367,70 @@ def test_bank(self):
data['seller_bango'] = self.seller_bango_uri
res = self.client.post(self.list_url, data=data)
eq_(res.status_code, 201)
+
+
+class TestPaymentNotice(APITest):
+ api_name = 'bango'
+
+ def setUp(self):
+ self.trans_uuid = 'some-transaction-uid'
+ self.seller = Seller.objects.create(uuid='seller-uuid')
+ self.product = SellerProduct.objects.create(seller=self.seller,
+ external_id='xyz')
+ self.trans = Transaction.objects.create(
+ amount=1, provider=constants.SOURCE_BANGO,
+ seller_product=self.product,
+ uuid=self.trans_uuid,
+ uid_pay='external-trans-uid'
+ )
+ self.url = self.get_list_url('payment_notice')
+
+ def data(self, overrides=None):
+ data = {'moz_transaction': self.trans_uuid,
+ 'moz_signature': sign(self.trans_uuid),
+ 'billing_config_id': '1234',
+ 'bango_trans_id': '56789',
+ 'bango_response_code': 'OK',
+ 'bango_response_message': 'Success'}
+ if overrides:
+ data.update(overrides)
+ return data
+
+ def post(self, data, expected_status=201):
+ res = self.client.post(self.url, data=data)
+ eq_(res.status_code, expected_status, res.content)
+ return json.loads(res.content)
+
+ def test_success(self):
+ self.post(self.data())
+ tr = self.trans.reget()
+ eq_(tr.status, constants.STATUS_COMPLETED)
+
+ def test_incorrect_sig(self):
+ data = self.data({'moz_signature': sign(self.trans_uuid) + 'garbage'})
+ self.post(data, expected_status=400)
+
+ def test_missing_sig(self):
+ data = self.data()
+ del data['moz_signature']
+ self.post(data, expected_status=400)
+
+ def test_missing_transaction(self):
+ data = self.data()
+ del data['moz_transaction']
+ self.post(data, expected_status=400)
+
+ def test_unknown_transaction(self):
+ self.post(self.data({'moz_transaction': 'does-not-exist'}),
+ expected_status=400)
+
+ def test_already_completed(self):
+ self.trans.status = constants.STATUS_COMPLETED
+ self.trans.save()
+ self.post(self.data(), expected_status=400)
+
+ def test_expired_transaction(self):
+ self.trans.created = datetime.now() - timedelta(seconds=62)
+ self.trans.save()
+ with self.settings(TRANSACTION_EXPIRY=60):
+ self.post(self.data(), expected_status=400)
@@ -0,0 +1,34 @@
+import os
+import tempfile
+import unittest
+
+from django.conf import settings
+
+from nose.tools import raises
+import test_utils
+
+from lib.bango.utils import sign, verify_sig
+
+
+class TestSigning(test_utils.TestCase):
+
+ def test_sign(self):
+ sig = sign('123')
+ assert verify_sig(sig, '123')
+
+ def test_sign_unicode(self):
+ sig = sign('123')
+ assert verify_sig(sig, u'123')
+
+ @raises(TypeError)
+ def test_cannot_sign_non_ascii(self):
+ sign(u'Ivan Krsti\u0107')
+
+ def test_wrong_key(self):
+ tmp = tempfile.NamedTemporaryFile(mode='w', delete=False)
+ tmp.write('some secret')
+ tmp.close()
+ self.addCleanup(lambda: os.unlink(tmp.name))
+ with self.settings(AES_KEYS={'bango:signature': tmp.name}):
+ sig = sign('123')
+ assert not verify_sig(sig, '123')
View
@@ -1,14 +1,16 @@
from tastypie.api import Api
from .resources import simple
-from .resources.billing import CreateBillingConfigurationResource
+from .resources.billing import (CreateBillingConfigurationResource,
+ PaymentNoticeResource)
from .resources.package import BangoProductResource, PackageResource
bango = Api(api_name='bango')
for lib in (CreateBillingConfigurationResource,
BangoProductResource,
PackageResource,
+ PaymentNoticeResource,
simple.MakePremiumResource,
simple.UpdateRatingResource,
simple.CreateBankDetailsResource):
View
@@ -0,0 +1,25 @@
+import hashlib
+import hmac
+
+from aesfield.default import lookup
+
+
+def sign(msg):
+ """
+ Sign a message with a Bango key.
+ """
+ if isinstance(msg, unicode):
+ try:
+ msg = msg.encode('ascii')
+ except UnicodeEncodeError, exc:
+ raise TypeError('Cannot sign a non-ascii message. Error: %s'
+ % exc)
+ key = lookup(key='bango:signature')
+ return hmac.new(key, msg, hashlib.sha256).hexdigest()
+
+
+def verify_sig(sig, msg):
+ """
+ Verify the signature of a message using a Bango key.
+ """
+ return str(sig) == sign(msg)
View
@@ -10,6 +10,7 @@
'sellerpaypal:token': filename,
'sellerpaypal:secret': filename,
'sellerproduct:secret': filename,
+ 'bango:signature': filename,
}
SOLITUDE_PROXY = False
@@ -92,7 +92,7 @@
},
'loggers': {
's': {
- 'handlers': ['unicodesyslog'],
+ 'handlers': ['unicodesyslog', 'console'],
'level': 'INFO',
},
'suds': {
@@ -151,6 +151,10 @@
BANGO_MOCK = False
BANGO_PROXY = ''
+# Time in seconds that a transaction expires. If you try to complete a
+# transaction after this time, it will fail.
+TRANSACTION_EXPIRY = 60 * 30
+
# Metlog configuration
METLOG_CONF = {
'sender': {

0 comments on commit 218868c

Please sign in to comment.