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 @@
{% trans "Customer accounts" %} {% bootstrap_field sform.customer_accounts layout="control" %} + {% bootstrap_field sform.customer_accounts_native 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/control/templates/pretixcontrol/organizers/ssoprovider_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_delete.html new file mode 100644 index 000000000..d94197e00 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_delete.html @@ -0,0 +1,25 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} +

{% trans "Delete SSO provider:" %} {{ provider.name }}

+
+ {% csrf_token %} + {% if is_allowed %} +

{% blocktrans %}Are you sure you want to delete this SSO provider?{% endblocktrans %} + {% else %} +

{% blocktrans %}This SSO provider cannot be deleted since it has already been used.{% endblocktrans %} + {% endif %} +

+
+ + {% trans "Cancel" %} + + {% if is_allowed %} + + {% endif %} +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_edit.html new file mode 100644 index 000000000..1e9869107 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoprovider_edit.html @@ -0,0 +1,33 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if provider %} +

{% trans "SSO provider:" %} {{ provider.name }}

+ {% else %} +

{% trans "Create a new SSO provider" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} + {% if redirect_uri %} +
+ +
+ +
+
+ {% endif %} +
+ +
+ +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoproviders.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoproviders.html new file mode 100644 index 000000000..248c347a1 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoproviders.html @@ -0,0 +1,44 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "SSO providers" %}{% endblock %} +{% block inner %} +

{% trans "SSO providers" %}

+

+ {% blocktrans trimmed %} + You can connect existing Single-Sign-On (SSO) providers to allow your customers to log in using your own + account system. + {% endblocktrans %} +

+ + + {% trans "Create a new SSO provider" %} + + + + + + + + + + {% for p in providers %} + + + + + {% endfor %} + +
{% trans "Name" %}
+ + {% if not p.is_active %}{% endif %} + {{ p.name }} + {% if not p.is_active %}{% endif %} + + + + +
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index ed43ea9ca..a14131f45 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -133,6 +133,13 @@ urlpatterns = [ name='organizer.membershiptype.edit'), re_path(r'^organizer/(?P[^/]+)/membershiptype/(?P[^/]+)/delete$', organizer.MembershipTypeDeleteView.as_view(), name='organizer.membershiptype.delete'), + re_path(r'^organizer/(?P[^/]+)/ssoproviders$', organizer.SSOProviderListView.as_view(), name='organizer.ssoproviders'), + re_path(r'^organizer/(?P[^/]+)/ssoprovider/add$', organizer.SSOProviderCreateView.as_view(), + name='organizer.ssoprovider.add'), + re_path(r'^organizer/(?P[^/]+)/ssoprovider/(?P[^/]+)/edit$', organizer.SSOProviderUpdateView.as_view(), + name='organizer.ssoprovider.edit'), + re_path(r'^organizer/(?P[^/]+)/ssoprovider/(?P[^/]+)/delete$', organizer.SSOProviderDeleteView.as_view(), + name='organizer.ssoprovider.delete'), re_path(r'^organizer/(?P[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'), re_path(r'^organizer/(?P[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'), re_path(r'^organizer/(?P[^/]+)/customer/add$', diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index abfe0e07a..a21a8e300 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -71,6 +71,7 @@ from pretix.base.models import ( Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer, Team, TeamInvite, User, ) +from pretix.base.models.customers import CustomerSSOProvider from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.giftcards import ( GiftCardTransaction, gen_giftcard_secret, @@ -94,7 +95,8 @@ from pretix.control.forms.organizer import ( EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, - OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, + OrganizerSettingsForm, OrganizerUpdateForm, SSOProviderForm, TeamForm, + WebHookForm, ) from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.permissions import ( @@ -1924,6 +1926,119 @@ class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequ return redirect(success_url) +class SSOProviderListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = CustomerSSOProvider + template_name = 'pretixcontrol/organizers/ssoproviders.html' + permission = 'can_change_organizer_settings' + context_object_name = 'providers' + + def get_queryset(self): + return self.request.organizer.sso_providers.all() + + +class SSOProviderCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + model = CustomerSSOProvider + template_name = 'pretixcontrol/organizers/ssoprovider_edit.html' + permission = 'can_change_organizer_settings' + form_class = SSOProviderForm + + def get_object(self, queryset=None): + return get_object_or_404(CustomerSSOProvider, organizer=self.request.organizer, pk=self.kwargs.get('provider')) + + def get_success_url(self): + return reverse('control:organizer.ssoproviders', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.organizer + return kwargs + + def form_valid(self, form): + messages.success(self.request, _('The provider has been created.')) + form.instance.organizer = self.request.organizer + ret = super().form_valid(form) + form.instance.log_action('pretix.ssoprovider.created', user=self.request.user, data={ + k: getattr(self.object, k, self.object.configuration.get(k)) for k in form.changed_data + }) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class SSOProviderUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + model = CustomerSSOProvider + template_name = 'pretixcontrol/organizers/ssoprovider_edit.html' + permission = 'can_change_organizer_settings' + context_object_name = 'provider' + form_class = SSOProviderForm + + def get_object(self, queryset=None): + return get_object_or_404(CustomerSSOProvider, organizer=self.request.organizer, pk=self.kwargs.get('provider')) + + def get_success_url(self): + return reverse('control:organizer.ssoproviders', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['redirect_uri'] = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={ + 'provider': self.object.pk + }) + return ctx + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.organizer + return kwargs + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.ssoprovider.changed', user=self.request.user, data={ + k: getattr(self.object, k, self.object.configuration.get(k)) for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView): + model = CustomerSSOProvider + template_name = 'pretixcontrol/organizers/ssoprovider_delete.html' + permission = 'can_change_organizer_settings' + context_object_name = 'provider' + + def get_object(self, queryset=None): + return get_object_or_404(CustomerSSOProvider, organizer=self.request.organizer, pk=self.kwargs.get('provider')) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['is_allowed'] = self.object.allow_delete() + return ctx + + def get_success_url(self): + return reverse('control:organizer.ssoproviders', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + success_url = self.get_success_url() + self.object = self.get_object() + if self.object.allow_delete(): + self.object.log_action('pretix.ssoprovider.deleted', user=self.request.user) + self.object.delete() + messages.success(request, _('The selected object has been deleted.')) + return redirect(success_url) + + class CustomerListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView): model = Customer template_name = 'pretixcontrol/organizers/customers.html' @@ -1969,7 +2084,7 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi ) def post(self, request, *args, **kwargs): - if request.POST.get('action') == 'pwreset': + if request.POST.get('action') == 'pwreset' and self.customer.provider_id is None: self.customer.log_action('pretix.customer.password.resetrequested', {}, user=self.request.user) ctx = self.customer.get_email_context() token = TokenGenerator().make_token(self.customer) diff --git a/src/pretix/locale/wordlist.txt b/src/pretix/locale/wordlist.txt index 9dbe8e307..394dfca85 100644 --- a/src/pretix/locale/wordlist.txt +++ b/src/pretix/locale/wordlist.txt @@ -112,6 +112,7 @@ Sofort SOFORT Somecity SSL +SSO STARTTLS Su subevent diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 71380952c..8d6de2ac9 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -39,6 +39,7 @@ from decimal import Decimal from django.conf import settings from django.contrib import messages from django.core.exceptions import ValidationError +from django.core.signing import BadSignature, loads from django.core.validators import EmailValidator from django.db.models import F, Q from django.http import HttpResponseNotAllowed, JsonResponse @@ -261,7 +262,7 @@ class CustomerStep(CartMixin, TemplateFlowStep): p.item.require_membership or (p.variation and p.variation.require_membership) for p in self.positions - ) + ) and self.request.event.settings.customer_accounts_native @cached_property def guest_allowed(self): @@ -290,6 +291,22 @@ class CustomerStep(CartMixin, TemplateFlowStep): field.widget.is_required = False return f + def _handle_sso_login(self): + value = self.request.POST['login-sso-data'] + try: + data = loads(value, salt=f'customer_sso_popup_{self.request.organizer.pk}', max_age=120) + except BadSignature: + return False + try: + customer = self.request.organizer.customers.get(pk=data['customer'], provider__isnull=False) + except Customer.DoesNotExist: + return False + self.cart_session['customer_mode'] = 'login' + self.cart_session['customer'] = customer.pk + self.cart_session['customer_cart_tied_to_login'] = True + customer_login(self.request, customer) + return True + def post(self, request): self.request = request @@ -301,7 +318,12 @@ class CustomerStep(CartMixin, TemplateFlowStep): self.cart_session['customer'] = request.customer.pk self.cart_session['customer_cart_tied_to_login'] = True return redirect(self.get_next_url(request)) - elif self.login_form.is_valid(): + elif "login-sso-data" in self.request.POST: + if not self._handle_sso_login(): + messages.error(request, _('We failed to process your authentication request, please try again.')) + return self.render() + return redirect(self.get_next_url(request)) + elif self.event.settings.customer_accounts_native and self.login_form.is_valid(): customer_login(self.request, self.login_form.get_customer()) self.cart_session['customer_mode'] = 'login' self.cart_session['customer'] = self.login_form.get_customer().pk @@ -309,7 +331,7 @@ class CustomerStep(CartMixin, TemplateFlowStep): return redirect(self.get_next_url(request)) else: return self.render() - elif request.POST.get("customer_mode") == 'register': + elif request.POST.get("customer_mode") == 'register' and self.signup_allowed: if self.register_form.is_valid(): customer = self.register_form.create() self.cart_session['customer_mode'] = 'login' diff --git a/src/pretix/presale/forms/customer.py b/src/pretix/presale/forms/customer.py index cdb83ea3f..f3db3e8d8 100644 --- a/src/pretix/presale/forms/customer.py +++ b/src/pretix/presale/forms/customer.py @@ -32,6 +32,7 @@ from django.contrib.auth.password_validation import ( from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core import signing from django.utils.functional import cached_property +from django.utils.html import escape from django.utils.translation import gettext_lazy as _ from phonenumber_field.formfields import PhoneNumberField @@ -83,7 +84,7 @@ class AuthenticationForm(forms.Form): if email is not None and password: try: - u = self.request.organizer.customers.get(email=email.lower()) + u = self.request.organizer.customers.get(email=email.lower(), provider__isnull=True) except Customer.DoesNotExist: # Run the default password hasher once to reduce the timing # difference between an existing and a nonexistent user (django #20760). @@ -333,7 +334,7 @@ class ResetPasswordForm(forms.Form): if 'email' not in self.cleaned_data: return try: - self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'].lower()) + self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'].lower(), provider__isnull=True) return self.customer.email except Customer.DoesNotExist: # Yup, this is an information leak. But it prevents dozens of support requests – and even if we didn't @@ -473,6 +474,14 @@ class ChangeInfoForm(forms.ModelForm): widget=WrappedPhoneNumberPrefixWidget() ) + if self.instance.provider_id is not None: + self.fields['email'].disabled = True + self.fields['email'].help_text = _( + 'To change your email address, change it in your {provider} account and then log out and log in ' + 'again.' + ).format(provider=escape(self.instance.provider.name)) + del self.fields['password_current'] + def clean_password_current(self): old_pw = self.cleaned_data.get('password_current') @@ -501,13 +510,13 @@ class ChangeInfoForm(forms.ModelForm): email = self.cleaned_data.get('email') password_current = self.cleaned_data.get('password_current') - if email != self.instance.email and not password_current: + if email != self.instance.email and not password_current and self.instance.provider_id is None: raise forms.ValidationError( self.error_messages['pw_current_wrong'], code='pw_current_wrong', ) - if email is not None: + if email is not None and self.instance.provider_id is not None: try: self.request.organizer.customers.exclude(pk=self.instance.pk).get(email=email.lower()) except Customer.DoesNotExist: diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html b/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html index ded0c7576..5398eb588 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html @@ -50,16 +50,31 @@ and access them at any time. {% endblocktrans %}

- {% bootstrap_form login_form layout="checkout" %} + {% if request.organizer.settings.customer_accounts_native %} + {% bootstrap_form login_form layout="checkout" %} + + {% endif %}
-
- - {% trans "Reset password" %} - +
+ {% for provider in request.organizer.sso_providers.all %} + {% if provider.is_active %} + + {{ provider.button_label }} + + {% endif %} + {% endfor %}
+ {% 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 %}
+