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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user