Add 3DS support to Stripe plugin

This commit is contained in:
Raphael Michel
2017-07-10 21:36:59 +02:00
parent 687ce29366
commit 554800c06f
6 changed files with 169 additions and 20 deletions

View File

@@ -1,3 +1,4 @@
import hashlib
import json import json
import logging import logging
from collections import OrderedDict 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.payment import BasePaymentProvider, PaymentException
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import mark_order_paid, mark_order_refunded 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') logger = logging.getLogger('pretix.plugins.stripe')
@@ -92,20 +93,19 @@ class Stripe(BasePaymentProvider):
def order_can_retry(self, order): def order_can_retry(self, order):
return self._is_still_available() return self._is_still_available()
def payment_perform(self, request, order) -> str: def _charge_source(self, source, order):
self._init_api()
try: try:
charge = stripe.Charge.create( charge = stripe.Charge.create(
amount=int(order.total * 100), amount=int(order.total * 100),
currency=self.event.currency.lower(), currency=self.event.currency.lower(),
source=request.session['payment_stripe_token'], source=source,
metadata={ metadata={
'order': str(order.id), 'order': str(order.id),
'event': self.event.id, 'event': self.event.id,
'code': order.code 'code': order.code
}, },
# TODO: Is this sufficient? # 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: except stripe.error.CardError as e:
if e.json_body: if e.json_body:
@@ -119,7 +119,7 @@ class Stripe(BasePaymentProvider):
'error': True, 'error': True,
'message': err['message'], 'message': err['message'],
}) })
order.save() order.save(update_fields=['payment_info'])
raise PaymentException(_('Stripe reported an error with your card: %s') % err['message']) raise PaymentException(_('Stripe reported an error with your card: %s') % err['message'])
except stripe.error.StripeError as e: except stripe.error.StripeError as e:
@@ -133,7 +133,7 @@ class Stripe(BasePaymentProvider):
'error': True, 'error': True,
'message': err['message'], '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 ' raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch '
'with us if this problem persists.')) 'with us if this problem persists.'))
else: else:
@@ -142,7 +142,7 @@ class Stripe(BasePaymentProvider):
mark_order_paid(order, 'stripe', str(charge)) mark_order_paid(order, 'stripe', str(charge))
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
RequiredAction.objects.create( 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, 'order': order.code,
'charge': charge.id 'charge': charge.id
}) })
@@ -154,9 +154,44 @@ class Stripe(BasePaymentProvider):
else: else:
logger.info('Charge failed: %s' % str(charge)) logger.info('Charge failed: %s' % str(charge))
order.payment_info = 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) 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: def order_pending_render(self, request, order) -> str:
if order.payment_info: if order.payment_info:

View File

@@ -56,6 +56,12 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason']) text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed': elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status']) 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: if text:
return _('Stripe reported an event: {}').format(text) return _('Stripe reported an event: {}').format(text)

View File

@@ -10,7 +10,7 @@ var pretixstripe = {
waitingDialog.show(gettext("Contacting Stripe …")); waitingDialog.show(gettext("Contacting Stripe …"));
$(".stripe-errors").hide(); $(".stripe-errors").hide();
pretixstripe.stripe.createToken(pretixstripe.card).then(function (result) { pretixstripe.stripe.createSource(pretixstripe.card).then(function (result) {
waitingDialog.hide(); waitingDialog.hide();
if (result.error) { if (result.error) {
$(".stripe-errors").stop().hide().removeClass("sr-only"); $(".stripe-errors").stop().hide().removeClass("sr-only");
@@ -19,9 +19,9 @@ var pretixstripe = {
} else { } else {
var $form = $("#stripe_token").closest("form"); var $form = $("#stripe_token").closest("form");
// Insert the token into the form so it gets submitted to the server // Insert the token into the form so it gets submitted to the server
$("#stripe_token").val(result.token.id); $("#stripe_token").val(result.source.id);
$("#stripe_card_brand").val(result.token.card.brand); $("#stripe_card_brand").val(result.source.card.brand);
$("#stripe_card_last4").val(result.token.card.last4); $("#stripe_card_last4").val(result.source.card.last4);
// and submit // and submit
$form.get(0).submit(); $form.get(0).submit();
} }

View File

@@ -1,10 +1,11 @@
from django.conf.urls import include, url from django.conf.urls import include, url
from .views import refund, webhook from .views import ReturnView, refund, webhook
event_patterns = [ event_patterns = [
url(r'^stripe/', include([ url(r'^stripe/', include([
url(r'^webhook/$', webhook, name='webhook'), url(r'^webhook/$', webhook, name='webhook'),
url(r'^return/(?P<order>[^/]+)/(?P<hash>[^/]+)/$', ReturnView.as_view(), name='return'),
])), ])),
] ]

View File

@@ -1,19 +1,25 @@
import hashlib
import json import json
import logging import logging
import stripe import stripe
from django.contrib import messages from django.contrib import messages
from django.db import transaction 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.shortcuts import get_object_or_404, redirect
from django.urls import reverse 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.utils.translation import ugettext_lazy as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from pretix.base.models import Order, Quota, RequiredAction 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.base.services.orders import mark_order_paid, mark_order_refunded
from pretix.control.permissions import event_permission_required from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.stripe.payment import Stripe from pretix.plugins.stripe.payment import Stripe
from pretix.presale.utils import event_view from pretix.presale.utils import event_view
@@ -32,12 +38,16 @@ def webhook(request, *args, **kwargs):
# come from anywhere. # come from anywhere.
if event_json['data']['object']['object'] == "charge": 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": 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: else:
return HttpResponse("Not interested in this data type", status=200) return HttpResponse("Not interested in this data type", status=200)
def charge_webhook(request, event_json, charge_id):
prov = Stripe(request.event) prov = Stripe(request.event)
prov._init_api() prov._init_api()
try: try:
@@ -75,7 +85,9 @@ def webhook(request, *args, **kwargs):
if not RequiredAction.objects.filter(event=request.event, action_type='pretix.plugins.stripe.overpaid', if not RequiredAction.objects.filter(event=request.event, action_type='pretix.plugins.stripe.overpaid',
data__icontains=order.code).exists(): data__icontains=order.code).exists():
RequiredAction.objects.create( 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, 'order': order.code,
'charge': charge.id 'charge': charge.id
}) })
@@ -84,6 +96,40 @@ def webhook(request, *args, **kwargs):
return HttpResponse(status=200) 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') @event_permission_required('can_view_orders')
@require_POST @require_POST
def refund(request, **kwargs): def refund(request, **kwargs):
@@ -108,3 +154,64 @@ def refund(request, **kwargs):
'event': request.event.slug, 'event': request.event.slug,
'code': data['order'] '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 ''))

View File

@@ -32,7 +32,7 @@ bleach==2.*
raven raven
django-i18nfield>=1.0.1 django-i18nfield>=1.0.1
# Stripe # Stripe
stripe==1.22.* stripe==1.62.*
# PayPal # PayPal
paypalrestsdk==1.12.* paypalrestsdk==1.12.*
pycparser==2.13 # https://github.com/eliben/pycparser/issues/147 pycparser==2.13 # https://github.com/eliben/pycparser/issues/147