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 @@