From 0fc2d6134f2fa094514a07be337681bc87c0482a Mon Sep 17 00:00:00 2001 From: Kian Cross Date: Fri, 16 Jan 2026 12:46:08 +0000 Subject: [PATCH] Add option to restrict anonymous access to order URLs (#4735) * Add option to restrict anonymous access to order URLs By default, users who place orders while logged in can still access their order URLs without authentication. This raises potential security risks, particularly if order confirmation emails are forwarded. This commit introduces an organiser-level setting to disable anonymous access for such orders. When enabled, unauthenticated attempts to access URLs starting with `/order/`, which are intended for the customer, are redirected to the login page. Upon successful authentication, the user is redirected back to the original order URL. It is important to note that this change does not impact routes intended for attendees (e.g., `/ticket/*`), which remain accessible without authentication. * Change name of setting for future clarity Co-authored-by: Raphael Michel * Update message wording Co-authored-by: Raphael Michel * Eliminate database query Co-authored-by: Raphael Michel * Rename feature flag to fix breaking tests * Refactor order access verification code into `OrderDetailsMixin` * Add test for logged-in customer accessing another customer's order * Refactor order access conditions to remove nesting * Handle case where customer is not yet verified * Add additional information to help message * Fix multidomain issue Co-authored-by: Raphael Michel * Merge order/position variants into single tests * Add docstring explaining return type of `order` property * Apply suggestion from @raphaelm * Fix indentation --------- Co-authored-by: Raphael Michel Co-authored-by: Raphael Michel --- src/pretix/api/serializers/organizer.py | 1 + src/pretix/base/settings.py | 13 ++ src/pretix/control/forms/organizer.py | 1 + .../pretixcontrol/organizers/edit.html | 1 + src/pretix/presale/views/order.py | 117 +++++++--- src/tests/control/test_orders.py | 8 + src/tests/presale/test_orders.py | 219 ++++++++++++++++++ 7 files changed, 329 insertions(+), 31 deletions(-) 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()