diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 8d24ae277..821e7fda7 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -1,3 +1,4 @@ +import hashlib import json import logging from collections import OrderedDict @@ -12,7 +13,7 @@ from pretix.base.models import Quota, RequiredAction from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.mail import SendMailException from pretix.base.services.orders import mark_order_paid, mark_order_refunded -from pretix.multidomain.urlreverse import build_absolute_uri +from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse logger = logging.getLogger('pretix.plugins.stripe') @@ -92,20 +93,19 @@ class Stripe(BasePaymentProvider): def order_can_retry(self, order): return self._is_still_available() - def payment_perform(self, request, order) -> str: - self._init_api() + def _charge_source(self, source, order): try: charge = stripe.Charge.create( amount=int(order.total * 100), currency=self.event.currency.lower(), - source=request.session['payment_stripe_token'], + source=source, metadata={ 'order': str(order.id), 'event': self.event.id, 'code': order.code }, # TODO: Is this sufficient? - idempotency_key=str(self.event.id) + order.code + request.session['payment_stripe_token'] + idempotency_key=str(self.event.id) + order.code + source ) except stripe.error.CardError as e: if e.json_body: @@ -119,7 +119,7 @@ class Stripe(BasePaymentProvider): 'error': True, 'message': err['message'], }) - order.save() + order.save(update_fields=['payment_info']) raise PaymentException(_('Stripe reported an error with your card: %s') % err['message']) except stripe.error.StripeError as e: @@ -133,7 +133,7 @@ class Stripe(BasePaymentProvider): 'error': True, 'message': err['message'], }) - order.save() + order.save(update_fields=['payment_info']) raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' 'with us if this problem persists.')) else: @@ -142,7 +142,7 @@ class Stripe(BasePaymentProvider): mark_order_paid(order, 'stripe', str(charge)) except Quota.QuotaExceededException as e: RequiredAction.objects.create( - event=request.event, action_type='pretix.plugins.stripe.overpaid', data=json.dumps({ + event=self.event, action_type='pretix.plugins.stripe.overpaid', data=json.dumps({ 'order': order.code, 'charge': charge.id }) @@ -154,9 +154,44 @@ class Stripe(BasePaymentProvider): else: logger.info('Charge failed: %s' % str(charge)) order.payment_info = str(charge) - order.save() + order.save(update_fields=['payment_info']) raise PaymentException(_('Stripe reported an error: %s') % charge.failure_message) - del request.session['payment_stripe_token'] + + def payment_perform(self, request, order) -> str: + self._init_api() + + if request.session['payment_stripe_token'].startswith('src_'): + src = stripe.Source.retrieve(request.session['payment_stripe_token']) + if src.type == 'card' and src.card and src.card.three_d_secure == 'required': + request.session['payment_stripe_order_secret'] = order.secret + source = stripe.Source.create( + type='three_d_secure', + amount=int(order.total * 100), + currency=self.event.currency.lower(), + three_d_secure={ + 'card': src.id + }, + metadata={ + 'order': str(order.id), + 'event': self.event.id, + 'code': order.code + }, + redirect={ + 'return_url': eventreverse(self.event, 'plugins:stripe:return', kwargs={ + 'order': order.code, + 'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(), + }) + }, + ) + if source.status == "pending": + order.payment_info = str(source) + order.save(update_fields=['payment_info']) + return source.redirect.url + + try: + self._charge_source(request.session['payment_stripe_token'], order) + finally: + del request.session['payment_stripe_token'] def order_pending_render(self, request, order) -> str: if order.payment_info: diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py index 5f99740b1..3540c55ee 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -56,6 +56,12 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason']) elif event_type == 'charge.dispute.closed': text = _('Dispute closed. Status: {}').format(data['data']['object']['status']) + elif event_type == 'source.chargeable': + text = _('Payment authorized.') + elif event_type == 'source.canceled': + text = _('Payment authorization canceled.') + elif event_type == 'source.failed': + text = _('Payment authorization failed.') if text: return _('Stripe reported an event: {}').format(text) diff --git a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js index 64af3e4a0..ace10e5ff 100644 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js +++ b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js @@ -10,7 +10,7 @@ var pretixstripe = { waitingDialog.show(gettext("Contacting Stripe …")); $(".stripe-errors").hide(); - pretixstripe.stripe.createToken(pretixstripe.card).then(function (result) { + pretixstripe.stripe.createSource(pretixstripe.card).then(function (result) { waitingDialog.hide(); if (result.error) { $(".stripe-errors").stop().hide().removeClass("sr-only"); @@ -19,9 +19,9 @@ var pretixstripe = { } else { var $form = $("#stripe_token").closest("form"); // Insert the token into the form so it gets submitted to the server - $("#stripe_token").val(result.token.id); - $("#stripe_card_brand").val(result.token.card.brand); - $("#stripe_card_last4").val(result.token.card.last4); + $("#stripe_token").val(result.source.id); + $("#stripe_card_brand").val(result.source.card.brand); + $("#stripe_card_last4").val(result.source.card.last4); // and submit $form.get(0).submit(); } diff --git a/src/pretix/plugins/stripe/urls.py b/src/pretix/plugins/stripe/urls.py index a70126311..e3c010baf 100644 --- a/src/pretix/plugins/stripe/urls.py +++ b/src/pretix/plugins/stripe/urls.py @@ -1,10 +1,11 @@ from django.conf.urls import include, url -from .views import refund, webhook +from .views import ReturnView, refund, webhook event_patterns = [ url(r'^stripe/', include([ url(r'^webhook/$', webhook, name='webhook'), + url(r'^return/(?P[^/]+)/(?P[^/]+)/$', ReturnView.as_view(), name='return'), ])), ] diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index eb74521d8..2d57e449f 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -1,19 +1,25 @@ +import hashlib import json import logging import stripe from django.contrib import messages from django.db import transaction -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ +from django.views import View from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from pretix.base.models import Order, Quota, RequiredAction +from pretix.base.payment import PaymentException from pretix.base.services.orders import mark_order_paid, mark_order_refunded from pretix.control.permissions import event_permission_required +from pretix.multidomain.urlreverse import eventreverse from pretix.plugins.stripe.payment import Stripe from pretix.presale.utils import event_view @@ -32,12 +38,16 @@ def webhook(request, *args, **kwargs): # come from anywhere. if event_json['data']['object']['object'] == "charge": - charge_id = event_json['data']['object']['id'] + return charge_webhook(request, event_json, event_json['data']['object']['id']) elif event_json['data']['object']['object'] == "dispute": - charge_id = event_json['data']['object']['charge'] + return charge_webhook(request, event_json, event_json['data']['object']['charge']) + elif event_json['data']['object']['object'] == "source": + return source_webhook(request, event_json, event_json['data']['object']['id']) else: return HttpResponse("Not interested in this data type", status=200) + +def charge_webhook(request, event_json, charge_id): prov = Stripe(request.event) prov._init_api() try: @@ -75,7 +85,9 @@ def webhook(request, *args, **kwargs): if not RequiredAction.objects.filter(event=request.event, action_type='pretix.plugins.stripe.overpaid', data__icontains=order.code).exists(): RequiredAction.objects.create( - event=request.event, action_type='pretix.plugins.stripe.overpaid', data=json.dumps({ + event=request.event, + action_type='pretix.plugins.stripe.overpaid', + data=json.dumps({ 'order': order.code, 'charge': charge.id }) @@ -84,6 +96,40 @@ def webhook(request, *args, **kwargs): return HttpResponse(status=200) +def source_webhook(request, event_json, source_id): + prov = Stripe(request.event) + prov._init_api() + try: + src = stripe.Source.retrieve(source_id) + except stripe.error.StripeError: + logger.exception('Stripe error on webhook. Event data: %s' % str(event_json)) + return HttpResponse('Charge not found', status=500) + + metadata = src['metadata'] + if 'event' not in metadata: + return HttpResponse('Event not given in charge metadata', status=200) + + if int(metadata['event']) != request.event.pk: + return HttpResponse('Not interested in this event', status=200) + + with transaction.atomic(): + try: + order = request.event.orders.get(id=metadata['order'], payment_provider='stripe') + except Order.DoesNotExist: + return HttpResponse('Order not found', status=200) + + order.log_action('pretix.plugins.stripe.event', data=event_json) + go = (event_json['type'] == 'source.chargeable' and order.status == Order.STATUS_PENDING and + src.status == 'chargeable') + if go: + try: + prov._charge_source(source_id, order) + except PaymentException: + logger.exception('Webhook error') + + return HttpResponse(status=200) + + @event_permission_required('can_view_orders') @require_POST def refund(request, **kwargs): @@ -108,3 +154,64 @@ def refund(request, **kwargs): 'event': request.event.slug, 'code': data['order'] })) + + +class StripeOrderView: + def dispatch(self, request, *args, **kwargs): + try: + self.order = request.event.orders.get(code=kwargs['order']) + if hashlib.sha1(self.order.secret.lower().encode()).hexdigest() != kwargs['hash'].lower(): + raise Http404('') + except Order.DoesNotExist: + # Do a hash comparison as well to harden timing attacks + if 'abcdefghijklmnopq'.lower() == hashlib.sha1('abcdefghijklmnopq'.encode()).hexdigest(): + raise Http404('') + else: + raise Http404('') + return super().dispatch(request, *args, **kwargs) + + @cached_property + def pprov(self): + return self.request.event.get_payment_providers()[self.order.payment_provider] + + +@method_decorator(event_view, name='dispatch') +class ReturnView(StripeOrderView, View): + def get(self, request, *args, **kwargs): + prov = Stripe(request.event) + prov._init_api() + src = stripe.Source.retrieve(request.GET.get('source')) + if src.client_secret != request.GET.get('client_secret'): + messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' + 'in your emails to continue.')) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + + with transaction.atomic(): + self.order.refresh_from_db() + if self.order.status == Order.STATUS_PAID: + del request.session['payment_stripe_token'] + return self._redirect_to_order() + + if src.status == 'chargeable': + try: + prov._charge_source(src.id, self.order) + except PaymentException as e: + messages.error(request, str(e)) + return self._redirect_to_order() + finally: + del request.session['payment_stripe_token'] + else: + messages.error(self.request, _('We had trouble authorizing your card payment. Please try again and ' + 'get in touch with us if this problem persists.')) + return self._redirect_to_order() + + def _redirect_to_order(self): + if self.request.session.get('payment_stripe_order_secret') != self.order.secret: + messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' + 'in your emails to continue.')) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + + return redirect(eventreverse(self.request.event, 'presale:event.order', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }) + ('?paid=yes' if self.order.status == Order.STATUS_PAID else '')) diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 35ef7b715..d5d7026b0 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -32,7 +32,7 @@ bleach==2.* raven django-i18nfield>=1.0.1 # Stripe -stripe==1.22.* +stripe==1.62.* # PayPal paypalrestsdk==1.12.* pycparser==2.13 # https://github.com/eliben/pycparser/issues/147