OpenID Connect RP support for customer accounts

This commit is contained in:
Raphael Michel
2022-07-11 12:45:51 +02:00
committed by Raphael Michel
parent e102a590ab
commit 7f5518dbf6
39 changed files with 1943 additions and 55 deletions

View File

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

View 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/>.
#

View 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

View 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'),
),
]

View File

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

View File

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

View File

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

View File

@@ -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.'),

View File

@@ -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'),

View File

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

View File

@@ -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" %}

View File

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

View File

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

View File

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

View File

@@ -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$',

View File

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

View File

@@ -112,6 +112,7 @@ Sofort
SOFORT
Somecity
SSL
SSO
STARTTLS
Su
subevent

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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'),

View File

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

View File

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

View 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())
})

View 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);
})

View File

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

View File

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

View 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")

View File

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

View File

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

View File

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

View File

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

View File

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