From 7f5518dbf69c23743b2d4f71b05802ad6dd37a7c Mon Sep 17 00:00:00 2001
From: Raphael Michel
Date: Mon, 11 Jul 2022 12:45:51 +0200
Subject: [PATCH] OpenID Connect RP support for customer accounts
---
doc/api/resources/customers.rst | 5 +-
src/pretix/api/serializers/organizer.py | 6 +
src/pretix/base/customersso/__init__.py | 21 ++
src/pretix/base/customersso/oidc.py | 207 +++++++++++
.../migrations/0219_auto_20220706_0913.py | 38 ++
src/pretix/base/models/customers.py | 31 ++
src/pretix/base/settings.py | 11 +
src/pretix/control/forms/organizer.py | 91 +++++
src/pretix/control/logdisplay.py | 3 +
src/pretix/control/navigation.py | 9 +
.../pretixcontrol/organizers/customer.html | 14 +-
.../pretixcontrol/organizers/edit.html | 1 +
.../organizers/ssoprovider_delete.html | 25 ++
.../organizers/ssoprovider_edit.html | 33 ++
.../organizers/ssoproviders.html | 44 +++
src/pretix/control/urls.py | 7 +
src/pretix/control/views/organizer.py | 119 ++++++-
src/pretix/locale/wordlist.txt | 1 +
src/pretix/presale/checkoutflow.py | 28 +-
src/pretix/presale/forms/customer.py | 17 +-
.../event/checkout_customer.html | 29 +-
.../templates/pretixpresale/fragment_js.html | 1 +
.../pretixpresale/fragment_modals.html | 26 ++
.../organizers/customer_login.html | 44 ++-
.../organizers/customer_profile.html | 18 +-
.../templates/pretixpresale/postmessage.html | 31 ++
src/pretix/presale/urls.py | 2 +
src/pretix/presale/utils.py | 2 +
src/pretix/presale/views/customer.py | 318 ++++++++++++++++-
.../static/pretixpresale/js/postmessage.js | 10 +
src/pretix/static/pretixpresale/js/ui/sso.js | 51 +++
.../static/pretixpresale/scss/main.scss | 11 +-
src/tests/api/test_customers.py | 23 ++
src/tests/base/test_customer_oidc_rp.py | 331 ++++++++++++++++++
src/tests/control/test_customer.py | 30 ++
src/tests/control/test_organizer.py | 39 +++
src/tests/control/test_permissions.py | 8 +
src/tests/presale/test_checkout.py | 40 +++
src/tests/presale/test_customer.py | 273 ++++++++++++++-
39 files changed, 1943 insertions(+), 55 deletions(-)
create mode 100644 src/pretix/base/customersso/__init__.py
create mode 100644 src/pretix/base/customersso/oidc.py
create mode 100644 src/pretix/base/migrations/0219_auto_20220706_0913.py
create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_delete.html
create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_edit.html
create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/ssoproviders.html
create mode 100644 src/pretix/presale/templates/pretixpresale/postmessage.html
create mode 100644 src/pretix/static/pretixpresale/js/postmessage.js
create mode 100644 src/pretix/static/pretixpresale/js/ui/sso.js
create mode 100644 src/tests/base/test_customer_oidc_rp.py
diff --git a/doc/api/resources/customers.rst b/doc/api/resources/customers.rst
index d9fd2c14f..251a06c8a 100644
--- a/doc/api/resources/customers.rst
+++ b/doc/api/resources/customers.rst
@@ -14,7 +14,10 @@ The customer resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the customer
-external_identifier string External ID of the customer (or ``null``)
+external_identifier string External ID of the customer (or ``null``). This field can
+ be changed for customers created manually or through
+ the API, but is read-only for customers created through a
+ SSO integration.
email string Customer email address
name string Name of this customer (or ``null``)
name_parts object of strings Decomposition of name (i.e. given name, family name)
diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py
index b40c6ed9e..182f797d2 100644
--- a/src/pretix/api/serializers/organizer.py
+++ b/src/pretix/api/serializers/organizer.py
@@ -74,6 +74,11 @@ class CustomerSerializer(I18nAwareModelSerializer):
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified', 'notes')
+ def update(self, instance, validated_data):
+ if instance and instance.provider_id:
+ validated_data['external_identifier'] = instance.external_identifier
+ return super().update(instance, validated_data)
+
class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
@@ -284,6 +289,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class OrganizerSettingsSerializer(SettingsSerializer):
default_fields = [
'customer_accounts',
+ 'customer_accounts_native',
'customer_accounts_link_by_email',
'invoice_regenerate_allowed',
'contact_mail',
diff --git a/src/pretix/base/customersso/__init__.py b/src/pretix/base/customersso/__init__.py
new file mode 100644
index 000000000..9fd5bdc50
--- /dev/null
+++ b/src/pretix/base/customersso/__init__.py
@@ -0,0 +1,21 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
diff --git a/src/pretix/base/customersso/oidc.py b/src/pretix/base/customersso/oidc.py
new file mode 100644
index 000000000..5b93b578e
--- /dev/null
+++ b/src/pretix/base/customersso/oidc.py
@@ -0,0 +1,207 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import logging
+from urllib.parse import urlencode, urljoin
+
+import requests
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+from requests import RequestException
+
+logger = logging.getLogger(__name__)
+
+
+def _urljoin(base, path):
+ if not base.endswith("/"):
+ base += "/"
+ return urljoin(base, path)
+
+
+def oidc_validate_and_complete_config(config):
+ for k in ("base_url", "client_id", "client_secret", "uid_field", "email_field", "scope"):
+ if not config.get(k):
+ raise ValidationError(_('Configuration option "{name}" is missing.').format(name=k))
+
+ conf_url = _urljoin(config["base_url"], ".well-known/openid-configuration")
+ try:
+ resp = requests.get(conf_url, timeout=10)
+ resp.raise_for_status()
+ provider_config = resp.json()
+ except RequestException as e:
+ raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
+ url=conf_url,
+ error=str(e)
+ ))
+ except ValueError as e:
+ raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
+ url=conf_url,
+ error=str(e)
+ ))
+
+ if not provider_config.get("authorization_endpoint"):
+ raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
+ error="authorization_endpoint not set"
+ ))
+
+ if not provider_config.get("userinfo_endpoint"):
+ raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
+ error="userinfo_endpoint not set"
+ ))
+
+ if not provider_config.get("token_endpoint"):
+ raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
+ error="token_endpoint not set"
+ ))
+
+ if "code" not in provider_config.get("response_types_supported", []):
+ raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
+ error=f"provider supports response types {','.join(provider_config.get('response_types_supported', []))}, but we only support 'code'."
+ ))
+
+ if "query" not in provider_config.get("response_modes_supported", ["query", "fragment"]):
+ raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
+ error=f"provider supports response modes {','.join(provider_config.get('response_modes_supported', []))}, but we only support 'query'."
+ ))
+
+ if "authorization_code" not in provider_config.get("grant_types_supported", ["authorization_code", "implicit"]):
+ raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
+ error=f"provider supports grant types {','.join(provider_config.get('grant_types_supported', ''))}, but we only support 'authorization_code'."
+ ))
+
+ if "openid" not in config["scope"].split(" "):
+ raise ValidationError(
+ _('You are not requesting "{scope}".').format(
+ scope="openid",
+ ))
+
+ for scope in config["scope"].split(" "):
+ if scope not in provider_config.get("scopes_supported", []):
+ raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
+ scope=scope,
+ scopes=", ".join(provider_config.get("scopes_supported", []))
+ ))
+
+ for k, v in config.items():
+ if k.endswith('_field') and v:
+ if v not in provider_config.get("claims_supported", []): # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
+ raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
+ field=v,
+ fields=", ".join(provider_config.get("claims_supported", []))
+ ))
+
+ config['provider_config'] = provider_config
+ return config
+
+
+def oidc_authorize_url(provider, state, redirect_uri):
+ endpoint = provider.configuration['provider_config']['authorization_endpoint']
+ params = {
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
+ # https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint
+ 'response_type': 'code',
+ 'client_id': provider.configuration['client_id'],
+ 'scope': provider.configuration['scope'],
+ 'state': state,
+ 'redirect_uri': redirect_uri,
+ }
+ return endpoint + '?' + urlencode(params)
+
+
+def oidc_validate_authorization(provider, code, redirect_uri):
+ endpoint = provider.configuration['provider_config']['token_endpoint']
+ params = {
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
+ # https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'redirect_uri': redirect_uri,
+ }
+ try:
+ resp = requests.post(
+ endpoint,
+ data=params,
+ headers={
+ 'Accept': 'application/json',
+ },
+ auth=(provider.configuration['client_id'], provider.configuration['client_secret']),
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except RequestException:
+ logger.exception('Could not retrieve authorization token')
+ raise ValidationError(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='could not reach login provider',
+ )
+ )
+
+ if 'access_token' not in data:
+ raise ValidationError(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='access token missing',
+ )
+ )
+
+ endpoint = provider.configuration['provider_config']['userinfo_endpoint']
+ try:
+ # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
+ resp = requests.get(
+ endpoint,
+ headers={
+ 'Authorization': f'Bearer {data["access_token"]}'
+ },
+ )
+ resp.raise_for_status()
+ userinfo = resp.json()
+ except RequestException:
+ logger.exception('Could not retrieve user info')
+ raise ValidationError(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='could not fetch user info',
+ )
+ )
+
+ if 'email_verified' in userinfo and not userinfo['email_verified']:
+ # todo: how universal is this, do we need to make this configurable?
+ raise ValidationError(_('The email address on this account is not yet verified. Please first confirm the '
+ 'email address in your customer account.'))
+
+ profile = {}
+ for k, v in provider.configuration.items():
+ if k.endswith('_field'):
+ profile[k[:-6]] = userinfo.get(v)
+
+ if not profile.get('uid'):
+ raise ValidationError(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='could not fetch user id',
+ )
+ )
+
+ if not profile.get('email'):
+ raise ValidationError(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='could not fetch user email',
+ )
+ )
+
+ return profile
diff --git a/src/pretix/base/migrations/0219_auto_20220706_0913.py b/src/pretix/base/migrations/0219_auto_20220706_0913.py
new file mode 100644
index 000000000..b27fe842a
--- /dev/null
+++ b/src/pretix/base/migrations/0219_auto_20220706_0913.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.12 on 2022-07-06 09:13
+
+import django.db.models.deletion
+import i18nfield.fields
+from django.db import migrations, models
+
+import pretix.base.models.base
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0218_checkinlist_addon_match'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CustomerSSOProvider',
+ fields=[
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', i18nfield.fields.I18nCharField(max_length=200)),
+ ('is_active', models.BooleanField(default=True)),
+ ('button_label', i18nfield.fields.I18nCharField(max_length=200)),
+ ('method', models.CharField(max_length=190)),
+ ('configuration', models.JSONField()),
+ ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers', to='pretixbase.organizer')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=(models.Model, pretix.base.models.base.LoggingMixin),
+ ),
+ migrations.AddField(
+ model_name='customer',
+ name='provider',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='pretixbase.customerssoprovider'),
+ ),
+ ]
diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py
index cb496c284..630202fef 100644
--- a/src/pretix/base/models/customers.py
+++ b/src/pretix/base/models/customers.py
@@ -30,6 +30,7 @@ from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
+from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
@@ -39,12 +40,42 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
+class CustomerSSOProvider(LoggedModel):
+ METHOD_OIDC = 'oidc'
+ METHODS = (
+ (METHOD_OIDC, 'OpenID Connect'),
+ )
+
+ id = models.BigAutoField(primary_key=True)
+ organizer = models.ForeignKey(Organizer, related_name='sso_providers', on_delete=models.CASCADE)
+ name = I18nCharField(
+ max_length=200,
+ verbose_name=_("Provider name"),
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_('Active'))
+ button_label = I18nCharField(
+ max_length=200,
+ verbose_name=_("Login button label"),
+ )
+ method = models.CharField(
+ max_length=190,
+ verbose_name=_("Single-sign-on method"),
+ null=False, blank=False,
+ choices=METHODS,
+ )
+ configuration = models.JSONField()
+
+ def allow_delete(self):
+ return not self.customers.exists()
+
+
class Customer(LoggedModel):
"""
Represents a registered customer of an organizer.
"""
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
+ provider = models.ForeignKey(CustomerSSOProvider, related_name='customers', on_delete=models.PROTECT, null=True, blank=True)
identifier = models.CharField(
max_length=190,
db_index=True,
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index a0518ad21..4ce0c51f6 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -146,6 +146,17 @@ DEFAULTS = {
"advanced features like memberships.")
)
},
+ 'customer_accounts_native': {
+ 'default': 'True',
+ 'type': bool,
+ 'form_class': forms.BooleanField,
+ 'serializer_class': serializers.BooleanField,
+ 'form_kwargs': dict(
+ label=_("Allow customers to log in with email address and password"),
+ help_text=_("If disabled, you will need to connect one or more single-sign-on providers."),
+ 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 d11be47a4..87859d947 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -51,6 +51,7 @@ from pytz import common_timezones
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
+from pretix.base.customersso.oidc import oidc_validate_and_complete_config
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
@@ -61,6 +62,7 @@ from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
)
+from pretix.base.models.customers import CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.control.forms import ExtFileField, SplitDateTimeField
@@ -354,6 +356,7 @@ class OrganizerSettingsForm(SettingsForm):
auto_fields = [
'allowed_restricted_plugins',
'customer_accounts',
+ 'customer_accounts_native',
'customer_accounts_link_by_email',
'invoice_regenerate_allowed',
'contact_mail',
@@ -631,6 +634,10 @@ class CustomerUpdateForm(forms.ModelForm):
titles=self.instance.organizer.settings.name_scheme_titles,
label=_('Name'),
)
+ if self.instance.provider_id:
+ self.fields['email'].disabled = True
+ self.fields['is_verified'].disabled = True
+ self.fields['external_identifier'].disabled = True
def clean(self):
email = self.cleaned_data.get('email')
@@ -706,3 +713,87 @@ OrganizerFooterLinkFormset = inlineformset_factory(
formset=BaseOrganizerFooterLinkFormSet,
can_order=False, can_delete=True, extra=0
)
+
+
+class SSOProviderForm(I18nModelForm):
+
+ config_oidc_base_url = forms.URLField(
+ label=pgettext_lazy('sso_oidc', 'Base URL'),
+ required=False,
+ )
+ config_oidc_client_id = forms.CharField(
+ label=pgettext_lazy('sso_oidc', 'Client ID'),
+ required=False,
+ )
+ config_oidc_client_secret = forms.CharField(
+ label=pgettext_lazy('sso_oidc', 'Client secret'),
+ required=False,
+ )
+ config_oidc_scope = forms.CharField(
+ label=pgettext_lazy('sso_oidc', 'Scope'),
+ help_text=pgettext_lazy('sso_oidc', 'Multiple scopes separated with spaces.'),
+ required=False,
+ )
+ config_oidc_uid_field = forms.CharField(
+ label=pgettext_lazy('sso_oidc', 'User ID field'),
+ help_text=pgettext_lazy('sso_oidc', 'We will assume that the contents of the user ID fields are unique and '
+ 'can never change for a user.'),
+ required=True,
+ initial='sub',
+ )
+ config_oidc_email_field = forms.CharField(
+ label=pgettext_lazy('sso_oidc', 'Email field'),
+ help_text=pgettext_lazy('sso_oidc', 'We will assume that all email addresses received from the SSO provider '
+ 'are verified to really belong the the user. If this can\'t be '
+ 'guaranteed, security issues might arise.'),
+ required=True,
+ initial='email',
+ )
+ config_oidc_phone_field = forms.CharField(
+ label=pgettext_lazy('sso_oidc', 'Phone field'),
+ required=False,
+ )
+
+ class Meta:
+ model = CustomerSSOProvider
+ fields = ['is_active', 'name', 'button_label', 'method']
+ widgets = {
+ 'method': forms.RadioSelect,
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ name_scheme = self.event.settings.name_scheme
+ scheme = PERSON_NAME_SCHEMES.get(name_scheme)
+ for fname, label, size in scheme['fields']:
+ self.fields[f'config_oidc_{fname}_field'] = forms.CharField(
+ label=pgettext_lazy('sso_oidc', f'{label} field').format(label=label),
+ required=False,
+ )
+
+ self.fields['method'].choices = [c for c in self.fields['method'].choices if c[0]]
+
+ for fname, f in self.fields.items():
+ if fname.startswith('config_'):
+ prefix, method, suffix = fname.split('_', 2)
+ f.widget.attrs['data-display-dependency'] = f'input[name=method][value={method}]'
+
+ if self.instance and self.instance.method == method:
+ f.initial = self.instance.configuration.get(suffix)
+
+ def clean(self):
+ data = self.cleaned_data
+ if not data.get("method"):
+ return data
+
+ config = {}
+ for fname, f in self.fields.items():
+ if fname.startswith(f'config_{data["method"]}_'):
+ prefix, method, suffix = fname.split('_', 2)
+ config[suffix] = data.get(fname)
+
+ if data["method"] == "oidc":
+ oidc_validate_and_complete_config(config)
+
+ self.instance.configuration = config
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 137ceba19..9ad70d25a 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -321,6 +321,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.webhook.changed': _('The webhook has been changed.'),
'pretix.webhook.retries.expedited': _('The webhook call retry jobs have been manually expedited.'),
'pretix.webhook.retries.dropped': _('The webhook call retry jobs have been dropped.'),
+ 'pretix.ssoprovider.created': _('The SSO provider has been created.'),
+ 'pretix.ssoprovider.changed': _('The SSO provider has been changed.'),
+ 'pretix.ssoprovider.deleted': _('The SSO provider has been deleted.'),
'pretix.membershiptype.created': _('The membership type has been created.'),
'pretix.membershiptype.changed': _('The membership type has been changed.'),
'pretix.membershiptype.deleted': _('The membership type has been deleted.'),
diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py
index c27c9351a..e7fdbbe9a 100644
--- a/src/pretix/control/navigation.py
+++ b/src/pretix/control/navigation.py
@@ -550,6 +550,15 @@ def get_organizer_navigation(request):
'active': 'organizer.membershiptype' in url.url_name,
}
)
+ children.append(
+ {
+ 'label': _('SSO providers'),
+ 'url': reverse('control:organizer.ssoproviders', kwargs={
+ 'organizer': request.organizer.slug
+ }),
+ 'active': 'organizer.ssoprovider' in url.url_name,
+ }
+ )
if children:
nav.append({
'label': _('Customer accounts'),
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer.html b/src/pretix/control/templates/pretixcontrol/organizers/customer.html
index 17b68a1ff..ee2d0e616 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/customer.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/customer.html
@@ -27,10 +27,14 @@
- {% trans "Customer ID" %}
- #{{ customer.identifier }}
- {% if customer.external_identifier %}
- - {% trans "External identifier" %}
- - {{ customer.external_identifier }}
- {% endif %}
+ {% if customer.provider %}
+ - {% trans "SSO provider" %}
+ - {{ customer.provider.name }}
+ {% endif %}
+ {% if customer.external_identifier %}
+ - {% trans "External identifier" %}
+ - {{ customer.external_identifier }}
+ {% endif %}
- {% trans "Status" %}
-
{% if not customer.is_active %}
@@ -44,7 +48,7 @@
- {% trans "E-mail" %}
-
{{ customer.email|default_if_none:"" }}
- {% if customer.email %}
+ {% if customer.email and not customer.provider %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html
index eaa525b1c..f021e3fde 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html
@@ -131,6 +131,7 @@
- {% bootstrap_form login_form layout="checkout" %}
+ {% if request.organizer.settings.customer_accounts_native %}
+ {% bootstrap_form login_form layout="checkout" %}
+
+ {% endif %}
diff --git a/src/pretix/presale/templates/pretixpresale/fragment_js.html b/src/pretix/presale/templates/pretixpresale/fragment_js.html
index 80af10578..d41a101e0 100644
--- a/src/pretix/presale/templates/pretixpresale/fragment_js.html
+++ b/src/pretix/presale/templates/pretixpresale/fragment_js.html
@@ -14,6 +14,7 @@
+
diff --git a/src/pretix/presale/templates/pretixpresale/fragment_modals.html b/src/pretix/presale/templates/pretixpresale/fragment_modals.html
index 4343a93b8..a060d0866 100644
--- a/src/pretix/presale/templates/pretixpresale/fragment_modals.html
+++ b/src/pretix/presale/templates/pretixpresale/fragment_modals.html
@@ -4,6 +4,32 @@
{% load escapejson %}
+
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html
index 9ca8b7fad..e59c3c94e 100644
--- a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html
+++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html
@@ -14,24 +14,38 @@
diff --git a/src/pretix/presale/templates/pretixpresale/postmessage.html b/src/pretix/presale/templates/pretixpresale/postmessage.html
new file mode 100644
index 000000000..2ab50bd1d
--- /dev/null
+++ b/src/pretix/presale/templates/pretixpresale/postmessage.html
@@ -0,0 +1,31 @@
+{% load compress %}
+{% load i18n %}
+{% load static %}
+
+
+
+
{{ settings.PRETIX_INSTANCE_NAME }}
+ {% compress css %}
+
+ {% endcompress %}
+ {% compress js %}
+
+
+ {% endcompress %}
+
+
+
+
+
+
+
{% trans "We are processing your request …" %}
+
+ {{ message|json_script:"postmessage" }}
+
+
+
+ {% trans "If this takes longer than a few minutes, please contact us." %}
+
+
+
+
diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py
index db39986e0..f2a916a50 100644
--- a/src/pretix/presale/urls.py
+++ b/src/pretix/presale/urls.py
@@ -177,6 +177,8 @@ organizer_patterns = [
re_path(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
name='organizer.widget.productlist'),
re_path(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
+ re_path(r'^account/login/(?P
[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'),
+ re_path(r'^account/login/(?P[0-9]+)/return$', pretix.presale.views.customer.SSOLoginReturnView.as_view(), name='organizer.customer.login.return'),
re_path(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
re_path(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'),
re_path(r'^account/register$', pretix.presale.views.customer.RegistrationView.as_view(), name='organizer.customer.register'),
diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py
index 72ec11352..dd5939f26 100644
--- a/src/pretix/presale/utils.py
+++ b/src/pretix/presale/utils.py
@@ -38,6 +38,7 @@ from urllib.parse import urljoin
from django.conf import settings
from django.core.exceptions import PermissionDenied
+from django.db.models import Q
from django.http import Http404
from django.middleware.csrf import rotate_token
from django.shortcuts import redirect
@@ -87,6 +88,7 @@ def get_customer(request):
with scope(organizer=request.organizer):
try:
customer = request.organizer.customers.get(
+ Q(provider__isnull=True) | Q(provider__is_active=True),
is_active=True, is_verified=True,
pk=session[session_key]
)
diff --git a/src/pretix/presale/views/customer.py b/src/pretix/presale/views/customer.py
index 59da6ea3c..74c200fe6 100644
--- a/src/pretix/presale/views/customer.py
+++ b/src/pretix/presale/views/customer.py
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+import hashlib
from importlib import import_module
from urllib.parse import (
parse_qs, quote, urlencode, urljoin, urlparse, urlsplit, urlunparse,
@@ -26,11 +27,13 @@ from urllib.parse import (
from django.conf import settings
from django.contrib import messages
+from django.core.exceptions import ValidationError
from django.core.signing import BadSignature, dumps, loads
-from django.db import transaction
+from django.db import IntegrityError, transaction
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
from django.http import Http404, HttpResponseRedirect
-from django.shortcuts import get_object_or_404, redirect
+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.http import url_has_allowed_host_and_scheme
@@ -40,8 +43,12 @@ from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import DeleteView, FormView, ListView, View
+from pretix.base.customersso.oidc import (
+ oidc_authorize_url, oidc_validate_authorization,
+)
from pretix.base.models import Customer, InvoiceAddress, Order, OrderPosition
from pretix.base.services.mail import mail
+from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
from pretix.presale.forms.customer import (
@@ -58,9 +65,9 @@ SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
class RedirectBackMixin:
redirect_field_name = 'next'
- def get_redirect_url(self):
+ def get_redirect_url(self, redirect_to=None):
"""Return the user-originating redirect URL if it's safe."""
- redirect_to = self.request.POST.get(
+ redirect_to = redirect_to or self.request.POST.get(
self.redirect_field_name,
self.request.GET.get(self.redirect_field_name, '')
)
@@ -101,6 +108,11 @@ class LoginView(RedirectBackMixin, FormView):
return HttpResponseRedirect(redirect_to)
return super().dispatch(request, *args, **kwargs)
+ def post(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
+ return super().post(request, *args, **kwargs)
+
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
@@ -190,6 +202,8 @@ class RegistrationView(RedirectBackMixin, FormView):
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
+ if not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
if self.redirect_authenticated_user and self.request.customer:
redirect_to = self.get_success_url()
if redirect_to == self.request.path:
@@ -231,8 +245,10 @@ class SetPasswordView(FormView):
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
+ if not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
try:
- self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id'))
+ self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id'), provider__isnull=True)
except Customer.DoesNotExist:
messages.error(request, _('You clicked an invalid link.'))
return HttpResponseRedirect(self.get_success_url())
@@ -272,6 +288,8 @@ class ResetPasswordView(FormView):
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
+ if not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
@@ -425,6 +443,8 @@ class ChangePasswordView(CustomerRequiredMixin, FormView):
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
+ if self.request.customer.provider_id:
+ raise Http404('Feature not enabled')
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
@@ -464,7 +484,7 @@ class ChangeInformationView(CustomerRequiredMixin, FormView):
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
def form_valid(self, form):
- if form.cleaned_data['email'] != self.initial_email:
+ if form.cleaned_data['email'] != self.initial_email and not self.request.customer.provider:
new_email = form.cleaned_data['email']
form.cleaned_data['email'] = form.instance.email = self.initial_email
ctx = form.instance.get_email_context()
@@ -493,6 +513,7 @@ class ChangeInformationView(CustomerRequiredMixin, FormView):
with transaction.atomic():
form.save()
d = dict(form.cleaned_data)
+ print(d)
del d['email']
self.request.customer.log_action('pretix.customer.changed', d)
@@ -520,7 +541,7 @@ class ConfirmChangeView(View):
return HttpResponseRedirect(self.get_success_url())
try:
- customer = request.organizer.customers.get(pk=data.get('customer'))
+ customer = request.organizer.customers.get(pk=data.get('customer'), provider__isnull=True)
except Customer.DoesNotExist:
messages.error(request, _('You clicked an invalid link.'))
return HttpResponseRedirect(self.get_success_url())
@@ -541,3 +562,286 @@ class ConfirmChangeView(View):
def get_success_url(self):
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
+
+
+class SSOLoginView(RedirectBackMixin, View):
+ """
+ Start logging in with a SSO provider.
+ """
+ form_class = AuthenticationForm
+ redirect_authenticated_user = True
+
+ @method_decorator(sensitive_post_parameters())
+ @method_decorator(csrf_protect)
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts:
+ raise Http404('Feature not enabled')
+ if self.redirect_authenticated_user and self.request.customer:
+ redirect_to = self.get_success_url()
+ if redirect_to == self.request.path:
+ raise ValueError(
+ "Redirection loop for authenticated user detected. Check that "
+ "your LOGIN_REDIRECT_URL doesn't point to a login page."
+ )
+ return HttpResponseRedirect(redirect_to)
+ return super().dispatch(request, *args, **kwargs)
+
+ @cached_property
+ def provider(self):
+ return get_object_or_404(self.request.organizer.sso_providers.filter(is_active=True), pk=self.kwargs['provider'])
+
+ def get(self, request, *args, **kwargs):
+ next_url = request.GET.get('next') or ''
+ popup_origin = request.GET.get('popup_origin', '')
+ if popup_origin:
+ popup_origin_parsed = urlparse(popup_origin)
+ untrusted = (
+ popup_origin_parsed.hostname != urlparse(settings.SITE_URL).hostname and
+ not KnownDomain.objects.filter(domainname=popup_origin_parsed.hostname, organizer=self.request.organizer.pk).exists()
+ )
+ if untrusted:
+ # Do not accept faked origins
+ popup_origin = None
+
+ nonce = get_random_string(32)
+ 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"
+ redirect_uri = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={
+ 'provider': self.provider.pk
+ })
+
+ if self.provider.method == "oidc":
+ return redirect(oidc_authorize_url(self.provider, f'{nonce}#{next_url}', redirect_uri))
+ else:
+ raise Http404("Unknown SSO method.")
+
+ def get_success_url(self):
+ url = self.get_redirect_url()
+
+ if not url:
+ return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
+ return url
+
+
+class SSOLoginReturnView(RedirectBackMixin, View):
+ """
+ Start logging in with a SSO provider.
+ """
+ form_class = AuthenticationForm
+ redirect_authenticated_user = True
+
+ @method_decorator(sensitive_post_parameters())
+ @method_decorator(csrf_protect)
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts:
+ raise Http404('Feature not enabled')
+ if self.redirect_authenticated_user and self.request.customer:
+ redirect_to = self.get_success_url()
+ if redirect_to == self.request.path:
+ raise ValueError(
+ "Redirection loop for authenticated user detected. Check that "
+ "your LOGIN_REDIRECT_URL doesn't point to a login page."
+ )
+ return HttpResponseRedirect(redirect_to)
+ r = super().dispatch(request, *args, **kwargs)
+ request.session.pop(f'pretix_customerauth_{self.provider.pk}_nonce', None)
+ request.session.pop(f'pretix_customerauth_{self.provider.pk}_popup_origin', None)
+ request.session.pop(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested', None)
+ return r
+
+ @cached_property
+ def provider(self):
+ return get_object_or_404(self.request.organizer.sso_providers.filter(is_active=True), pk=self.kwargs['provider'])
+
+ def get(self, request, *args, **kwargs):
+ redirect_to = None
+ popup_origin = None
+
+ if request.session.get(f'pretix_customerauth_{self.provider.pk}_popup_origin'):
+ popup_origin = request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin']
+
+ if self.provider.method == "oidc":
+ if not request.GET.get('state'):
+ return self._fail(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='state parameter missing',
+ ),
+ popup_origin,
+ )
+
+ nonce, redirect_to = request.GET['state'].split('#')
+
+ if nonce != request.session.get(f'pretix_customerauth_{self.provider.pk}_nonce'):
+ return self._fail(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='invalid nonce',
+ ),
+ popup_origin,
+ )
+ redirect_uri = build_absolute_uri(
+ self.request.organizer, 'presale:organizer.customer.login.return',
+ kwargs={
+ 'provider': self.provider.pk
+ }
+ )
+ try:
+ profile = oidc_validate_authorization(
+ self.provider,
+ request.GET.get('code'),
+ redirect_uri,
+ )
+ except ValidationError as e:
+ for msg in e:
+ return self._fail(msg, popup_origin)
+ else:
+ raise Http404("Unknown SSO method.")
+
+ identifier = hashlib.sha256(
+ profile['uid'].encode() + b'@' + str(self.provider.pk).encode()
+ ).hexdigest().upper()[:settings.ENTROPY['customer_identifier']]
+ if "1" not in identifier and "0" not in identifier:
+ # This is a hack to make sure the hash space does not overlap with the random identifiers generated by
+ # Customer.assign_identifier()
+ identifier = identifier[:4] + "1" + identifier[4:-1]
+
+ try:
+ customer = self.request.organizer.customers.get(
+ provider=self.provider,
+ identifier=identifier,
+ )
+ except Customer.MultipleObjectsReturned:
+ return self._fail(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='identifier not unique',
+ ),
+ popup_origin,
+ )
+ except Customer.DoesNotExist:
+ name_scheme = self.request.organizer.settings.name_scheme
+ name_parts = {
+ '_scheme': name_scheme,
+ }
+ scheme = PERSON_NAME_SCHEMES.get(name_scheme)
+ for fname, label, size in scheme['fields']:
+ if fname in profile:
+ name_parts[fname] = profile[fname]
+ if len(name_parts) == 1 and profile.get('name'):
+ name_parts = {'_legacy': profile['name']}
+ customer = Customer(
+ organizer=self.request.organizer,
+ identifier=identifier,
+ external_identifier=profile['uid'],
+ provider=self.provider,
+ email=profile['email'],
+ phone=profile.get('phone') or None,
+ name_parts=name_parts,
+ is_active=True,
+ is_verified=True, # todo: always?
+ locale=request.LANGUAGE_CODE,
+ )
+ try:
+ customer.save(force_insert=True)
+ except IntegrityError:
+ # This might either be a race condition or the email address is taken
+ # by a different customer account
+ try:
+ customer = self.request.organizer.customers.get(
+ provider=self.provider,
+ identifier=identifier,
+ )
+ except Customer.DoesNotExist:
+ return self._fail(
+ _('We were unable to use your login since the email address {email} is already used for a '
+ 'different account in this system.').format(email=profile['email']),
+ popup_origin,
+ )
+ else:
+ if customer.is_active and customer.email != profile['email']:
+ customer.email = profile['email']
+ try:
+ customer.save(update_fields=['email'])
+ except IntegrityError:
+ return self._fail(
+ _('We were unable to use your login since the email address {email} is already used for a '
+ 'different account in this system.').format(email=profile['email']),
+ popup_origin,
+ )
+ customer.log_action('pretix.customer.changed', {
+ 'email': profile['email'],
+ '_source': 'provider'
+ })
+
+ if customer.external_identifier != profile['uid']:
+ return self._fail(
+ _('Login was not successful. Error message: "{error}".').format(
+ error='identifier not unique',
+ ),
+ popup_origin,
+ )
+
+ if not customer.is_active:
+ self._fail(
+ AuthenticationForm.error_messages['inactive'],
+ popup_origin,
+ )
+
+ if not customer.is_verified:
+ return self._fail(
+ AuthenticationForm.error_messages['unverified'],
+ popup_origin
+ )
+
+ if popup_origin:
+ return render(self.request, 'pretixpresale/postmessage.html', {
+ 'message': {
+ '__process': 'customer_sso_popup',
+ 'status': 'ok',
+ 'value': dumps({
+ 'customer': customer.pk,
+ }, salt=f'customer_sso_popup_{self.request.organizer.pk}')
+ },
+ 'origin': popup_origin,
+ })
+ else:
+ customer_login(self.request, customer)
+ return redirect(self.get_success_url(redirect_to))
+
+ def _fail(self, message, popup_origin):
+ if not popup_origin:
+ messages.error(
+ self.request,
+ message,
+ )
+ return redirect(eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}))
+ else:
+ return render(self.request, 'pretixpresale/postmessage.html', {
+ 'message': {
+ '__process': 'customer_sso_popup',
+ 'status': 'error',
+ 'value': str(message)
+ },
+ 'origin': popup_origin,
+ })
+
+ def get_success_url(self, redirect_to=None):
+ url = self.get_redirect_url(redirect_to)
+
+ if not url:
+ return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
+ else:
+ if self.request.session.get(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'):
+ otpstore = SessionStore()
+ otpstore[f'customer_cross_domain_auth_{self.request.organizer.pk}'] = self.request.session.session_key
+ otpstore.set_expiry(60)
+ otpstore.save(must_create=True)
+ otp = otpstore.session_key
+
+ u = urlparse(url)
+ qsl = parse_qs(u.query)
+ qsl['cross_domain_customer_auth'] = otp
+ url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment))
+
+ return url
diff --git a/src/pretix/static/pretixpresale/js/postmessage.js b/src/pretix/static/pretixpresale/js/postmessage.js
new file mode 100644
index 000000000..2e2fa5d8d
--- /dev/null
+++ b/src/pretix/static/pretixpresale/js/postmessage.js
@@ -0,0 +1,10 @@
+/*global $ */
+
+$(function () {
+ window.addEventListener("message", (event) => {
+ if (event.data && event.data.__process === "popup_close") {
+ window.close()
+ }
+ });
+ window.opener.postMessage(JSON.parse($("#postmessage").text()), $("#origin").text())
+})
\ No newline at end of file
diff --git a/src/pretix/static/pretixpresale/js/ui/sso.js b/src/pretix/static/pretixpresale/js/ui/sso.js
new file mode 100644
index 000000000..f8fd40c9d
--- /dev/null
+++ b/src/pretix/static/pretixpresale/js/ui/sso.js
@@ -0,0 +1,51 @@
+/*global $ */
+
+$(function () {
+ var popup_window = null
+ var popup_check_interval = null
+
+ $("#popupmodal").removeAttr("hidden");
+
+ $("a[data-open-in-popup-window]").on("click", function (e) {
+ e.preventDefault()
+
+ $("#popupmodal a").attr("href", this.href)
+
+ var url = this.href
+ if (url.includes("?")) {
+ url += "&popup_origin=" + window.location.origin
+ } else {
+ url += "?popup_origin=" + window.location.origin
+ }
+ popup_window = window.open(
+ url,
+ "presale-popup",
+ "scrollbars=yes,resizable=yes,status=yes,location=yes,toolbar=no,menubar=no,width=940,height=620,left=50,top=50"
+ )
+ $("body").addClass("has-popup")
+
+ popup_check_interval = window.setInterval(function () {
+ if (popup_window.closed) {
+ $("body").removeClass("has-popup")
+ window.clearInterval(popup_check_interval)
+ }
+ }, 250)
+
+ return false
+ });
+
+ window.addEventListener("message", function (event) {
+ if (event.source !== popup_window)
+ return
+ if (event.data && event.data.__process === "customer_sso_popup") {
+ if (event.data.status === "ok") {
+ $("#login_sso_data").val(event.data.value)
+ $("#login_sso_data").closest("form").get(0).submit()
+ } else {
+ alert(event.data.value) // todo
+ }
+ event.source.postMessage({'__process': 'popup_close'}, "*")
+ }
+ console.log(event)
+ }, false);
+})
\ No newline at end of file
diff --git a/src/pretix/static/pretixpresale/scss/main.scss b/src/pretix/static/pretixpresale/scss/main.scss
index 517550b17..868b8fee0 100644
--- a/src/pretix/static/pretixpresale/scss/main.scss
+++ b/src/pretix/static/pretixpresale/scss/main.scss
@@ -166,7 +166,7 @@ body.loading .container {
font-size: 120px;
color: $brand-primary;
}
-#loadingmodal, #ajaxerr, #cookie-consent-modal {
+#loadingmodal, #ajaxerr, #cookie-consent-modal, #popupmodal {
position: fixed;
top: 0;
left: 0;
@@ -183,6 +183,11 @@ body.loading .container {
font-size: 200px;
color: $brand-primary;
}
+ popupmodal .big-icon {
+ margin-top: 10px;
+ font-size: 100px;
+ color: $brand-primary;
+ }
.modal-card {
margin: 50px auto 0;
@@ -231,7 +236,7 @@ body.loading .container {
}
}
@media (max-width: 700px) {
- #loadingmodal, #ajaxerr, #cookie-consent-modal {
+ #loadingmodal, #ajaxerr, #cookie-consent-modal, #popupmodal {
.modal-card {
margin: 25px auto 0;
max-height: calc(100vh - 50px - 20px);
@@ -253,7 +258,7 @@ body.loading .container {
background: rgba(236, 236, 236, .9);
}
-.loading #loadingmodal, .ajaxerr #ajaxerr {
+.loading #loadingmodal, .ajaxerr #ajaxerr, .has-popup #popupmodal {
opacity: 1;
visibility: visible;
transition: opacity .5s ease-in-out;
diff --git a/src/tests/api/test_customers.py b/src/tests/api/test_customers.py
index 54a18cac5..a88798232 100644
--- a/src/tests/api/test_customers.py
+++ b/src/tests/api/test_customers.py
@@ -140,6 +140,29 @@ def test_customer_patch(token_client, organizer, customer):
assert customer.email == 'blubb@example.org'
+@pytest.mark.django_db
+def test_customer_patch_with_provider(token_client, organizer, customer):
+ with scopes_disabled():
+ customer.provider = organizer.sso_providers.create(
+ method="oidc",
+ name="OIDC OP",
+ configuration={}
+ )
+ customer.external_identifier = "123"
+ customer.save()
+
+ resp = token_client.patch(
+ '/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier),
+ format='json',
+ data={
+ 'external_identifier': '234',
+ }
+ )
+ assert resp.status_code == 200
+ customer.refresh_from_db()
+ assert customer.external_identifier == "123"
+
+
@pytest.mark.django_db
def test_customer_anonymize(token_client, organizer, customer):
resp = token_client.post(
diff --git a/src/tests/base/test_customer_oidc_rp.py b/src/tests/base/test_customer_oidc_rp.py
new file mode 100644
index 000000000..0f979015d
--- /dev/null
+++ b/src/tests/base/test_customer_oidc_rp.py
@@ -0,0 +1,331 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import pytest
+import responses
+from django.core.exceptions import ValidationError
+from responses import matchers
+
+from pretix.base.customersso.oidc import (
+ oidc_authorize_url, oidc_validate_and_complete_config,
+ oidc_validate_authorization,
+)
+from pretix.base.models import Organizer
+from pretix.base.models.customers import CustomerSSOProvider
+
+
+def test_missing_parameter():
+ config = {
+ "base_url": "https://example.com",
+ "client_id": "abc123",
+ "client_secret": "abcdefghi",
+ "uid_field": "sub",
+ }
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert '"email_field" is missing' in str(e.value)
+
+
+@responses.activate
+def test_autoconf_unreachable():
+ config = {
+ "base_url": "https://example.com/provider",
+ "client_id": "abc123",
+ "client_secret": "abcdefghi",
+ "uid_field": "sub",
+ "email_field": "email",
+ "scope": "foo bar",
+ }
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={"error": "not found"},
+ status=404
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "Unable to retrieve" in str(e.value)
+ assert "404" in str(e.value)
+
+
+@responses.activate
+def test_incompatible():
+ config = {
+ "base_url": "https://example.com/provider",
+ "client_id": "abc123",
+ "client_secret": "abcdefghi",
+ "uid_field": "sub",
+ "email_field": "email",
+ "scope": "foo bar",
+ }
+
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={},
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "authorization_endpoint not set" in str(e.value)
+
+ responses.reset()
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ },
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "userinfo_endpoint not set" in str(e.value)
+
+ responses.reset()
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ },
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "token_endpoint not set" in str(e.value)
+
+ responses.reset()
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ },
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "provider supports response types" in str(e.value)
+
+ responses.reset()
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["bogus"],
+ },
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "provider supports response modes" in str(e.value)
+
+ responses.reset()
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["query"],
+ "grant_types_supported": ["test"],
+ },
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "provider supports grant types" in str(e.value)
+
+ responses.reset()
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["query"],
+ "grant_types_supported": ["authorization_code"],
+ },
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "not requesting" in str(e.value)
+
+ config["scope"] = "openid foo"
+
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_and_complete_config(config)
+ assert "requesting scope" in str(e.value)
+
+
+@responses.activate
+def test_compatible():
+ config = {
+ "base_url": "https://example.com/provider",
+ "client_id": "abc123",
+ "client_secret": "abcdefghi",
+ "uid_field": "sub",
+ "email_field": "email",
+ "scope": "openid email profile",
+ }
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json={
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["query"],
+ "grant_types_supported": ["authorization_code"],
+ "scopes_supported": ["openid", "email", "profile"],
+ "claims_supported": ["email", "sub"]
+ },
+ )
+ config = oidc_validate_and_complete_config(config)
+ assert config["provider_config"]["token_endpoint"] == "https://example.com/token"
+
+
+@pytest.fixture
+def organizer():
+ return Organizer.objects.create(name="Dummy", slug="dummy")
+
+
+@pytest.fixture
+def provider(organizer):
+ return CustomerSSOProvider.objects.create(
+ organizer=organizer,
+ method="oidc",
+ name="OIDC OP",
+ configuration={
+ "base_url": "https://example.com/provider",
+ "client_id": "abc123",
+ "client_secret": "abcdefghi",
+ "uid_field": "sub",
+ "email_field": "email",
+ "scope": "openid email profile",
+ "provider_config": {
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["query"],
+ "grant_types_supported": ["authorization_code"],
+ "scopes_supported": ["openid", "email", "profile"],
+ "claims_supported": ["email", "sub"]
+ }
+ }
+ )
+
+
+@pytest.mark.django_db
+def test_authorize_url(provider):
+ assert (
+ "https://example.com/authorize?"
+ "response_type=code&"
+ "client_id=abc123&"
+ "scope=openid+email+profile&"
+ "state=state_val&"
+ "redirect_uri=https%3A%2F%2Fredirect%3Ffoo%3Dbar"
+ ) == oidc_authorize_url(provider, "state_val", "https://redirect?foo=bar")
+
+
+@pytest.mark.django_db
+@responses.activate
+def test_validate_authorization_invalid(provider):
+ responses.add(
+ responses.POST,
+ "https://example.com/token",
+ json={},
+ status=400,
+ )
+ with pytest.raises(ValidationError):
+ oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar")
+
+
+@pytest.mark.django_db
+@responses.activate
+def test_validate_authorization_userinfo_invalid(provider):
+ responses.add(
+ responses.POST,
+ "https://example.com/token",
+ json={
+ 'access_token': 'test_access_token',
+ },
+ match=[
+ matchers.urlencoded_params_matcher({
+ "grant_type": "authorization_code",
+ "code": "code_received",
+ "redirect_uri": "https://redirect?foo=bar",
+ })
+ ],
+ )
+ responses.add(
+ responses.GET,
+ "https://example.com/userinfo",
+ json={
+ 'uid': 'abcdf',
+ 'email': 'test@example.org'
+ },
+ match=[
+ matchers.header_matcher({"Authorization": "Bearer test_access_token"})
+ ],
+ )
+ with pytest.raises(ValidationError) as e:
+ oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar")
+ assert 'could not fetch' in str(e.value)
+
+
+@pytest.mark.django_db
+@responses.activate
+def test_validate_authorization_valid(provider):
+ responses.add(
+ responses.POST,
+ "https://example.com/token",
+ json={
+ 'access_token': 'test_access_token',
+ },
+ match=[
+ matchers.urlencoded_params_matcher({
+ "grant_type": "authorization_code",
+ "code": "code_received",
+ "redirect_uri": "https://redirect?foo=bar",
+ })
+ ],
+ )
+ responses.add(
+ responses.GET,
+ "https://example.com/userinfo",
+ json={
+ 'sub': 'abcdf',
+ 'email': 'test@example.org'
+ },
+ match=[
+ matchers.header_matcher({"Authorization": "Bearer test_access_token"})
+ ],
+ )
+ oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar")
diff --git a/src/tests/control/test_customer.py b/src/tests/control/test_customer.py
index e8c8097a7..52b9febed 100644
--- a/src/tests/control/test_customer.py
+++ b/src/tests/control/test_customer.py
@@ -31,6 +31,7 @@ from tests.base import extract_form_fields
from pretix.base.models import (
Item, Order, OrderPosition, Organizer, Team, User,
)
+from pretix.base.models.customers import CustomerSSOProvider
@pytest.fixture
@@ -90,6 +91,16 @@ def admin_user(organizer):
return u
+@pytest.fixture
+def provider(organizer):
+ return CustomerSSOProvider.objects.create(
+ organizer=organizer,
+ method="oidc",
+ name="OIDC OP",
+ configuration={}
+ )
+
+
@pytest.mark.django_db
def test_list_of_customers(organizer, admin_user, client, customer):
client.login(email='dummy@dummy.dummy', password='dummy')
@@ -125,6 +136,25 @@ def test_customer_update(organizer, admin_user, customer, client):
assert customer.is_verified
+@pytest.mark.django_db
+def test_customer_update_email_not_allowed_for_sso_customers(organizer, admin_user, customer, client, provider):
+ customer.provider = provider
+ customer.save()
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ resp = client.get('/control/organizer/dummy/customer/{}/edit'.format(customer.identifier))
+ doc = BeautifulSoup(resp.content, "lxml")
+ d = extract_form_fields(doc)
+ d['name_parts_0'] = 'John Doe'
+ d['email'] = 'customer@example.net'
+ d['external_identifier'] = 'aaaaaaa'
+ resp = client.post('/control/organizer/dummy/customer/{}/edit'.format(customer.identifier), d)
+ assert resp.status_code == 302
+ customer.refresh_from_db()
+ assert customer.name == 'John Doe'
+ assert customer.email == "john@example.org"
+ assert not customer.external_identifier
+
+
@pytest.mark.django_db
def test_customer_anonymize(organizer, admin_user, customer, client, order):
customer.is_active = True
diff --git a/src/tests/control/test_organizer.py b/src/tests/control/test_organizer.py
index c2e87e38c..28538c268 100644
--- a/src/tests/control/test_organizer.py
+++ b/src/tests/control/test_organizer.py
@@ -23,6 +23,7 @@ import datetime
from smtplib import SMTPResponseException
import pytest
+import responses
from django.db import transaction
from django.test.utils import override_settings
from django_scopes import scopes_disabled
@@ -292,3 +293,41 @@ class OrganizerTest(SoupTest):
self.orga1.settings.flush()
assert "smtp_use_custom" not in self.orga1.settings._cache()
assert "mail_from" not in self.orga1.settings._cache()
+
+ @responses.activate
+ def test_create_sso_provider(self):
+ conf = {
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["query"],
+ "grant_types_supported": ["authorization_code"],
+ "scopes_supported": ["openid", "email", "profile"],
+ "claims_supported": ["email", "sub"]
+ }
+ responses.add(
+ responses.GET,
+ "https://example.com/provider/.well-known/openid-configuration",
+ json=conf
+ )
+ doc = self.post_doc(
+ '/control/organizer/%s/ssoprovider/add' % self.orga1.slug,
+ {
+ 'name_0': 'OIDC',
+ 'button_label_0': 'Log in with OIDC',
+ 'method': 'oidc',
+ 'config_oidc_base_url': 'https://example.com/provider',
+ 'config_oidc_client_id': 'aaaa',
+ 'config_oidc_client_secret': 'bbbb',
+ 'config_oidc_scope': 'openid email',
+ 'config_oidc_email_field': 'email',
+ 'config_oidc_uid_field': 'sub',
+ },
+ follow=True
+ )
+ assert not doc.select('.has-error, .alert-danger')
+ with scopes_disabled():
+ p = self.orga1.sso_providers.get()
+ assert p.configuration['scope'] == 'openid email'
+ assert p.configuration['provider_config'] == conf
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index 05fa66c12..c5f73eb0c 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -204,6 +204,10 @@ organizer_urls = [
'organizer/abc/webhook/add',
'organizer/abc/webhook/1/edit',
'organizer/abc/webhook/1/logs',
+ 'organizer/abc/ssoproviders',
+ 'organizer/abc/ssoprovider/add',
+ 'organizer/abc/ssoprovider/1/edit',
+ 'organizer/abc/ssoprovider/1/delete',
'organizer/abc/customers',
'organizer/abc/customer/add',
'organizer/abc/customer/1/',
@@ -523,6 +527,10 @@ organizer_permission_urls = [
("can_change_organizer_settings", "organizer/dummy/membershiptype/add", 200),
("can_change_organizer_settings", "organizer/dummy/membershiptype/1/edit", 404),
("can_change_organizer_settings", "organizer/dummy/membershiptype/1/delete", 404),
+ ("can_change_organizer_settings", "organizer/dummy/ssoproviders", 200),
+ ("can_change_organizer_settings", "organizer/dummy/ssoprovider/add", 200),
+ ("can_change_organizer_settings", "organizer/dummy/ssoprovider/1/edit", 404),
+ ("can_change_organizer_settings", "organizer/dummy/ssoprovider/1/delete", 404),
("can_manage_customers", "organizer/dummy/customers", 200),
("can_manage_customers", "organizer/dummy/customer/ABC/edit", 404),
("can_manage_customers", "organizer/dummy/customer/ABC/anonymize", 404),
diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py
index 326b1f1db..e015d3590 100644
--- a/src/tests/presale/test_checkout.py
+++ b/src/tests/presale/test_checkout.py
@@ -31,6 +31,7 @@ from bs4 import BeautifulSoup
from django.conf import settings
from django.core import mail as djmail
from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.signing import dumps
from django.test import TestCase
from django.utils.crypto import get_random_string
from django.utils.timezone import now
@@ -43,6 +44,7 @@ from pretix.base.models import (
Order, OrderPayment, OrderPosition, Organizer, Question, QuestionAnswer,
Quota, SeatingPlan, Voucher,
)
+from pretix.base.models.customers import CustomerSSOProvider
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemVariation, SubEventItem, SubEventItemVariation,
)
@@ -4238,6 +4240,44 @@ class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase):
}, follow=False)
assert response.status_code == 200
+ def test_native_auth_disabled(self):
+ self.orga.settings.customer_accounts_native = False
+ response = self.client.get('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug))
+ assert b'register-email' not in response.content
+ assert b'login-email' not in response.content
+
+ response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), {
+ 'customer_mode': 'register',
+ 'register-email': 'foo@example.com',
+ 'register-name_parts_0': 'John Doe',
+ }, follow=False)
+ assert response.status_code == 200
+
+ response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), {
+ 'customer_mode': 'login',
+ 'login-email': 'john@example.org',
+ 'login-password': 'foo',
+ }, follow=False)
+ assert response.status_code == 200
+
+ def test_sso_login(self):
+ with scopes_disabled():
+ self.customer.provider = CustomerSSOProvider.objects.create(
+ organizer=self.orga,
+ method="oidc",
+ name="OIDC OP",
+ configuration={}
+ )
+ self.customer.save()
+ response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), {
+ 'customer_mode': 'login',
+ 'login-sso-data': dumps({'customer': self.customer.pk}, salt=f'customer_sso_popup_{self.orga.pk}'),
+ 'login-password': 'foo',
+ }, follow=False)
+ assert response.status_code == 302
+ self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
+ target_status_code=200)
+
def test_select_membership(self):
mtype = self.orga.membership_types.create(name='Week pass', transferable=False)
mtype2 = self.orga.membership_types.create(name='Invalid pass')
diff --git a/src/tests/presale/test_customer.py b/src/tests/presale/test_customer.py
index d7611de84..c267d18c9 100644
--- a/src/tests/presale/test_customer.py
+++ b/src/tests/presale/test_customer.py
@@ -22,16 +22,20 @@
import datetime
from datetime import timedelta
from decimal import Decimal
-from urllib.parse import parse_qs, urlparse
+from urllib.parse import parse_qs, quote, urlparse
import pytest
+import responses
from django.core import mail as djmail, signing
from django.core.signing import dumps
from django.test import Client
from django.utils.timezone import now
from django_scopes import scopes_disabled
-from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
+from pretix.base.models import (
+ Customer, Event, Item, Order, OrderPosition, Organizer,
+)
+from pretix.base.models.customers import CustomerSSOProvider
from pretix.multidomain.models import KnownDomain
from pretix.presale.forms.customer import TokenGenerator
@@ -69,6 +73,27 @@ def test_disabled(env, client):
assert r.status_code == 404
+@pytest.mark.django_db
+def test_native_disabled(env, client):
+ env[0].settings.customer_accounts_native = False
+ r = client.get('/bigevents/account/register')
+ assert r.status_code == 404
+ r = client.get('/bigevents/account/login')
+ assert r.status_code == 200
+ r = client.get('/bigevents/account/pwreset')
+ assert r.status_code == 404
+ r = client.get('/bigevents/account/pwrecover')
+ assert r.status_code == 404
+ r = client.get('/bigevents/account/activate')
+ assert r.status_code == 404
+ r = client.get('/bigevents/account/change')
+ assert r.status_code == 302
+ r = client.get('/bigevents/account/confirmchange')
+ assert r.status_code == 302
+ r = client.get('/bigevents/account/')
+ assert r.status_code == 302
+
+
@pytest.mark.django_db
def test_org_register(env, client):
signer = signing.TimestampSigner(salt='customer-registration-captcha-127.0.0.1')
@@ -209,6 +234,162 @@ def test_org_login_not_active(env, client):
assert b'alert-danger' in r.content
+@pytest.fixture
+def provider(env):
+ return CustomerSSOProvider.objects.create(
+ organizer=env[0],
+ method="oidc",
+ name="OIDC OP",
+ configuration={
+ "base_url": "https://example.com/provider",
+ "client_id": "abc123",
+ "client_secret": "abcdefghi",
+ "uid_field": "sub",
+ "email_field": "email",
+ "scope": "openid email profile",
+ "provider_config": {
+ "authorization_endpoint": "https://example.com/authorize",
+ "token_endpoint": "https://example.com/token",
+ "userinfo_endpoint": "https://example.com/userinfo",
+ "response_types_supported": ["code"],
+ "response_modes_supported": ["query"],
+ "grant_types_supported": ["authorization_code"],
+ "scopes_supported": ["openid", "email", "profile"],
+ "claims_supported": ["email", "sub"]
+ }
+ }
+ )
+
+
+@responses.activate
+def _sso_login(client, provider, email='test@example.org', popup_origin=None, expect_fail=False):
+ responses.reset()
+ responses.add(
+ responses.POST,
+ "https://example.com/token",
+ json={
+ 'access_token': 'test_access_token',
+ },
+ )
+ responses.add(
+ responses.GET,
+ "https://example.com/userinfo",
+ json={
+ 'sub': 'abcdf',
+ 'email': email
+ },
+ )
+
+ url = f'/bigevents/account/login/{provider.pk}/?next=/redirect'
+ if popup_origin:
+ url += '&popup_origin=' + popup_origin
+ r = client.get(url, follow=False)
+ assert r.status_code == 302
+ assert "/authorize" in r['Location']
+ u = urlparse(r['Location'])
+ state = parse_qs(u.query)['state'][0]
+ r = client.get(f'/bigevents/account/login/{provider.pk}/return?code=test_code&state={quote(state)}')
+ if not expect_fail:
+ if popup_origin:
+ assert r.status_code == 200
+ assert popup_origin in r.content.decode()
+ else:
+ assert r.status_code == 302
+ assert "/redirect" in r['Location']
+ else:
+ if popup_origin:
+ assert r.status_code == 200
+ assert popup_origin in r.content.decode()
+ else:
+ assert r.status_code == 302
+ assert "/account/login" in r['Location']
+
+ r = client.get('/bigevents/account/')
+ assert r.status_code == 302
+
+
+@pytest.mark.django_db
+def test_org_sso_login_new_customer(env, client, provider):
+ _sso_login(client, provider)
+
+ with scopes_disabled():
+ c = Customer.objects.get(provider=provider)
+ assert c.external_identifier == "abcdf"
+
+ r = client.get('/bigevents/account/')
+ assert r.status_code == 200
+
+
+@pytest.mark.django_db
+def test_org_sso_logout_if_provider_disabled(env, client, provider):
+ _sso_login(client, provider)
+
+ with scopes_disabled():
+ c = Customer.objects.get(provider=provider)
+ assert c.external_identifier == "abcdf"
+
+ r = client.get('/bigevents/account/')
+ assert r.status_code == 200
+
+ provider.is_active = False
+ provider.save()
+
+ r = client.get('/bigevents/account/')
+ assert r.status_code == 302
+
+
+@pytest.mark.django_db
+def test_org_sso_login_new_customer_popup(env, client, provider):
+ KnownDomain.objects.create(organizer=env[0], event=env[1], domainname="popuporigin")
+ _sso_login(client, provider, popup_origin="https://popuporigin")
+
+
+@pytest.mark.django_db
+def test_org_sso_login_new_customer_popup_invalid_origin(env, client, provider):
+ KnownDomain.objects.create(organizer=env[0], event=env[1], domainname="popuporigin")
+ with pytest.raises(AssertionError):
+ _sso_login(client, provider, popup_origin="https://forbidden")
+
+
+@pytest.mark.django_db
+def test_org_sso_login_returning_customer_new_email(env, client, provider):
+ _sso_login(client, provider)
+ with scopes_disabled():
+ c = Customer.objects.get(provider=provider)
+
+ r = client.get('/bigevents/account/logout')
+ assert r.status_code == 302
+
+ _sso_login(client, provider, 'new@example.net')
+ c.refresh_from_db()
+ assert c.email == "new@example.net"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_org_sso_login_returning_customer_new_email_conflict(env, client, provider):
+ with scopes_disabled():
+ customer = env[0].customers.create(email='new@example.net', is_verified=True, is_active=False)
+ customer.set_password('foo')
+ customer.save()
+
+ _sso_login(client, provider)
+
+ r = client.get('/bigevents/account/logout')
+ assert r.status_code == 302
+
+ _sso_login(client, provider, 'new@example.net', expect_fail=True)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_org_sso_login_new_customer_email_conflict(env, client, provider):
+ with scopes_disabled():
+ customer = env[0].customers.create(email='new@example.net', is_verified=True, is_active=False)
+ customer.set_password('foo')
+ customer.save()
+
+ _sso_login(client, provider, 'new@example.net', expect_fail=True)
+
+
@pytest.mark.django_db
@pytest.mark.parametrize("url", [
"account/change",
@@ -308,6 +489,20 @@ def test_org_order_list(env, client):
assert o3.code in content
+@pytest.mark.django_db
+def test_no_login_for_sso_accounts_even_if_password_is_set(env, client, provider):
+ with scopes_disabled():
+ customer = env[0].customers.create(email='john@example.org', is_verified=True, provider=provider)
+ customer.set_password('foo')
+ customer.save()
+
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 200
+
+
@pytest.mark.django_db
def test_change_name(env, client):
with scopes_disabled():
@@ -330,6 +525,24 @@ def test_change_name(env, client):
assert customer.name == 'John Doe'
+@pytest.mark.django_db
+def test_no_change_email_or_pass_for_sso_customers(env, client, provider):
+ _sso_login(client, provider, 'john@example.org')
+ r = client.post('/bigevents/account/change', {
+ 'name_parts_0': 'Johnny',
+ 'email': 'john@example.com',
+ })
+ assert r.status_code == 302
+ with scopes_disabled():
+ customer = Customer.objects.get(provider=provider)
+ customer.refresh_from_db()
+ assert customer.email == 'john@example.org'
+ assert customer.name == 'Johnny'
+ assert len(djmail.outbox) == 0
+ r = client.get('/bigevents/account/password')
+ assert r.status_code == 404
+
+
@pytest.mark.django_db
def test_change_email(env, client):
with scopes_disabled():
@@ -567,3 +780,59 @@ def test_cross_domain_login_validate_redirect_url(env, client, client2):
assert u.path == '/account/'
q = parse_qs(u.query)
assert 'cross_domain_customer_auth' not in q
+
+
+@pytest.mark.django_db
+@responses.activate
+def test_cross_domain_login_with_sso(env, client, client2, provider):
+ with scopes_disabled():
+ KnownDomain.objects.create(domainname='org.test', organizer=env[0])
+ KnownDomain.objects.create(domainname='event.test', organizer=env[0], event=env[1])
+
+ # Log in on org domain
+ responses.reset()
+ responses.add(
+ responses.POST,
+ "https://example.com/token",
+ json={
+ 'access_token': 'test_access_token',
+ },
+ )
+ responses.add(
+ responses.GET,
+ "https://example.com/userinfo",
+ json={
+ 'sub': 'abcdf',
+ 'email': 'john@example.org'
+ },
+ )
+
+ url = f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true'
+ r = client.get(url, follow=False, HTTP_HOST='org.test')
+ assert r.status_code == 302
+ assert "/authorize" in r['Location']
+ u = urlparse(r['Location'])
+ state = parse_qs(u.query)['state'][0]
+
+ r = client.get(f'/account/login/{provider.pk}/return?code=test_code&state={quote(state)}', HTTP_HOST='org.test')
+ assert r.status_code == 302
+ u = urlparse(r.headers['Location'])
+ assert u.netloc == 'event.test'
+ assert u.path == '/redeem'
+ q = parse_qs(u.query)
+ assert 'cross_domain_customer_auth' in q
+
+ # Take session over to event domain
+ r = client2.get(f'/?{u.query}', HTTP_HOST='event.test')
+ assert r.status_code == 200
+ assert b'john@example.org' in r.content
+
+ # Logged in on org domain
+ r = client.get('/', HTTP_HOST='event.test')
+ assert r.status_code == 200
+ assert b'john@example.org' in r.content
+
+ # Logged in on event domain
+ r = client2.get('/', HTTP_HOST='org.test')
+ assert r.status_code == 200
+ assert b'john@example.org' in r.content