diff --git a/src/pretix/presale/templates/pretixpresale/fragment_login_status.html b/src/pretix/presale/templates/pretixpresale/fragment_login_status.html index 5344e2242..faa164d6d 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_login_status.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_login_status.html @@ -14,10 +14,23 @@ {% trans "Log out" %} {% else %} - - - {% trans "Log in" %} - +
+ {% if request.event_domain %} + {% csrf_token %} + {% endif %} + {% if request.resolver_match.url_name != "organizer.customer.login" %} + + {% endif %} + +
{% endif %} {% endif %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 94df03d95..cab0c9050 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -98,6 +98,7 @@ event_patterns = [ re_path(r'unlock/(?P[a-z0-9]{64})/$', pretix.presale.views.user.UnlockHashView.as_view(), name='event.payment.unlock'), re_path(r'resend/$', pretix.presale.views.user.ResendLinkView.as_view(), name='event.resend_link'), + re_path(r'^account/loginstart$', pretix.presale.views.customer.LoginStartView.as_view(), name='event.customer.loginstart'), re_path(r'^favicon.ico/?$', pretix.presale.views.organizer.OrganizerFavicon.as_view(), diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index 4b4d64e58..8b6b17ef6 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -151,7 +151,9 @@ def add_customer_to_request(request): else: parent_session_key = otpstore.get(f'customer_cross_domain_auth_{request.organizer.pk}') - if parent_session_key: # not already invalidated, expired, … + expected_nonce = request.session.pop('cross_domain_customer_auth_nonce', None) + found_nonce = request.GET.get("cross_domain_customer_auth_nonce") + if parent_session_key and expected_nonce and expected_nonce == found_nonce: # not already invalidated, expired, … # Make sure the OTP can't be used again otpstore.delete() diff --git a/src/pretix/presale/views/customer.py b/src/pretix/presale/views/customer.py index 0919bea23..a43051c46 100644 --- a/src/pretix/presale/views/customer.py +++ b/src/pretix/presale/views/customer.py @@ -146,6 +146,7 @@ class LoginView(RedirectBackMixin, FormView): u = urlparse(url) qsl = parse_qs(u.query) qsl['cross_domain_customer_auth'] = otp + qsl['cross_domain_customer_auth_nonce'] = self.request.GET.get("request_cross_domain_customer_auth_nonce", "") url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment)) return url @@ -703,6 +704,7 @@ class SSOLoginView(RedirectBackMixin, View): request.session[f'pretix_customerauth_{self.provider.pk}_nonce'] = nonce request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin'] = popup_origin request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'] = self.request.GET.get("request_cross_domain_customer_auth") == "true" + request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_nonce'] = self.request.GET.get("request_cross_domain_customer_auth_nonce") redirect_uri = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={ 'provider': self.provider.pk }) @@ -953,6 +955,28 @@ class SSOLoginReturnView(RedirectBackMixin, View): u = urlparse(url) qsl = parse_qs(u.query) qsl['cross_domain_customer_auth'] = otp + qsl['cross_domain_customer_auth_nonce'] = self.request.session.get(f'pretix_customerauth_{self.provider.pk}_cross_domain_nonce', '') url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment)) return url + + +class LoginStartView(View): + # When a login is initiated on a event-domain-level view, we need to carry the user to the organizer domain through + # this POST request to be able to set a nonce on their current session. We can't just use a link, since then we'd + # need to create sessions for every anonymous user of the ticketshop, which is too expensive. + + def post(self, request, *args, **kwargs): + if getattr(self.request, 'domain_mode', 'system') not in (KnownDomain.MODE_ORG_ALT_DOMAIN, KnownDomain.MODE_EVENT_DOMAIN): + raise Http404("Only active on event-level domains") + + nonce = get_random_string(32) + request.session['cross_domain_customer_auth_nonce'] = nonce + query = { + "next": request.POST.get("next", ""), + "request_cross_domain_customer_auth_nonce": nonce, + "request_cross_domain_customer_auth": "true", + } + return redirect_to_url( + build_absolute_uri(self.request.organizer, "presale:organizer.customer.login") + "?" + urlencode(query) + ) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 206de6ba7..2cc0e0a6b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -42,7 +42,7 @@ import os import re from collections import Counter, OrderedDict, defaultdict from decimal import Decimal -from urllib.parse import quote +from urllib.parse import quote, urlencode from django import forms from django.conf import settings @@ -55,6 +55,7 @@ from django.http import ( FileResponse, Http404, HttpResponseRedirect, JsonResponse, ) from django.shortcuts import get_object_or_404, redirect, render +from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.timezone import now @@ -117,8 +118,14 @@ class OrderDetailMixin(NoSearchIndexViewMixin): 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') + nonce = get_random_string(32) + self.request.session['cross_domain_customer_auth_nonce'] = nonce + query = { + "next": self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path(), + "request_cross_domain_customer_auth_nonce": nonce, + "request_cross_domain_customer_auth": "true", + } + return redirect_to_url(f'{login_url}?{urlencode(query)}') else: next_url = quote(self.request.get_full_path()) diff --git a/src/pretix/static/pretixpresale/scss/main.scss b/src/pretix/static/pretixpresale/scss/main.scss index d598c8d92..d64f15c5a 100644 --- a/src/pretix/static/pretixpresale/scss/main.scss +++ b/src/pretix/static/pretixpresale/scss/main.scss @@ -221,6 +221,11 @@ footer nav .btn-link { /*border-bottom: 2px solid $brand-primary;*/ font-weight: bold; } + .btn-link { + padding: 0; + margin-left: 5px; + vertical-align: top; + } img { vertical-align: baseline; } @@ -653,6 +658,35 @@ h2 .label { } +.helper-position-relative { + position: relative; +} +.helper-display-block { + display: block !important; +} +.helper-display-inline { + display: inline !important; +} +.helper-display-inline-block { + display: inline-block !important; +} +.helper-display-none-soft { + display: none; +} +.helper-display-none { + display: none !important; +} +.helper-width-auto { + width: auto; +} +.helper-width-100 { + width: 100%; +} +.helper-space-below { + margin-bottom: 10px; +} + + @import "_iframe.scss"; @import "_a11y.scss"; @import "_print.scss"; diff --git a/src/tests/presale/test_customer.py b/src/tests/presale/test_customer.py index dee2eabb4..506b1f480 100644 --- a/src/tests/presale/test_customer.py +++ b/src/tests/presale/test_customer.py @@ -721,9 +721,21 @@ def _cross_domain_login(env, client, client2, org_alt=False): else: KnownDomain.objects.create(domainname='event.test', organizer=env[0], event=env[1]) - # Log in on org domain + # Start session on event domain path = '/conf/' if org_alt else '/' - r = client.post(f'/account/login?next=https://event.test{path}redeem&request_cross_domain_customer_auth=true', { + r = client2.post(f'{path}account/loginstart', { + 'next': f'https://event.test{path}redeem', + }, HTTP_HOST='event.test') + assert r.status_code == 302 + u = urlparse(r.headers['Location']) + assert u.netloc == 'org.test' + assert u.path == '/account/login' + assert 'request_cross_domain_customer_auth=' in u.query + assert 'request_cross_domain_customer_auth_nonce=' in u.query + assert 'next=' in u.query + + # Log in on org domain + r = client.post(f'{u.path}?{u.query}', { 'email': 'john@example.org', 'password': 'foo', }, HTTP_HOST='org.test') @@ -734,6 +746,7 @@ def _cross_domain_login(env, client, client2, org_alt=False): assert u.path == path + 'redeem' q = parse_qs(u.query) assert 'cross_domain_customer_auth' in q + assert 'cross_domain_customer_auth_nonce' in q # Take session over to event domain r = client2.get(f'{path}?{u.query}', HTTP_HOST='event.test') @@ -745,7 +758,7 @@ def _cross_domain_login(env, client, client2, org_alt=False): def test_cross_domain_login(env, client, client2): _cross_domain_login(env, client, client2) - # Logged in on evnet domain + # Logged in on event domain r = client.get('/', HTTP_HOST='event.test') assert r.status_code == 200 assert b'john@example.org' in r.content @@ -896,7 +909,14 @@ def test_cross_domain_login_with_sso(env, client, client2, provider): }, ) - url = f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true' + r = client2.post('/account/loginstart', {"next": "https://event.test/redeem"}, follow=False, HTTP_HOST='event.test') + assert r.status_code == 302 + assert "/account/login" in r['Location'] + + u = urlparse(r.headers['Location']) + nonce = parse_qs(u.query)['request_cross_domain_customer_auth_nonce'][0] + url = (f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true&' + f'request_cross_domain_customer_auth_nonce={nonce}') r = client.get(url, follow=False, HTTP_HOST='org.test') assert r.status_code == 302 assert "/authorize" in r['Location'] @@ -910,6 +930,7 @@ def test_cross_domain_login_with_sso(env, client, client2, provider): assert u.path == '/redeem' q = parse_qs(u.query) assert 'cross_domain_customer_auth' in q + assert 'cross_domain_customer_auth_nonce' in q # Take session over to event domain r = client2.get(f'/?{u.query}', HTTP_HOST='event.test')