mirror of
https://github.com/pretix/pretix.git
synced 2026-05-07 15:34:02 +00:00
OpenID Connect RP support for customer accounts
This commit is contained in:
committed by
Raphael Michel
parent
e102a590ab
commit
7f5518dbf6
@@ -39,6 +39,7 @@ from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.signing import BadSignature, loads
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models import F, Q
|
||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||
@@ -261,7 +262,7 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
p.item.require_membership or
|
||||
(p.variation and p.variation.require_membership)
|
||||
for p in self.positions
|
||||
)
|
||||
) and self.request.event.settings.customer_accounts_native
|
||||
|
||||
@cached_property
|
||||
def guest_allowed(self):
|
||||
@@ -290,6 +291,22 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
field.widget.is_required = False
|
||||
return f
|
||||
|
||||
def _handle_sso_login(self):
|
||||
value = self.request.POST['login-sso-data']
|
||||
try:
|
||||
data = loads(value, salt=f'customer_sso_popup_{self.request.organizer.pk}', max_age=120)
|
||||
except BadSignature:
|
||||
return False
|
||||
try:
|
||||
customer = self.request.organizer.customers.get(pk=data['customer'], provider__isnull=False)
|
||||
except Customer.DoesNotExist:
|
||||
return False
|
||||
self.cart_session['customer_mode'] = 'login'
|
||||
self.cart_session['customer'] = customer.pk
|
||||
self.cart_session['customer_cart_tied_to_login'] = True
|
||||
customer_login(self.request, customer)
|
||||
return True
|
||||
|
||||
def post(self, request):
|
||||
self.request = request
|
||||
|
||||
@@ -301,7 +318,12 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
self.cart_session['customer'] = request.customer.pk
|
||||
self.cart_session['customer_cart_tied_to_login'] = True
|
||||
return redirect(self.get_next_url(request))
|
||||
elif self.login_form.is_valid():
|
||||
elif "login-sso-data" in self.request.POST:
|
||||
if not self._handle_sso_login():
|
||||
messages.error(request, _('We failed to process your authentication request, please try again.'))
|
||||
return self.render()
|
||||
return redirect(self.get_next_url(request))
|
||||
elif self.event.settings.customer_accounts_native and self.login_form.is_valid():
|
||||
customer_login(self.request, self.login_form.get_customer())
|
||||
self.cart_session['customer_mode'] = 'login'
|
||||
self.cart_session['customer'] = self.login_form.get_customer().pk
|
||||
@@ -309,7 +331,7 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
return redirect(self.get_next_url(request))
|
||||
else:
|
||||
return self.render()
|
||||
elif request.POST.get("customer_mode") == 'register':
|
||||
elif request.POST.get("customer_mode") == 'register' and self.signup_allowed:
|
||||
if self.register_form.is_valid():
|
||||
customer = self.register_form.create()
|
||||
self.cart_session['customer_mode'] = 'login'
|
||||
|
||||
@@ -32,6 +32,7 @@ from django.contrib.auth.password_validation import (
|
||||
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||
from django.core import signing
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
|
||||
@@ -83,7 +84,7 @@ class AuthenticationForm(forms.Form):
|
||||
|
||||
if email is not None and password:
|
||||
try:
|
||||
u = self.request.organizer.customers.get(email=email.lower())
|
||||
u = self.request.organizer.customers.get(email=email.lower(), provider__isnull=True)
|
||||
except Customer.DoesNotExist:
|
||||
# Run the default password hasher once to reduce the timing
|
||||
# difference between an existing and a nonexistent user (django #20760).
|
||||
@@ -333,7 +334,7 @@ class ResetPasswordForm(forms.Form):
|
||||
if 'email' not in self.cleaned_data:
|
||||
return
|
||||
try:
|
||||
self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'].lower())
|
||||
self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'].lower(), provider__isnull=True)
|
||||
return self.customer.email
|
||||
except Customer.DoesNotExist:
|
||||
# Yup, this is an information leak. But it prevents dozens of support requests – and even if we didn't
|
||||
@@ -473,6 +474,14 @@ class ChangeInfoForm(forms.ModelForm):
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
|
||||
if self.instance.provider_id is not None:
|
||||
self.fields['email'].disabled = True
|
||||
self.fields['email'].help_text = _(
|
||||
'To change your email address, change it in your {provider} account and then log out and log in '
|
||||
'again.'
|
||||
).format(provider=escape(self.instance.provider.name))
|
||||
del self.fields['password_current']
|
||||
|
||||
def clean_password_current(self):
|
||||
old_pw = self.cleaned_data.get('password_current')
|
||||
|
||||
@@ -501,13 +510,13 @@ class ChangeInfoForm(forms.ModelForm):
|
||||
email = self.cleaned_data.get('email')
|
||||
password_current = self.cleaned_data.get('password_current')
|
||||
|
||||
if email != self.instance.email and not password_current:
|
||||
if email != self.instance.email and not password_current and self.instance.provider_id is None:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
)
|
||||
|
||||
if email is not None:
|
||||
if email is not None and self.instance.provider_id is not None:
|
||||
try:
|
||||
self.request.organizer.customers.exclude(pk=self.instance.pk).get(email=email.lower())
|
||||
except Customer.DoesNotExist:
|
||||
|
||||
@@ -50,16 +50,31 @@
|
||||
and access them at any time.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_form login_form layout="checkout" %}
|
||||
{% if request.organizer.settings.customer_accounts_native %}
|
||||
{% bootstrap_form login_form layout="checkout" %}
|
||||
<div class="row">
|
||||
<div class="col-md-offset-3 col-md-9">
|
||||
<a
|
||||
href="{% abseventurl request.organizer "presale:organizer.customer.resetpw" %}"
|
||||
target="_blank">
|
||||
{% trans "Reset password" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-offset-3 col-md-9">
|
||||
<a
|
||||
href="{% abseventurl request.organizer "presale:organizer.customer.resetpw" %}"
|
||||
target="_blank">
|
||||
{% trans "Reset password" %}
|
||||
</a>
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% for provider in request.organizer.sso_providers.all %}
|
||||
{% if provider.is_active %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.login" provider=provider.pk %}?next={% if request.event_domain %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ request.get_full_path|urlencode }}"
|
||||
class="btn btn-primary btn-lg btn-block" data-open-in-popup-window>
|
||||
{{ provider.button_label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="login-sso-data" id="login_sso_data">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/widget/floatformat.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/questions.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/sso.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cookieconsent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cart.js" %}"></script>
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
{% load escapejson %}
|
||||
<div id="ajaxerr">
|
||||
</div>
|
||||
<div id="popupmodal" hidden aria-live="polite">
|
||||
<div class="modal-card">
|
||||
<div class="modal-card-icon">
|
||||
<i class="fa fa-window-restore big-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="modal-card-content">
|
||||
<div>
|
||||
<h3>
|
||||
{% trans "We've started the requested process in a new window." %}
|
||||
</h3>
|
||||
<p class="text">
|
||||
{% trans "If you do not see the new window, we can help you launch it again." %}
|
||||
</p>
|
||||
<p>
|
||||
<a href="" data-open-in-popup-window class="btn btn-default">
|
||||
<span class="fa fa-external-link-square"></span>
|
||||
{% trans "Open window again" %}
|
||||
</a>
|
||||
</p>
|
||||
<p class="text">
|
||||
{% trans "Once the process in the new window has been completed, you can continue here." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loadingmodal" hidden aria-live="polite">
|
||||
<div class="modal-card">
|
||||
<div class="modal-card-icon">
|
||||
|
||||
@@ -14,24 +14,38 @@
|
||||
</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group buttons">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||
{% trans "Log in" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.register" %}">
|
||||
{% trans "Create account" %}
|
||||
</a>
|
||||
{% if request.organizer.settings.customer_accounts_native %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group buttons">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||
{% trans "Log in" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.resetpw" %}">
|
||||
{% trans "Reset password" %}
|
||||
{% endif %}
|
||||
{% for provider in request.organizer.sso_providers.all %}
|
||||
{% if provider.is_active %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.login" provider=provider.pk %}?{{ request.META.QUERY_STRING }}"
|
||||
class="btn btn-primary btn-lg btn-block">
|
||||
{{ provider.button_label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if request.organizer.settings.customer_accounts_native %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block"
|
||||
href="{% eventurl request.organizer "presale:organizer.customer.register" %}">
|
||||
{% trans "Create account" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-link btn-block"
|
||||
href="{% eventurl request.organizer "presale:organizer.customer.resetpw" %}">
|
||||
{% trans "Reset password" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,14 +19,18 @@
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Customer ID" %}</dt>
|
||||
<dd>#{{ customer.identifier }}</dd>
|
||||
{% if customer.provider %}
|
||||
<dt>{% trans "Login method" %}</dt>
|
||||
<dd>{{ customer.provider.name }}</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "E-mail" %}</dt>
|
||||
<dd>{{ customer.email }}
|
||||
</dd>
|
||||
<dt>{% trans "Name" %}</dt>
|
||||
<dd>{{ customer.name }}</dd>
|
||||
{% if customer.phone %}
|
||||
<dt>{% trans "Phone" %}</dt>
|
||||
<dd>{{ customer.phone }}</dd>
|
||||
<dt>{% trans "Phone" %}</dt>
|
||||
<dd>{{ customer.phone }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
<div class="text-right">
|
||||
@@ -34,10 +38,12 @@
|
||||
class="btn btn-default">
|
||||
{% trans "Change account information" %}
|
||||
</a>
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}"
|
||||
class="btn btn-default">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
{% if not customer.provider %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}"
|
||||
class="btn btn-default">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
31
src/pretix/presale/templates/pretixpresale/postmessage.html
Normal file
31
src/pretix/presale/templates/pretixpresale/postmessage.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
|
||||
{% compress css %}
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixpresale/scss/waiting.scss" %}"/>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/postmessage.js" %}"></script>
|
||||
{% endcompress %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<i class="fa fa-cog big-rotating-icon" aria-hidden="true"></i>
|
||||
|
||||
<h1>{% trans "We are processing your request …" %}</h1>
|
||||
|
||||
{{ message|json_script:"postmessage" }}
|
||||
<script type="text/plain" id="origin">{{ origin }}</script>
|
||||
|
||||
<p>
|
||||
{% trans "If this takes longer than a few minutes, please contact us." %}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,6 +177,8 @@ organizer_patterns = [
|
||||
re_path(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
|
||||
name='organizer.widget.productlist'),
|
||||
re_path(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
|
||||
re_path(r'^account/login/(?P<provider>[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'),
|
||||
re_path(r'^account/login/(?P<provider>[0-9]+)/return$', pretix.presale.views.customer.SSOLoginReturnView.as_view(), name='organizer.customer.login.return'),
|
||||
re_path(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
|
||||
re_path(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'),
|
||||
re_path(r'^account/register$', pretix.presale.views.customer.RegistrationView.as_view(), name='organizer.customer.register'),
|
||||
|
||||
@@ -38,6 +38,7 @@ from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.middleware.csrf import rotate_token
|
||||
from django.shortcuts import redirect
|
||||
@@ -87,6 +88,7 @@ def get_customer(request):
|
||||
with scope(organizer=request.organizer):
|
||||
try:
|
||||
customer = request.organizer.customers.get(
|
||||
Q(provider__isnull=True) | Q(provider__is_active=True),
|
||||
is_active=True, is_verified=True,
|
||||
pk=session[session_key]
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import hashlib
|
||||
from importlib import import_module
|
||||
from urllib.parse import (
|
||||
parse_qs, quote, urlencode, urljoin, urlparse, urlsplit, urlunparse,
|
||||
@@ -26,11 +27,13 @@ from urllib.parse import (
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.signing import BadSignature, dumps, loads
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
@@ -40,8 +43,12 @@ from django.views.decorators.csrf import csrf_protect
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.generic import DeleteView, FormView, ListView, View
|
||||
|
||||
from pretix.base.customersso.oidc import (
|
||||
oidc_authorize_url, oidc_validate_authorization,
|
||||
)
|
||||
from pretix.base.models import Customer, InvoiceAddress, Order, OrderPosition
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||
from pretix.presale.forms.customer import (
|
||||
@@ -58,9 +65,9 @@ SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
class RedirectBackMixin:
|
||||
redirect_field_name = 'next'
|
||||
|
||||
def get_redirect_url(self):
|
||||
def get_redirect_url(self, redirect_to=None):
|
||||
"""Return the user-originating redirect URL if it's safe."""
|
||||
redirect_to = self.request.POST.get(
|
||||
redirect_to = redirect_to or self.request.POST.get(
|
||||
self.redirect_field_name,
|
||||
self.request.GET.get(self.redirect_field_name, '')
|
||||
)
|
||||
@@ -101,6 +108,11 @@ class LoginView(RedirectBackMixin, FormView):
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts_native:
|
||||
raise Http404('Feature not enabled')
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['request'] = self.request
|
||||
@@ -190,6 +202,8 @@ class RegistrationView(RedirectBackMixin, FormView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts:
|
||||
raise Http404('Feature not enabled')
|
||||
if not request.organizer.settings.customer_accounts_native:
|
||||
raise Http404('Feature not enabled')
|
||||
if self.redirect_authenticated_user and self.request.customer:
|
||||
redirect_to = self.get_success_url()
|
||||
if redirect_to == self.request.path:
|
||||
@@ -231,8 +245,10 @@ class SetPasswordView(FormView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts:
|
||||
raise Http404('Feature not enabled')
|
||||
if not request.organizer.settings.customer_accounts_native:
|
||||
raise Http404('Feature not enabled')
|
||||
try:
|
||||
self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id'))
|
||||
self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id'), provider__isnull=True)
|
||||
except Customer.DoesNotExist:
|
||||
messages.error(request, _('You clicked an invalid link.'))
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
@@ -272,6 +288,8 @@ class ResetPasswordView(FormView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts:
|
||||
raise Http404('Feature not enabled')
|
||||
if not request.organizer.settings.customer_accounts_native:
|
||||
raise Http404('Feature not enabled')
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -425,6 +443,8 @@ class ChangePasswordView(CustomerRequiredMixin, FormView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts:
|
||||
raise Http404('Feature not enabled')
|
||||
if self.request.customer.provider_id:
|
||||
raise Http404('Feature not enabled')
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -464,7 +484,7 @@ class ChangeInformationView(CustomerRequiredMixin, FormView):
|
||||
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.cleaned_data['email'] != self.initial_email:
|
||||
if form.cleaned_data['email'] != self.initial_email and not self.request.customer.provider:
|
||||
new_email = form.cleaned_data['email']
|
||||
form.cleaned_data['email'] = form.instance.email = self.initial_email
|
||||
ctx = form.instance.get_email_context()
|
||||
@@ -493,6 +513,7 @@ class ChangeInformationView(CustomerRequiredMixin, FormView):
|
||||
with transaction.atomic():
|
||||
form.save()
|
||||
d = dict(form.cleaned_data)
|
||||
print(d)
|
||||
del d['email']
|
||||
self.request.customer.log_action('pretix.customer.changed', d)
|
||||
|
||||
@@ -520,7 +541,7 @@ class ConfirmChangeView(View):
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
try:
|
||||
customer = request.organizer.customers.get(pk=data.get('customer'))
|
||||
customer = request.organizer.customers.get(pk=data.get('customer'), provider__isnull=True)
|
||||
except Customer.DoesNotExist:
|
||||
messages.error(request, _('You clicked an invalid link.'))
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
@@ -541,3 +562,286 @@ class ConfirmChangeView(View):
|
||||
|
||||
def get_success_url(self):
|
||||
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||
|
||||
|
||||
class SSOLoginView(RedirectBackMixin, View):
|
||||
"""
|
||||
Start logging in with a SSO provider.
|
||||
"""
|
||||
form_class = AuthenticationForm
|
||||
redirect_authenticated_user = True
|
||||
|
||||
@method_decorator(sensitive_post_parameters())
|
||||
@method_decorator(csrf_protect)
|
||||
@method_decorator(never_cache)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts:
|
||||
raise Http404('Feature not enabled')
|
||||
if self.redirect_authenticated_user and self.request.customer:
|
||||
redirect_to = self.get_success_url()
|
||||
if redirect_to == self.request.path:
|
||||
raise ValueError(
|
||||
"Redirection loop for authenticated user detected. Check that "
|
||||
"your LOGIN_REDIRECT_URL doesn't point to a login page."
|
||||
)
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def provider(self):
|
||||
return get_object_or_404(self.request.organizer.sso_providers.filter(is_active=True), pk=self.kwargs['provider'])
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
next_url = request.GET.get('next') or ''
|
||||
popup_origin = request.GET.get('popup_origin', '')
|
||||
if popup_origin:
|
||||
popup_origin_parsed = urlparse(popup_origin)
|
||||
untrusted = (
|
||||
popup_origin_parsed.hostname != urlparse(settings.SITE_URL).hostname and
|
||||
not KnownDomain.objects.filter(domainname=popup_origin_parsed.hostname, organizer=self.request.organizer.pk).exists()
|
||||
)
|
||||
if untrusted:
|
||||
# Do not accept faked origins
|
||||
popup_origin = None
|
||||
|
||||
nonce = get_random_string(32)
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_nonce'] = nonce
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin'] = popup_origin
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'] = self.request.GET.get("request_cross_domain_customer_auth") == "true"
|
||||
redirect_uri = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={
|
||||
'provider': self.provider.pk
|
||||
})
|
||||
|
||||
if self.provider.method == "oidc":
|
||||
return redirect(oidc_authorize_url(self.provider, f'{nonce}#{next_url}', redirect_uri))
|
||||
else:
|
||||
raise Http404("Unknown SSO method.")
|
||||
|
||||
def get_success_url(self):
|
||||
url = self.get_redirect_url()
|
||||
|
||||
if not url:
|
||||
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||
return url
|
||||
|
||||
|
||||
class SSOLoginReturnView(RedirectBackMixin, View):
|
||||
"""
|
||||
Start logging in with a SSO provider.
|
||||
"""
|
||||
form_class = AuthenticationForm
|
||||
redirect_authenticated_user = True
|
||||
|
||||
@method_decorator(sensitive_post_parameters())
|
||||
@method_decorator(csrf_protect)
|
||||
@method_decorator(never_cache)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.organizer.settings.customer_accounts:
|
||||
raise Http404('Feature not enabled')
|
||||
if self.redirect_authenticated_user and self.request.customer:
|
||||
redirect_to = self.get_success_url()
|
||||
if redirect_to == self.request.path:
|
||||
raise ValueError(
|
||||
"Redirection loop for authenticated user detected. Check that "
|
||||
"your LOGIN_REDIRECT_URL doesn't point to a login page."
|
||||
)
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
r = super().dispatch(request, *args, **kwargs)
|
||||
request.session.pop(f'pretix_customerauth_{self.provider.pk}_nonce', None)
|
||||
request.session.pop(f'pretix_customerauth_{self.provider.pk}_popup_origin', None)
|
||||
request.session.pop(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested', None)
|
||||
return r
|
||||
|
||||
@cached_property
|
||||
def provider(self):
|
||||
return get_object_or_404(self.request.organizer.sso_providers.filter(is_active=True), pk=self.kwargs['provider'])
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
redirect_to = None
|
||||
popup_origin = None
|
||||
|
||||
if request.session.get(f'pretix_customerauth_{self.provider.pk}_popup_origin'):
|
||||
popup_origin = request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin']
|
||||
|
||||
if self.provider.method == "oidc":
|
||||
if not request.GET.get('state'):
|
||||
return self._fail(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='state parameter missing',
|
||||
),
|
||||
popup_origin,
|
||||
)
|
||||
|
||||
nonce, redirect_to = request.GET['state'].split('#')
|
||||
|
||||
if nonce != request.session.get(f'pretix_customerauth_{self.provider.pk}_nonce'):
|
||||
return self._fail(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='invalid nonce',
|
||||
),
|
||||
popup_origin,
|
||||
)
|
||||
redirect_uri = build_absolute_uri(
|
||||
self.request.organizer, 'presale:organizer.customer.login.return',
|
||||
kwargs={
|
||||
'provider': self.provider.pk
|
||||
}
|
||||
)
|
||||
try:
|
||||
profile = oidc_validate_authorization(
|
||||
self.provider,
|
||||
request.GET.get('code'),
|
||||
redirect_uri,
|
||||
)
|
||||
except ValidationError as e:
|
||||
for msg in e:
|
||||
return self._fail(msg, popup_origin)
|
||||
else:
|
||||
raise Http404("Unknown SSO method.")
|
||||
|
||||
identifier = hashlib.sha256(
|
||||
profile['uid'].encode() + b'@' + str(self.provider.pk).encode()
|
||||
).hexdigest().upper()[:settings.ENTROPY['customer_identifier']]
|
||||
if "1" not in identifier and "0" not in identifier:
|
||||
# This is a hack to make sure the hash space does not overlap with the random identifiers generated by
|
||||
# Customer.assign_identifier()
|
||||
identifier = identifier[:4] + "1" + identifier[4:-1]
|
||||
|
||||
try:
|
||||
customer = self.request.organizer.customers.get(
|
||||
provider=self.provider,
|
||||
identifier=identifier,
|
||||
)
|
||||
except Customer.MultipleObjectsReturned:
|
||||
return self._fail(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='identifier not unique',
|
||||
),
|
||||
popup_origin,
|
||||
)
|
||||
except Customer.DoesNotExist:
|
||||
name_scheme = self.request.organizer.settings.name_scheme
|
||||
name_parts = {
|
||||
'_scheme': name_scheme,
|
||||
}
|
||||
scheme = PERSON_NAME_SCHEMES.get(name_scheme)
|
||||
for fname, label, size in scheme['fields']:
|
||||
if fname in profile:
|
||||
name_parts[fname] = profile[fname]
|
||||
if len(name_parts) == 1 and profile.get('name'):
|
||||
name_parts = {'_legacy': profile['name']}
|
||||
customer = Customer(
|
||||
organizer=self.request.organizer,
|
||||
identifier=identifier,
|
||||
external_identifier=profile['uid'],
|
||||
provider=self.provider,
|
||||
email=profile['email'],
|
||||
phone=profile.get('phone') or None,
|
||||
name_parts=name_parts,
|
||||
is_active=True,
|
||||
is_verified=True, # todo: always?
|
||||
locale=request.LANGUAGE_CODE,
|
||||
)
|
||||
try:
|
||||
customer.save(force_insert=True)
|
||||
except IntegrityError:
|
||||
# This might either be a race condition or the email address is taken
|
||||
# by a different customer account
|
||||
try:
|
||||
customer = self.request.organizer.customers.get(
|
||||
provider=self.provider,
|
||||
identifier=identifier,
|
||||
)
|
||||
except Customer.DoesNotExist:
|
||||
return self._fail(
|
||||
_('We were unable to use your login since the email address {email} is already used for a '
|
||||
'different account in this system.').format(email=profile['email']),
|
||||
popup_origin,
|
||||
)
|
||||
else:
|
||||
if customer.is_active and customer.email != profile['email']:
|
||||
customer.email = profile['email']
|
||||
try:
|
||||
customer.save(update_fields=['email'])
|
||||
except IntegrityError:
|
||||
return self._fail(
|
||||
_('We were unable to use your login since the email address {email} is already used for a '
|
||||
'different account in this system.').format(email=profile['email']),
|
||||
popup_origin,
|
||||
)
|
||||
customer.log_action('pretix.customer.changed', {
|
||||
'email': profile['email'],
|
||||
'_source': 'provider'
|
||||
})
|
||||
|
||||
if customer.external_identifier != profile['uid']:
|
||||
return self._fail(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='identifier not unique',
|
||||
),
|
||||
popup_origin,
|
||||
)
|
||||
|
||||
if not customer.is_active:
|
||||
self._fail(
|
||||
AuthenticationForm.error_messages['inactive'],
|
||||
popup_origin,
|
||||
)
|
||||
|
||||
if not customer.is_verified:
|
||||
return self._fail(
|
||||
AuthenticationForm.error_messages['unverified'],
|
||||
popup_origin
|
||||
)
|
||||
|
||||
if popup_origin:
|
||||
return render(self.request, 'pretixpresale/postmessage.html', {
|
||||
'message': {
|
||||
'__process': 'customer_sso_popup',
|
||||
'status': 'ok',
|
||||
'value': dumps({
|
||||
'customer': customer.pk,
|
||||
}, salt=f'customer_sso_popup_{self.request.organizer.pk}')
|
||||
},
|
||||
'origin': popup_origin,
|
||||
})
|
||||
else:
|
||||
customer_login(self.request, customer)
|
||||
return redirect(self.get_success_url(redirect_to))
|
||||
|
||||
def _fail(self, message, popup_origin):
|
||||
if not popup_origin:
|
||||
messages.error(
|
||||
self.request,
|
||||
message,
|
||||
)
|
||||
return redirect(eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}))
|
||||
else:
|
||||
return render(self.request, 'pretixpresale/postmessage.html', {
|
||||
'message': {
|
||||
'__process': 'customer_sso_popup',
|
||||
'status': 'error',
|
||||
'value': str(message)
|
||||
},
|
||||
'origin': popup_origin,
|
||||
})
|
||||
|
||||
def get_success_url(self, redirect_to=None):
|
||||
url = self.get_redirect_url(redirect_to)
|
||||
|
||||
if not url:
|
||||
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||
else:
|
||||
if self.request.session.get(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'):
|
||||
otpstore = SessionStore()
|
||||
otpstore[f'customer_cross_domain_auth_{self.request.organizer.pk}'] = self.request.session.session_key
|
||||
otpstore.set_expiry(60)
|
||||
otpstore.save(must_create=True)
|
||||
otp = otpstore.session_key
|
||||
|
||||
u = urlparse(url)
|
||||
qsl = parse_qs(u.query)
|
||||
qsl['cross_domain_customer_auth'] = otp
|
||||
url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment))
|
||||
|
||||
return url
|
||||
|
||||
Reference in New Issue
Block a user