From a3841ddc7cebd483510f0826ea5539a04bb56317 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Thu, 12 Dec 2019 16:55:17 +0100
Subject: [PATCH] lingo: start unauthenticated user support in API (#36875)

---
 combo/apps/lingo/urls.py    |  2 +-
 combo/apps/lingo/views.py   | 38 ++++++++++++++++++++----------
 tests/test_lingo_payment.py | 47 +++++++++++++++++++++++++++++++++----
 3 files changed, 70 insertions(+), 17 deletions(-)

diff --git a/combo/apps/lingo/urls.py b/combo/apps/lingo/urls.py
index 902d895..a1052c7 100644
--- a/combo/apps/lingo/urls.py
+++ b/combo/apps/lingo/urls.py
@@ -74,7 +74,7 @@ urlpatterns = [
         ItemDownloadView.as_view(), name='download-item-pdf'),
     url(r'^lingo/item/(?P<regie_id>[\w,-]+)/(?P<item_crypto_id>[\w,-]+)/$',
         ItemView.as_view(), name='view-item'),
-    url(r'^lingo/item/(?P<item_id>\d+)/pay$',
+    url(r'^lingo/item/(?P<signature>\w+)/pay$',
         BasketItemPayView.as_view(), name='basket-item-pay-view'),
     url(r'^lingo/self-invoice/(?P<cell_id>\w+)/$', SelfInvoiceView.as_view(),
         name='lingo-self-invoice'),
diff --git a/combo/apps/lingo/views.py b/combo/apps/lingo/views.py
index 9b20c02..439d107 100644
--- a/combo/apps/lingo/views.py
+++ b/combo/apps/lingo/views.py
@@ -39,7 +39,7 @@ from django.utils.encoding import smart_text
 import eopayment
 
 from combo.data.models import Page
-from combo.utils import check_request_signature, aes_hex_decrypt, DecryptionError
+from combo.utils import check_request_signature, aes_hex_decrypt, aes_hex_encrypt, DecryptionError
 from combo.profile.utils import get_user_from_name_id
 
 from .models import (Regie, BasketItem, Transaction, TransactionOperation,
@@ -150,7 +150,7 @@ class AddBasketItemApiView(View):
             elif request.GET.get('email'):
                 user = User.objects.get(email=request.GET.get('email'))
             else:
-                raise Exception('no user specified')
+                user = None
         except User.DoesNotExist:
             raise Exception('unknown user')
 
@@ -192,9 +192,14 @@ class AddBasketItemApiView(View):
                     'Bad format for capture date, it should be yyyy-mm-dd.')
 
         item.save()
-        item.regie.compute_extra_fees(user=item.user)
+        if user:
+            item.regie.compute_extra_fees(user=item.user)
 
-        payment_url = reverse('basket-item-pay-view', kwargs={'item_id': item.id})
+        payment_url = reverse(
+            'basket-item-pay-view',
+            kwargs={
+                'signature': aes_hex_encrypt(settings.SECRET_KEY, str(item.id))
+            })
         return JsonResponse({'result': 'success', 'id': str(item.id),
                              'payment_url': request.build_absolute_uri(payment_url)})
 
@@ -321,7 +326,9 @@ class CancelTransactionApiView(View):
 
 class PayMixin(object):
     @atomic
-    def handle_payment(self, request, regie, items, remote_items, next_url='/', email=''):
+    def handle_payment(
+            self, request, regie, items, remote_items, next_url='/', email='', firstname='',
+            lastname=''):
         if remote_items:
             total_amount = sum([x.amount for x in remote_items])
         else:
@@ -344,8 +351,6 @@ class PayMixin(object):
             lastname = user.last_name
         else:
             transaction.user = None
-            firstname = ''
-            lastname = ''
 
         transaction.save()
         transaction.regie = regie
@@ -436,18 +441,27 @@ class PayView(PayMixin, View):
 class BasketItemPayView(PayMixin, View):
     def get(self, request, *args, **kwargs):
         next_url = request.GET.get('next_url') or '/'
-        if not (request.user and request.user.is_authenticated):
-            return HttpResponseForbidden(_('No item payment allowed for anonymous users.'))
+        email = request.GET.get('email', '')
+        firstname = request.GET.get('firstname', '')
+        lastname = request.GET.get('lastname', '')
+
+        signature = kwargs.get('signature')
+        try:
+            item_id = aes_hex_decrypt(settings.SECRET_KEY, signature)
+        except DecryptionError:
+            return HttpResponseForbidden(_('Invalid payment request.'))
 
-        item = BasketItem.objects.get(pk=kwargs['item_id'])
+        item = BasketItem.objects.get(pk=item_id)
         regie = item.regie
         if regie.extra_fees_ws_url:
             return HttpResponseForbidden(_('No item payment allowed as extra fees set.'))
 
-        if item.user != request.user:
+        if item.user and item.user != request.user:
             return HttpResponseForbidden(_('Wrong item: payment not allowed.'))
 
-        return self.handle_payment(request, regie, [item], [], next_url)
+        return self.handle_payment(
+            request, regie, [item], [], next_url, email, firstname, lastname
+        )
 
 
 class PaymentException(Exception):
diff --git a/tests/test_lingo_payment.py b/tests/test_lingo_payment.py
index 0a0e152..54252b6 100644
--- a/tests/test_lingo_payment.py
+++ b/tests/test_lingo_payment.py
@@ -22,7 +22,7 @@ from combo.data.models import Page
 from combo.apps.lingo.models import (
     Regie, BasketItem, Transaction, TransactionOperation, RemoteItem, EXPIRED, LingoBasketCell,
     PaymentBackend)
-from combo.utils import sign_url
+from combo.utils import aes_hex_decrypt, sign_url
 
 from .test_manager import login
 
@@ -297,7 +297,9 @@ def test_add_amount_to_basket(app, key, regie, user):
     assert resp.status_code == 200
     response = json.loads(resp.text)
     assert response['result'] == 'success'
-    assert response['payment_url'].endswith('/lingo/item/%s/pay' % item.id)
+    payment_url = urlparse.urlparse(response['payment_url'])
+    assert payment_url.path.startswith('/lingo/item/')
+    assert payment_url.path.endswith('/pay')
     assert BasketItem.objects.filter(amount=Decimal('22.23')).exists()
     assert BasketItem.objects.filter(amount=Decimal('22.23'))[0].regie_id == other_regie.id
 
@@ -374,6 +376,44 @@ def test_add_basket_item_with_remote_regie(app, user, remote_regie):
     assert 'Can not add a basket item to a remote regie.' in resp.text
 
 
+def test_unauthenticated_user(app, regie):
+    url = reverse('api-add-basket-item')
+    data = {'amount': 10, 'display_name': 'test item'}
+    url = sign_url(url, settings.LINGO_API_SIGN_KEY)
+    resp = app.post_json(url, params=data)
+    assert resp.status_code == 200
+    payment_url = resp.json['payment_url']
+
+    item = BasketItem.objects.first()
+    assert item.user is None
+    assert item.amount == Decimal('10.00')
+    path = urlparse.urlparse(payment_url).path
+    start = '/lingo/item/'
+    end = '/pay'
+    assert path.startswith(start)
+    assert path.endswith(end)
+    signature = path.replace(start, '').replace(end, '')
+    assert aes_hex_decrypt(settings.SECRET_KEY, signature) == str(item.id)
+
+    resp = app.get(
+        payment_url,
+        params={
+            'next_url': 'http://example.net/form/id/',
+            'email': 'foo@localhost',
+            'firstname': 'foo',
+            'lastname': 'bar'
+        }
+    )
+    assert resp.location.startswith('http://dummy-payment.demo.entrouvert.com/')
+    qs = urlparse.parse_qs(urlparse.urlparse(resp.location).query)
+    assert qs['amount'] == ['10.00']
+    assert qs['email'] == ['foo@localhost']
+
+    # bad signature
+    payment_url = '/lingo/item/xxxxx/pay'
+    resp = app.get(payment_url, status=403)
+    assert 'Invalid payment request.' in resp.text
+
 def test_cant_pay_if_different_capture_date(app, basket_page, regie, user):
     capture1 = (timezone.now() + timedelta(days=1)).date()
     capture2 = (timezone.now() + timedelta(days=2)).date()
@@ -417,7 +457,7 @@ def test_pay_single_basket_item(app, key, regie, user, john_doe):
     assert BasketItem.objects.filter(regie=regie, amount=amount, payment_date__isnull=True).exists()
     payment_url = resp.json['payment_url']
     resp = app.get(payment_url, status=403)
-    assert 'No item payment allowed for anonymous users.' in resp.text
+    assert 'Wrong item: payment not allowed.' in resp.text
 
     login(app, username='john.doe', password='john.doe')
     resp = app.get(payment_url, status=403)
@@ -440,7 +480,6 @@ def test_pay_single_basket_item(app, key, regie, user, john_doe):
     assert resp.location.startswith('http://dummy-payment.demo.entrouvert.com/')
     qs = urlparse.parse_qs(urlparse.urlparse(resp.location).query)
     assert qs['amount'] == ['12.00']
-
     # simulate successful payment response from dummy backend
     data = {'transaction_id': qs['transaction_id'][0], 'ok': True,
             'amount': qs['amount'][0], 'signed': True}
-- 
2.20.1

