diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index aee83af95..ce3ed39b7 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -443,6 +443,7 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'customer_accounts', 'customer_accounts_native', 'customer_accounts_link_by_email', + 'customer_accounts_require_login_for_order_access', 'invoice_regenerate_allowed', 'contact_mail', 'imprint_url', diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index e6d123d06..540ab5c3a 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -180,6 +180,19 @@ DEFAULTS = { widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}), ) }, + 'customer_accounts_require_login_for_order_access': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Require login to access order confirmation pages"), + help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. " + "If a customer account is created while placing an order, the restriction only becomes active after the customer " + "account is activated."), + widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}), + ) + }, 'customer_accounts_link_by_email': { 'default': 'False', 'type': bool, diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index a8632b8ca..1f10385af 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -474,6 +474,7 @@ class OrganizerSettingsForm(SettingsForm): 'customer_accounts', 'customer_accounts_native', 'customer_accounts_link_by_email', + 'customer_accounts_require_login_for_order_access', 'invoice_regenerate_allowed', 'contact_mail', 'imprint_url', diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 8697304a7..2cfb241a7 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -132,6 +132,7 @@ {% trans "Customer accounts" %} {% bootstrap_field sform.customer_accounts layout="control" %} {% bootstrap_field sform.customer_accounts_native layout="control" %} + {% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %} {% bootstrap_field sform.customer_accounts_link_by_email layout="control" %} {% bootstrap_field sform.name_scheme layout="control" %} {% bootstrap_field sform.name_scheme_titles layout="control" %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index dc8182dd6..685b78506 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -42,6 +42,7 @@ import os import re from collections import OrderedDict, defaultdict from decimal import Decimal +from urllib.parse import quote from django import forms from django.conf import settings @@ -103,13 +104,55 @@ logger = logging.getLogger(__name__) class OrderDetailMixin(NoSearchIndexViewMixin): + def _allow_anonymous_access(self): + return not (self.request.organizer.settings.customer_accounts and + self.request.organizer.settings.customer_accounts_require_login_for_order_access) + + def verify_order_access(self): + o = self.order + + if o is None: + raise Http404(_('Unknown order code or not authorized to access this order.')) + + if o is False: + login_url = eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) + + if hasattr(self.request, "event_domain") and self.request.event_domain: + next_url = quote(self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path()) + return redirect_to_url(f'{login_url}?next={next_url}&request_cross_domain_customer_auth=true') + + else: + next_url = quote(self.request.get_full_path()) + return redirect_to_url(f'{login_url}?next={next_url}') + + return None @cached_property def order(self): + """ + Returns the order object when access is permitted, returns `False` when the + order exists but requires authentication, and returns `None` when the order + does not exist or access is denied entirely. + """ try: - return self.request.event.orders.filter().select_related('event').get_with_secret_check( + order = self.request.event.orders.filter().select_related('event').get_with_secret_check( code=self.kwargs['order'], received_secret=self.kwargs['secret'], tag=None, ) + + if has_event_access_permission(self.request, 'can_view_orders'): + return order + + if order.customer is None or not order.customer.is_verified or self._allow_anonymous_access(): + return order + + if not self.request.customer: + return False + + if order.customer_id == self.request.customer.pk: + return order + + return None + except Order.DoesNotExist: return None @@ -119,6 +162,13 @@ class OrderDetailMixin(NoSearchIndexViewMixin): 'secret': self.order.secret }) + def dispatch(self, request, *args, **kwargs): + resp = self.verify_order_access() + if resp: + return resp + + return super().dispatch(request, *args, **kwargs) + class OrderPositionDetailMixin(NoSearchIndexViewMixin): @cached_property @@ -157,8 +207,6 @@ class OrderPositionDetailMixin(NoSearchIndexViewMixin): @method_decorator(xframe_options_exempt, 'dispatch') class OrderOpen(EventViewMixin, OrderDetailMixin, View): def get(self, request, *args, **kwargs): - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) if self.order.check_email_confirm_secret(kwargs.get('hash')) and not self.order.email_known_to_work: self.order.log_action('pretix.event.order.contact.confirmed') self.order.email_known_to_work = True @@ -239,8 +287,6 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin, def get(self, request, *args, **kwargs): self.kwargs = kwargs - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) if self.order.status == Order.STATUS_PENDING: payment_to_complete = self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_CREATED, process_initiated=False).first() if payment_to_complete: @@ -360,8 +406,11 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView): def dispatch(self, request, *args, **kwargs): self.request = request self.request.pci_dss_payment_page = True - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if (self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) or self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or not self.payment.payment_provider.is_enabled @@ -428,8 +477,11 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView): def dispatch(self, request, *args, **kwargs): self.request = request - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or self.order._can_be_paid() is not True: messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) @@ -495,8 +547,11 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View): def dispatch(self, request, *args, **kwargs): self.request = request - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or self.order._can_be_paid() is not True: messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) @@ -541,8 +596,11 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): def dispatch(self, request, *args, **kwargs): self.request = request self.request.pci_dss_payment_page = True - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) or self.order._can_be_paid() is not True: messages.error(request, _('The payment method for this order cannot be changed.')) return redirect(self.get_order_url()) @@ -727,8 +785,6 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View): def dispatch(self, request, *args, **kwargs): self.request = request - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): @@ -860,8 +916,11 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem def dispatch(self, request, *args, **kwargs): self.request = request self.kwargs = kwargs - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if not self.order.can_modify_answers: messages.error(request, _('You cannot modify this order')) return redirect(self.get_order_url()) @@ -947,8 +1006,11 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView): def dispatch(self, request, *args, **kwargs): self.request = request self.kwargs = kwargs - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if not self.order.user_cancel_allowed: messages.error(request, _('You cannot cancel this order.')) return redirect(self.get_order_url()) @@ -995,14 +1057,7 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): def get_error_url(self): return self.get_order_url() - def get(self, request, *args, **kwargs): - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) - return super().get(request, *args, **kwargs) - def post(self, request, *args, **kwargs): - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) if not self.order.user_cancel_allowed: messages.error(request, _('You cannot cancel this order.')) return redirect(self.get_order_url()) @@ -1293,9 +1348,6 @@ class OrderPositionDownload(OrderDownloadMixin, EventViewMixin, OrderPositionDet class InvoiceDownload(EventViewMixin, OrderDetailMixin, View): def get(self, request, *args, **kwargs): - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) - try: invoice = Invoice.objects.get( event=self.request.event, @@ -1688,8 +1740,11 @@ class OrderChange(OrderChangeMixin, EventViewMixin, OrderDetailMixin, TemplateVi def dispatch(self, request, *args, **kwargs): self.request = request self.kwargs = kwargs - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) + + resp = self.verify_order_access() + if resp: + return resp + if not self.order.user_change_allowed: messages.error(request, _('You cannot change this order.')) return redirect(self.get_order_url()) diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 577cf0efe..2b58501de 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -2611,3 +2611,11 @@ def test_approve_cancellation_request(client, env): doc = BeautifulSoup(response.content.decode(), "lxml") assert doc.select('input[name=refund-new-giftcard]')[0]['value'] == '10.00' assert not env[2].cancellation_requests.exists() + + +@pytest.mark.django_db +def test_view_as_user(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + response = client.get('/%s/%s/order/%s/%s/' % (env[0].organizer.slug, env[0].slug, env[2].code, env[2].secret)) + assert response.status_code == 200 + assert env[2].code in response.content.decode() diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index d2ad44e71..9606938b8 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -1738,3 +1738,222 @@ class OrdersTest(BaseOrdersTest): self.order.secret, a.pk, match.group(1)) ) assert response.status_code == 404 + + def test_require_login_for_order_access_disabled_unauth(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=True) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + self.orga.settings.customer_accounts_require_login_for_order_access = False + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + def test_require_login_for_order_access_enabled_unauth(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=True) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + self.orga.settings.customer_accounts_require_login_for_order_access = True + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + def test_require_login_for_order_access_disabled_auth(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=True) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + self.orga.settings.customer_accounts_require_login_for_order_access = False + + response = self.client.post('/%s/account/login' % (self.orga.slug), { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert response.status_code == 302 + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + def test_require_login_for_order_access_enabled_auth(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=True) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + self.orga.settings.customer_accounts_require_login_for_order_access = True + + response = self.client.post('/%s/account/login' % (self.orga.slug), { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert response.status_code == 302 + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + def test_require_login_for_order_access_accounts_disabled(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=True) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + self.orga.settings.customer_accounts = False + self.orga.settings.customer_accounts_require_login_for_order_access = True + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode() + + def test_require_login_for_order_access_enabled_wrong_customer(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=True) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + with scopes_disabled(): + customer = self.orga.customers.create(email='jill@example.org', is_verified=True) + customer.set_password("bar") + customer.save() + + self.orga.settings.customer_accounts_require_login_for_order_access = True + + response = self.client.post('/%s/account/login' % (self.orga.slug), { + 'email': 'jill@example.org', + 'password': 'bar', + }) + assert response.status_code == 302 + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 404 + + def test_require_login_for_order_access_enabled_unverified_account(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + customer = self.orga.customers.create(email='john@example.org', is_verified=False) + customer.set_password("foo") + customer.save() + + self.order.customer = customer + self.order.save() + + self.orga.settings.customer_accounts_require_login_for_order_access = True + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + doc = BeautifulSoup(response.content.decode(), "lxml") + assert len(doc.select(".cart-row")) > 0 + assert "pending" in doc.select(".order-details")[0].text.lower() + assert "Peter" in response.content.decode() + assert "Lukas" not in response.content.decode()