forked from CGM_Public/pretix_original
OpenID Connect RP support for customer accounts
This commit is contained in:
committed by
Raphael Michel
parent
e102a590ab
commit
7f5518dbf6
@@ -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',
|
||||
|
||||
21
src/pretix/base/customersso/__init__.py
Normal file
21
src/pretix/base/customersso/__init__.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
207
src/pretix/base/customersso/oidc.py
Normal file
207
src/pretix/base/customersso/oidc.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
38
src/pretix/base/migrations/0219_auto_20220706_0913.py
Normal file
38
src/pretix/base/migrations/0219_auto_20220706_0913.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -27,10 +27,14 @@
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Customer ID" %}</dt>
|
||||
<dd>#{{ customer.identifier }}</dd>
|
||||
{% if customer.external_identifier %}
|
||||
<dt>{% trans "External identifier" %}</dt>
|
||||
<dd>{{ customer.external_identifier }}</dd>
|
||||
{% endif %}
|
||||
{% if customer.provider %}
|
||||
<dt>{% trans "SSO provider" %}</dt>
|
||||
<dd>{{ customer.provider.name }}</dd>
|
||||
{% endif %}
|
||||
{% if customer.external_identifier %}
|
||||
<dt>{% trans "External identifier" %}</dt>
|
||||
<dd>{{ customer.external_identifier }}</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "Status" %}</dt>
|
||||
<dd>
|
||||
{% if not customer.is_active %}
|
||||
@@ -44,7 +48,7 @@
|
||||
<dt>{% trans "E-mail" %}</dt>
|
||||
<dd>
|
||||
{{ customer.email|default_if_none:"" }}
|
||||
{% if customer.email %}
|
||||
{% if customer.email and not customer.provider %}
|
||||
<button type="submit" name="action" value="pwreset" class="btn btn-xs btn-default">
|
||||
{% trans "Send password reset link" %}
|
||||
</button>
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Customer accounts" %}</legend>
|
||||
{% 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" %}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<h1>{% trans "Delete SSO provider:" %} {{ provider.name }}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if is_allowed %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete this SSO provider?{% endblocktrans %}
|
||||
{% else %}
|
||||
<p>{% blocktrans %}This SSO provider cannot be deleted since it has already been used.{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:organizer.ssoproviders" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
{% if is_allowed %}
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
{% if provider %}
|
||||
<h1>{% trans "SSO provider:" %} {{ provider.name }}</h1>
|
||||
{% else %}
|
||||
<h1>{% trans "Create a new SSO provider" %}</h1>
|
||||
{% endif %}
|
||||
<form class="form-horizontal" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
{% if redirect_uri %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="redirect_uri">
|
||||
{% trans "Redirection URL" context "sso" %}
|
||||
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" value="{{ redirect_uri }}"
|
||||
class="form-control"
|
||||
disabled id="redirect_uri">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "SSO providers" %}{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>{% trans "SSO providers" %}</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can connect existing Single-Sign-On (SSO) providers to allow your customers to log in using your own
|
||||
account system.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<a href="{% url "control:organizer.ssoprovider.add" organizer=request.organizer.slug %}" class="btn btn-default">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new SSO provider" %}
|
||||
</a>
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in providers %}
|
||||
<tr>
|
||||
<td><strong>
|
||||
<a href="{% url "control:organizer.ssoprovider.edit" organizer=request.organizer.slug provider=p.id %}">
|
||||
{% if not p.is_active %}<del>{% endif %}
|
||||
{{ p.name }}
|
||||
{% if not p.is_active %}</del>{% endif %}
|
||||
</a>
|
||||
</strong></td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:organizer.ssoprovider.edit" organizer=request.organizer.slug provider=p.id %}"
|
||||
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:organizer.ssoprovider.delete" organizer=request.organizer.slug provider=p.id %}"
|
||||
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -133,6 +133,13 @@ urlpatterns = [
|
||||
name='organizer.membershiptype.edit'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptype/(?P<type>[^/]+)/delete$', organizer.MembershipTypeDeleteView.as_view(),
|
||||
name='organizer.membershiptype.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoproviders$', organizer.SSOProviderListView.as_view(), name='organizer.ssoproviders'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoprovider/add$', organizer.SSOProviderCreateView.as_view(),
|
||||
name='organizer.ssoprovider.add'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoprovider/(?P<provider>[^/]+)/edit$', organizer.SSOProviderUpdateView.as_view(),
|
||||
name='organizer.ssoprovider.edit'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoprovider/(?P<provider>[^/]+)/delete$', organizer.SSOProviderDeleteView.as_view(),
|
||||
name='organizer.ssoprovider.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/add$',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -112,6 +112,7 @@ Sofort
|
||||
SOFORT
|
||||
Somecity
|
||||
SSL
|
||||
SSO
|
||||
STARTTLS
|
||||
Su
|
||||
subevent
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -50,16 +50,31 @@
|
||||
and access them at any time.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_form login_form layout="checkout" %}
|
||||
{% if request.organizer.settings.customer_accounts_native %}
|
||||
{% bootstrap_form login_form layout="checkout" %}
|
||||
<div class="row">
|
||||
<div class="col-md-offset-3 col-md-9">
|
||||
<a
|
||||
href="{% abseventurl request.organizer "presale:organizer.customer.resetpw" %}"
|
||||
target="_blank">
|
||||
{% trans "Reset password" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-offset-3 col-md-9">
|
||||
<a
|
||||
href="{% abseventurl request.organizer "presale:organizer.customer.resetpw" %}"
|
||||
target="_blank">
|
||||
{% trans "Reset password" %}
|
||||
</a>
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% for provider in request.organizer.sso_providers.all %}
|
||||
{% if provider.is_active %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.login" provider=provider.pk %}?next={% if request.event_domain %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ request.get_full_path|urlencode }}"
|
||||
class="btn btn-primary btn-lg btn-block" data-open-in-popup-window>
|
||||
{{ provider.button_label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="login-sso-data" id="login_sso_data">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/widget/floatformat.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/questions.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/sso.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cookieconsent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cart.js" %}"></script>
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
{% load escapejson %}
|
||||
<div id="ajaxerr">
|
||||
</div>
|
||||
<div id="popupmodal" hidden aria-live="polite">
|
||||
<div class="modal-card">
|
||||
<div class="modal-card-icon">
|
||||
<i class="fa fa-window-restore big-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="modal-card-content">
|
||||
<div>
|
||||
<h3>
|
||||
{% trans "We've started the requested process in a new window." %}
|
||||
</h3>
|
||||
<p class="text">
|
||||
{% trans "If you do not see the new window, we can help you launch it again." %}
|
||||
</p>
|
||||
<p>
|
||||
<a href="" data-open-in-popup-window class="btn btn-default">
|
||||
<span class="fa fa-external-link-square"></span>
|
||||
{% trans "Open window again" %}
|
||||
</a>
|
||||
</p>
|
||||
<p class="text">
|
||||
{% trans "Once the process in the new window has been completed, you can continue here." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loadingmodal" hidden aria-live="polite">
|
||||
<div class="modal-card">
|
||||
<div class="modal-card-icon">
|
||||
|
||||
@@ -14,24 +14,38 @@
|
||||
</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group buttons">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||
{% trans "Log in" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.register" %}">
|
||||
{% trans "Create account" %}
|
||||
</a>
|
||||
{% if request.organizer.settings.customer_accounts_native %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group buttons">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||
{% trans "Log in" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.resetpw" %}">
|
||||
{% trans "Reset password" %}
|
||||
{% endif %}
|
||||
{% for provider in request.organizer.sso_providers.all %}
|
||||
{% if provider.is_active %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.login" provider=provider.pk %}?{{ request.META.QUERY_STRING }}"
|
||||
class="btn btn-primary btn-lg btn-block">
|
||||
{{ provider.button_label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if request.organizer.settings.customer_accounts_native %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block"
|
||||
href="{% eventurl request.organizer "presale:organizer.customer.register" %}">
|
||||
{% trans "Create account" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block"
|
||||
href="{% eventurl request.organizer "presale:organizer.customer.resetpw" %}">
|
||||
{% trans "Reset password" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,14 +19,18 @@
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Customer ID" %}</dt>
|
||||
<dd>#{{ customer.identifier }}</dd>
|
||||
{% if customer.provider %}
|
||||
<dt>{% trans "Login method" %}</dt>
|
||||
<dd>{{ customer.provider.name }}</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "E-mail" %}</dt>
|
||||
<dd>{{ customer.email }}
|
||||
</dd>
|
||||
<dt>{% trans "Name" %}</dt>
|
||||
<dd>{{ customer.name }}</dd>
|
||||
{% if customer.phone %}
|
||||
<dt>{% trans "Phone" %}</dt>
|
||||
<dd>{{ customer.phone }}</dd>
|
||||
<dt>{% trans "Phone" %}</dt>
|
||||
<dd>{{ customer.phone }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
<div class="text-right">
|
||||
@@ -34,10 +38,12 @@
|
||||
class="btn btn-default">
|
||||
{% trans "Change account information" %}
|
||||
</a>
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}"
|
||||
class="btn btn-default">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
{% if not customer.provider %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}"
|
||||
class="btn btn-default">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
31
src/pretix/presale/templates/pretixpresale/postmessage.html
Normal file
31
src/pretix/presale/templates/pretixpresale/postmessage.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
|
||||
{% compress css %}
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixpresale/scss/waiting.scss" %}"/>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/postmessage.js" %}"></script>
|
||||
{% endcompress %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<i class="fa fa-cog big-rotating-icon" aria-hidden="true"></i>
|
||||
|
||||
<h1>{% trans "We are processing your request …" %}</h1>
|
||||
|
||||
{{ message|json_script:"postmessage" }}
|
||||
<script type="text/plain" id="origin">{{ origin }}</script>
|
||||
|
||||
<p>
|
||||
{% trans "If this takes longer than a few minutes, please contact us." %}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<provider>[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'),
|
||||
re_path(r'^account/login/(?P<provider>[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'),
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
|
||||
10
src/pretix/static/pretixpresale/js/postmessage.js
Normal file
10
src/pretix/static/pretixpresale/js/postmessage.js
Normal file
@@ -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())
|
||||
})
|
||||
51
src/pretix/static/pretixpresale/js/ui/sso.js
Normal file
51
src/pretix/static/pretixpresale/js/ui/sso.js
Normal file
@@ -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);
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
331
src/tests/base/test_customer_oidc_rp.py
Normal file
331
src/tests/base/test_customer_oidc_rp.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user