From 78f02fd2fc83f0752aff85196a62544069345046 Mon Sep 17 00:00:00 2001 From: Avi Upadhyayula <69180850+aviupadhyayula@users.noreply.github.com> Date: Sun, 21 Apr 2024 13:04:16 -0400 Subject: [PATCH] Use capture context in cart checkout (#671) * Use capture context to verify transient token * Add migration * Minor changes to documentation * Add tests * Add comment explaining max char length --- .../migrations/0104_cart_checkout_context.py | 18 +++++ backend/clubs/models.py | 2 + backend/clubs/views.py | 39 +++++++---- backend/tests/clubs/test_ticketing.py | 66 ++++++++++++++++++- 4 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 backend/clubs/migrations/0104_cart_checkout_context.py diff --git a/backend/clubs/migrations/0104_cart_checkout_context.py b/backend/clubs/migrations/0104_cart_checkout_context.py new file mode 100644 index 000000000..3f7c3f220 --- /dev/null +++ b/backend/clubs/migrations/0104_cart_checkout_context.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-04-21 00:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0103_ticket_group_discount_ticket_group_size"), + ] + + operations = [ + migrations.AddField( + model_name="cart", + name="checkout_context", + field=models.CharField(blank=True, max_length=8297, null=True), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 361f83995..7fde4eb41 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1789,6 +1789,8 @@ class Cart(models.Model): owner = models.OneToOneField( get_user_model(), related_name="cart", on_delete=models.CASCADE ) + # Capture context from Cybersource should be 8297 chars + checkout_context = models.CharField(max_length=8297, blank=True, null=True) class TicketManager(models.Manager): diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 0b66f78a9..0df83b37f 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -332,19 +332,15 @@ def hour_to_string_helper(hour): return hour_string -def validate_transient_token(tt: str) -> Tuple[bool, str]: +def validate_transient_token(cc: str, tt: str) -> Tuple[bool, str]: """Validate the integrity of the transient token using - the public key (JWK) obtained from the public key endpoint""" + the public key (JWK) obtained from the capture context""" - cybersource_url = "https://" + settings.CYBERSOURCE_CONFIG["run_environment"] try: - header, payload, signature = tt.split(".") - decoded_header = json.loads(base64.b64decode(header + "==")) - kid = decoded_header["kid"] - resp = requests.get(f"{cybersource_url}/flex/v2/public-keys/{kid}") - if resp.status_code >= 400: - raise ApiException(reason=f"Public key retrieval failed {resp.json()}") - jwk = resp.json() + _, body, _ = cc.split(".") + decoded_body = json.loads(base64.b64decode(body + "===")) + jwk = decoded_body["flx"]["jwk"] + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) # This will throw if the key is invalid jwt.decode(tt, key=public_key, algorithms=["RS256"]) @@ -4964,6 +4960,11 @@ def initiate_checkout(self, request, *args, **kwargs): reason=f"Received {context} with HTTP status {http_status}", ) + # Tie generated capture context to user cart + if cart.checkout_context != context: + cart.checkout_context = context + cart.save() + # Place hold on tickets for 10 mins holding_expiration = timezone.now() + datetime.timedelta(minutes=10) tickets.update( @@ -5016,10 +5017,19 @@ def complete_checkout(self, request, *args, **kwargs): Cart.objects.prefetch_related("tickets"), owner=self.request.user ) - ok, message = validate_transient_token(tt) + cc = cart.checkout_context + if cc is None: + return Response( + {"success": False, "detail": "Associated capture context not found"}, + status=status.HTTP_500_BAD_REQUEST, + ) + + ok, message = validate_transient_token(cc, tt) if not ok: # Cleanup state since the purchase failed cart.tickets.update(holder=None, owner=None) + cart.checkout_context = None + cart.save() return Response( {"success": False, "detail": message}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -5049,6 +5059,8 @@ def complete_checkout(self, request, *args, **kwargs): except ApiException as e: # Cleanup state since the purchase failed cart.tickets.update(holder=None, owner=None) + cart.checkout_context = None + cart.save() return Response( { @@ -5076,6 +5088,8 @@ def complete_checkout(self, request, *args, **kwargs): except ApiException as e: # Cleanup state since the purchase failed cart.tickets.update(holder=None, owner=None) + cart.checkout_context = None + cart.save() return Response( { @@ -5113,6 +5127,9 @@ def complete_checkout(self, request, *args, **kwargs): Ticket.objects.update_holds() + cart.checkout_context = None + cart.save() + return Response( { "success": True, diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index cb2f9cc17..c91266de9 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -614,16 +614,78 @@ def test_initiate_checkout(self): ] ) ) as fake_cap_context: - fake_cap_context.return_value = "abcde", 200, None + cap_context_data = "abcde" + fake_cap_context.return_value = cap_context_data, 200, None resp = self.client.post(reverse("tickets-initiate-checkout")) self.assertIn(resp.status_code, [200, 201], resp.content) - # Tickets are held + # Capture context should be tied to cart + cart = Cart.objects.filter(owner=self.user1).first() + self.assertIsNotNone(cart.checkout_context) + self.assertEqual(cart.checkout_context, cap_context_data) + + # Tickets should be held held_tickets = Ticket.objects.filter(holder=self.user1) self.assertEqual(held_tickets.count(), 2, held_tickets) self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets) self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets) + def test_initiate_concurrent_checkouts(self): + self.client.login(username=self.user1.username, password="test") + + # Add tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate first checkout + cap_context_data = "abc" + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.filter(owner=self.user1).first() + cap_context_1 = cart.checkout_context + + # Initiate second checkout + cap_context_data = "def" # simulate capture context changing between checkouts + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.filter(owner=self.user1).first() + cap_context_2 = cart.checkout_context + + # Stored capture context should change between checkouts + self.assertNotEqual(cap_context_1, cap_context_2) + def test_initiate_checkout_fails_with_empty_cart(self): self.client.login(username=self.user1.username, password="test")