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