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

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