diff --git a/src/pretix/base/customersso/oidc.py b/src/pretix/base/customersso/oidc.py index 5b93b578e..9ad8c383c 100644 --- a/src/pretix/base/customersso/oidc.py +++ b/src/pretix/base/customersso/oidc.py @@ -19,17 +19,34 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import base64 +import hashlib import logging +import time +from datetime import datetime from urllib.parse import urlencode, urljoin +import jwt import requests +from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key +from cryptography.hazmat.primitives.serialization import ( + Encoding, NoEncryption, PrivateFormat, PublicFormat, +) from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from requests import RequestException +from pretix.multidomain.urlreverse import build_absolute_uri + logger = logging.getLogger(__name__) +""" +This module contains utilities for implementing OpenID Connect for customer authentication both as a receiving party (RP) +as well as an OpenID Provider (OP). +""" + + def _urljoin(base, path): if not base.endswith("/"): base += "/" @@ -205,3 +222,74 @@ def oidc_validate_authorization(provider, code, redirect_uri): ) return profile + + +def _hash_scheme(value): + # As described in https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken + digest = hashlib.sha256(value.encode()).digest() + digest_truncated = digest[:(len(digest) // 2)] + return base64.urlsafe_b64encode(digest_truncated).decode().rstrip("=") + + +def customer_claims(customer, scope): + scope = scope.split(' ') + claims = { + 'sub': customer.identifier, + 'locale': customer.locale, + } + if 'profile' in scope: + if customer.name: + claims['name'] = customer.name + if 'given_name' in customer.name_parts: + claims['given_name'] = customer.name_parts['given_name'] + if 'family_name' in customer.name_parts: + claims['family_name'] = customer.name_parts['family_name'] + if 'middle_name' in customer.name_parts: + claims['middle_name'] = customer.name_parts['middle_name'] + if 'calling_name' in customer.name_parts: + claims['nickname'] = customer.name_parts['calling_name'] + if 'email' in scope and customer.email: + claims['email'] = customer.email + claims['email_verified'] = customer.is_verified + if 'phone' in scope and customer.phone: + claims['phone_number'] = customer.phone.as_international + return claims + + +def _get_or_create_server_keypair(organizer): + if not organizer.settings.sso_server_signing_key_rsa256_private: + privkey = generate_private_key(key_size=4096, public_exponent=65537) + pubkey = privkey.public_key() + organizer.settings.sso_server_signing_key_rsa256_private = privkey.private_bytes( + Encoding.PEM, PrivateFormat.PKCS8, NoEncryption() + ).decode() + organizer.settings.sso_server_signing_key_rsa256_public = pubkey.public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo + ).decode() + return organizer.settings.sso_server_signing_key_rsa256_private, organizer.settings.sso_server_signing_key_rsa256_public + + +def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None): + payload = { + 'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'), + 'aud': client.client_id, + 'exp': int(expires.timestamp()), + 'iat': int(time.time()), + 'auth_time': auth_time, + **customer_claims(customer, client.evaluated_scope(scope) if scope_claims else ''), + } + if nonce: + payload['nonce'] = nonce + if with_code: + payload['c_hash'] = _hash_scheme(with_code) + if with_access_token: + payload['at_hash'] = _hash_scheme(with_access_token) + privkey, pubkey = _get_or_create_server_keypair(client.organizer) + return jwt.encode( + payload, + privkey, + headers={ + "kid": hashlib.sha256(pubkey.encode()).hexdigest()[:16] + }, + algorithm="RS256", + ) diff --git a/src/pretix/base/migrations/0220_auto_20220811_1002.py b/src/pretix/base/migrations/0220_auto_20220811_1002.py new file mode 100644 index 000000000..62821f6e7 --- /dev/null +++ b/src/pretix/base/migrations/0220_auto_20220811_1002.py @@ -0,0 +1,68 @@ +# Generated by Django 3.2.12 on 2022-08-11 10:02 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base +import pretix.base.models.customers +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0219_auto_20220706_0913'), + ] + + operations = [ + migrations.CreateModel( + name='CustomerSSOClient', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('client_id', models.CharField(db_index=True, default=pretix.base.models.customers.generate_client_id, max_length=100, unique=True)), + ('client_secret', models.CharField(max_length=255)), + ('client_type', models.CharField(default='confidential', max_length=32)), + ('authorization_grant_type', models.CharField(default='authorization-code', max_length=32)), + ('redirect_uris', models.TextField()), + ('allowed_scopes', pretix.base.models.fields.MultiStringField(default=['openid', 'profile', 'email', 'phone'])), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_clients', to='pretixbase.organizer')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AlterField( + model_name='customer', + name='provider', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='customers', to='pretixbase.customerssoprovider'), + ), + migrations.CreateModel( + name='CustomerSSOGrant', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('code', models.CharField(max_length=255, unique=True)), + ('nonce', models.CharField(max_length=255, null=True)), + ('auth_time', models.IntegerField()), + ('expires', models.DateTimeField()), + ('redirect_uri', models.TextField()), + ('scope', models.TextField()), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='pretixbase.customerssoclient')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_grants', to='pretixbase.customer')), + ], + ), + migrations.CreateModel( + name='CustomerSSOAccessToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('from_code', models.CharField(max_length=255, null=True)), + ('token', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField()), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='pretixbase.customerssoclient')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_access_tokens', to='pretixbase.customer')), + ], + ), + ] diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py index 630202fef..4df92010a 100644 --- a/src/pretix/base/models/customers.py +++ b/src/pretix/base/models/customers.py @@ -24,7 +24,7 @@ from django.conf import settings from django.contrib.auth.hashers import ( check_password, is_password_usable, make_password, ) -from django.core.validators import RegexValidator +from django.core.validators import RegexValidator, URLValidator from django.db import models from django.db.models import F, Q from django.utils.crypto import get_random_string, salted_hmac @@ -35,6 +35,7 @@ from phonenumber_field.modelfields import PhoneNumberField from pretix.base.banlist import banned from pretix.base.models.base import LoggedModel +from pretix.base.models.fields import MultiStringField from pretix.base.models.organizer import Organizer from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.helpers.countries import FastCountryField @@ -348,3 +349,134 @@ class AttendeeProfile(models.Model): parts.append(f'{a["field_label"]}: {val}') return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()]) + + +def generate_client_id(): + return get_random_string(40) + + +def generate_client_secret(): + return get_random_string(40) + + +class CustomerSSOClient(LoggedModel): + CLIENT_CONFIDENTIAL = "confidential" + CLIENT_PUBLIC = "public" + CLIENT_TYPES = ( + (CLIENT_CONFIDENTIAL, pgettext_lazy("openidconnect", "Confidential")), + (CLIENT_PUBLIC, pgettext_lazy("openidconnect", "Public")), + ) + + GRANT_AUTHORIZATION_CODE = "authorization-code" + GRANT_IMPLICIT = "implicit" + GRANT_TYPES = ( + (GRANT_AUTHORIZATION_CODE, pgettext_lazy("openidconnect", "Authorization code")), + (GRANT_IMPLICIT, pgettext_lazy("openidconnect", "Implicit")), + ) + + SCOPE_CHOICES = ( + ('openid', _('OpenID Connect access (required)')), + ('profile', _('Profile data (name, addresses)')), + ('email', _('E-mail address')), + ('phone', _('Phone number')), + ) + + id = models.BigAutoField(primary_key=True) + organizer = models.ForeignKey(Organizer, related_name='sso_clients', on_delete=models.CASCADE) + + name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False) + is_active = models.BooleanField(default=True, verbose_name=_('Active')) + + client_id = models.CharField( + verbose_name=_("Client ID"), + max_length=100, unique=True, default=generate_client_id, db_index=True + ) + client_secret = models.CharField( + max_length=255, blank=False, + ) + + client_type = models.CharField( + max_length=32, choices=CLIENT_TYPES, verbose_name=_("Client type"), default=CLIENT_CONFIDENTIAL, + ) + authorization_grant_type = models.CharField( + max_length=32, choices=GRANT_TYPES, verbose_name=_("Grant type"), default=GRANT_AUTHORIZATION_CODE, + ) + redirect_uris = models.TextField( + blank=False, + verbose_name=_("Redirection URIs"), + help_text=_("Allowed URIs list, space separated") + ) + allowed_scopes = MultiStringField( + default=['openid', 'profile', 'email', 'phone'], + delimiter=" ", + blank=True, + verbose_name=_('Allowed access scopes'), + help_text=_('Separate multiple values with spaces'), + ) + + def is_usable(self): + return self.is_active + + def allow_redirect_uri(self, redirect_uri): + return self.redirect_uris and any(r.strip() == redirect_uri for r in self.redirect_uris.split(' ')) + + def allow_delete(self): + return True + + def evaluated_scope(self, scope): + scope = set(scope.split(' ')) + allowed_scopes = set(self.allowed_scopes) + return ' '.join(scope & allowed_scopes) + + def clean(self): + redirect_uris = self.redirect_uris.strip().split() + + if redirect_uris: + validator = URLValidator() + for uri in redirect_uris: + validator(uri) + + def set_client_secret(self): + secret = get_random_string(64) + self.client_secret = make_password(secret) + return secret + + def check_client_secret(self, raw_secret): + """ + Return a boolean of whether the ra_secret was correct. Handles + hashing formats behind the scenes. + """ + def setter(raw_secret): + self.client_secret = make_password(raw_secret) + self.save(update_fields=["client_secret"]) + return check_password(raw_secret, self.client_secret, setter) + + +class CustomerSSOGrant(models.Model): + id = models.BigAutoField(primary_key=True) + client = models.ForeignKey( + CustomerSSOClient, on_delete=models.CASCADE, related_name="grants" + ) + customer = models.ForeignKey( + Customer, on_delete=models.CASCADE, related_name="sso_grants" + ) + code = models.CharField(max_length=255, unique=True) + nonce = models.CharField(max_length=255, null=True, blank=True) + auth_time = models.IntegerField() + expires = models.DateTimeField() + redirect_uri = models.TextField() + scope = models.TextField(blank=True) + + +class CustomerSSOAccessToken(models.Model): + id = models.BigAutoField(primary_key=True) + client = models.ForeignKey( + CustomerSSOClient, on_delete=models.CASCADE, related_name="access_tokens" + ) + customer = models.ForeignKey( + Customer, on_delete=models.CASCADE, related_name="sso_access_tokens" + ) + from_code = models.CharField(max_length=255, null=True, blank=True) + token = models.CharField(max_length=255, unique=True) + expires = models.DateTimeField() + scope = models.TextField(blank=True) diff --git a/src/pretix/base/models/fields.py b/src/pretix/base/models/fields.py index 9fc371111..218a7c668 100644 --- a/src/pretix/base/models/fields.py +++ b/src/pretix/base/models/fields.py @@ -33,7 +33,8 @@ class MultiStringField(TextField): 'delimiter_found': _('No value can contain the delimiter character.') } - def __init__(self, verbose_name=None, name=None, **kwargs): + def __init__(self, verbose_name=None, name=None, delimiter=DELIMITER, **kwargs): + self.delimiter = delimiter super().__init__(verbose_name, name, **kwargs) def deconstruct(self): @@ -44,13 +45,13 @@ class MultiStringField(TextField): if isinstance(value, (list, tuple)): return value elif value: - return [v for v in value.split(DELIMITER) if v] + return [v for v in value.split(self.delimiter) if v] else: return [] def get_prep_value(self, value): if isinstance(value, (list, tuple)): - return DELIMITER + DELIMITER.join(value) + DELIMITER + return self.delimiter + self.delimiter.join(value) + self.delimiter elif value is None: if self.null: return None @@ -63,14 +64,14 @@ class MultiStringField(TextField): def from_db_value(self, value, expression, connection): if value: - return [v for v in value.split(DELIMITER) if v] + return [v for v in value.split(self.delimiter) if v] else: return [] def validate(self, value, model_instance): super().validate(value, model_instance) for l in value: - if DELIMITER in l: + if self.delimiter in l: raise exceptions.ValidationError( self.error_messages['delimiter_found'], code='delimiter_found', @@ -78,9 +79,9 @@ class MultiStringField(TextField): def get_lookup(self, lookup_name): if lookup_name == 'contains': - return MultiStringContains + return make_multistring_contains_lookup(self.delimiter) elif lookup_name == 'icontains': - return MultiStringIContains + return make_multistring_icontains_lookup(self.delimiter) elif lookup_name == 'isnull': return builtin_lookups.IsNull raise NotImplementedError( @@ -88,18 +89,22 @@ class MultiStringField(TextField): ) -class MultiStringContains(builtin_lookups.Contains): - def process_rhs(self, qn, connection): - sql, params = super().process_rhs(qn, connection) - params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%" - return sql, params +def make_multistring_contains_lookup(delimiter): + class Cls(builtin_lookups.Contains): + def process_rhs(self, qn, connection): + sql, params = super().process_rhs(qn, connection) + params[0] = "%" + delimiter + params[0][1:-1] + delimiter + "%" + return sql, params + return Cls -class MultiStringIContains(builtin_lookups.IContains): - def process_rhs(self, qn, connection): - sql, params = super().process_rhs(qn, connection) - params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%" - return sql, params +def make_multistring_icontains_lookup(delimiter): + class Cls(builtin_lookups.IContains): + def process_rhs(self, qn, connection): + sql, params = super().process_rhs(qn, connection) + params[0] = "%" + delimiter + params[0][1:-1] + delimiter + "%" + return sql, params + return Cls class MultiStringSerializer(serializers.Field): diff --git a/src/pretix/base/services/cleanup.py b/src/pretix/base/services/cleanup.py index 06fc89539..1adee2f62 100644 --- a/src/pretix/base/services/cleanup.py +++ b/src/pretix/base/services/cleanup.py @@ -28,6 +28,7 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from pretix.base.models import CachedCombinedTicket, CachedTicket +from pretix.base.models.customers import CustomerSSOGrant from ..models import CachedFile, CartPosition, InvoiceAddress from ..signals import periodic_task @@ -68,3 +69,9 @@ def clean_cached_tickets(sender, **kwargs): @scopes_disabled() def clearsessions(sender, **kwargs): call_command('clearsessions') + + +@receiver(signal=periodic_task) +@scopes_disabled() +def clear_oidc_data(sender, **kwargs): + CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete() diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 87859d947..f925ae28f 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -62,7 +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.customers import CustomerSSOClient, 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 @@ -797,3 +797,36 @@ class SSOProviderForm(I18nModelForm): oidc_validate_and_complete_config(config) self.instance.configuration = config + + +class SSOClientForm(I18nModelForm): + regenerate_client_secret = forms.BooleanField( + label=_('Invalidate old client secret and generate a new one'), + required=False, + ) + + class Meta: + model = CustomerSSOClient + fields = ['is_active', 'name', 'client_id', 'client_type', 'authorization_grant_type', 'redirect_uris', + 'allowed_scopes'] + widgets = { + 'authorization_grant_type': forms.RadioSelect, + 'client_type': forms.RadioSelect, + 'allowed_scopes': forms.CheckboxSelectMultiple, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['allowed_scopes'] = forms.MultipleChoiceField( + label=self.fields['allowed_scopes'].label, + help_text=self.fields['allowed_scopes'].help_text, + required=self.fields['allowed_scopes'].required, + initial=self.fields['allowed_scopes'].initial, + choices=CustomerSSOClient.SCOPE_CHOICES, + widget=forms.CheckboxSelectMultiple + ) + if self.instance and self.instance.pk: + self.fields['client_id'].disabled = True + else: + del self.fields['client_id'] + del self.fields['regenerate_client_secret'] diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 9ad70d25a..118415ead 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -324,6 +324,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): '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.ssoclient.created': _('The SSO client has been created.'), + 'pretix.ssoclient.changed': _('The SSO client has been changed.'), + 'pretix.ssoclient.deleted': _('The SSO client 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.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index e7fdbbe9a..b40fab0f8 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -550,6 +550,15 @@ def get_organizer_navigation(request): 'active': 'organizer.membershiptype' in url.url_name, } ) + children.append( + { + 'label': _('SSO clients'), + 'url': reverse('control:organizer.ssoclients', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'organizer.ssoclient' in url.url_name, + } + ) children.append( { 'label': _('SSO providers'), diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html new file mode 100644 index 000000000..8be5bbf52 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html @@ -0,0 +1,25 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} +

{% trans "Delete SSO client:" %} {{ client.name }}

+
+ {% csrf_token %} + {% if is_allowed %} +

{% blocktrans %}Are you sure you want to delete this SSO client?{% endblocktrans %} + {% else %} +

{% blocktrans %}This SSO client cannot be deleted since it has already been used.{% endblocktrans %} + {% endif %} +

+
+ + {% trans "Cancel" %} + + {% if is_allowed %} + + {% endif %} +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_edit.html new file mode 100644 index 000000000..e1265ced3 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_edit.html @@ -0,0 +1,20 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if client %} +

{% trans "SSO client:" %} {{ client.name }}

+ {% else %} +

{% trans "Create a new SSO client" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+ +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoclients.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoclients.html new file mode 100644 index 000000000..b9eec0417 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoclients.html @@ -0,0 +1,44 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "SSO clients" %}{% endblock %} +{% block inner %} +

{% trans "SSO clients" %}

+

+ {% blocktrans trimmed %} + You can allow your customers to log into other systems using their customer account credentials by setting up + your other systems as a Single-Sign-On (SSO) client based on OpenID Connect. + {% endblocktrans %} +

+ + + {% trans "Create a new SSO client" %} + + + + + + + + + + {% for c in clients %} + + + + + {% endfor %} + +
{% trans "Name" %}
+ + {% if not c.is_active %}{% endif %} + {{ c.name }} + {% if not c.is_active %}{% endif %} + + + + +
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index a14131f45..32ea9b9d9 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -140,6 +140,13 @@ urlpatterns = [ name='organizer.ssoprovider.edit'), re_path(r'^organizer/(?P[^/]+)/ssoprovider/(?P[^/]+)/delete$', organizer.SSOProviderDeleteView.as_view(), name='organizer.ssoprovider.delete'), + re_path(r'^organizer/(?P[^/]+)/ssoclients$', organizer.SSOClientListView.as_view(), name='organizer.ssoclients'), + re_path(r'^organizer/(?P[^/]+)/ssoclient/add$', organizer.SSOClientCreateView.as_view(), + name='organizer.ssoclient.add'), + re_path(r'^organizer/(?P[^/]+)/ssoclient/(?P[^/]+)/edit$', organizer.SSOClientUpdateView.as_view(), + name='organizer.ssoclient.edit'), + re_path(r'^organizer/(?P[^/]+)/ssoclient/(?P[^/]+)/delete$', organizer.SSOClientDeleteView.as_view(), + name='organizer.ssoclient.delete'), re_path(r'^organizer/(?P[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'), re_path(r'^organizer/(?P[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'), re_path(r'^organizer/(?P[^/]+)/customer/add$', diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index a21a8e300..d665b0949 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -71,7 +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.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.giftcards import ( GiftCardTransaction, gen_giftcard_secret, @@ -95,8 +95,8 @@ from pretix.control.forms.organizer import ( EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, - OrganizerSettingsForm, OrganizerUpdateForm, SSOProviderForm, TeamForm, - WebHookForm, + OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm, + TeamForm, WebHookForm, ) from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.permissions import ( @@ -2039,6 +2039,134 @@ class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequire return redirect(success_url) +class SSOClientListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = CustomerSSOClient + template_name = 'pretixcontrol/organizers/ssoclients.html' + permission = 'can_change_organizer_settings' + context_object_name = 'clients' + + def get_queryset(self): + return self.request.organizer.sso_clients.all() + + +class SSOClientCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + model = CustomerSSOClient + template_name = 'pretixcontrol/organizers/ssoclient_edit.html' + permission = 'can_change_organizer_settings' + form_class = SSOClientForm + + def get_object(self, queryset=None): + return get_object_or_404(CustomerSSOClient, organizer=self.request.organizer, pk=self.kwargs.get('client')) + + def get_success_url(self): + return reverse('control:organizer.ssoclient.edit', kwargs={ + 'organizer': self.request.organizer.slug, + 'client': self.object.pk + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.organizer + return kwargs + + def form_valid(self, form): + secret = form.instance.set_client_secret() + messages.success( + self.request, + _('The SSO client has been created. Please note down the following client secret, it will never be shown ' + 'again: {secret}').format(secret=secret) + ) + form.instance.organizer = self.request.organizer + ret = super().form_valid(form) + form.instance.log_action('pretix.ssoclient.created', user=self.request.user, data={ + k: getattr(self.object, k, form.cleaned_data.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 SSOClientUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + model = CustomerSSOClient + template_name = 'pretixcontrol/organizers/ssoclient_edit.html' + permission = 'can_change_organizer_settings' + context_object_name = 'client' + form_class = SSOClientForm + + def get_object(self, queryset=None): + return get_object_or_404(CustomerSSOClient, organizer=self.request.organizer, pk=self.kwargs.get('client')) + + def get_success_url(self): + return reverse('control:organizer.ssoclient.edit', kwargs={ + 'organizer': self.request.organizer.slug, + 'client': self.object.pk + }) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + 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.ssoclient.changed', user=self.request.user, data={ + k: getattr(self.object, k, form.cleaned_data.get(k)) for k in form.changed_data + }) + if form.cleaned_data.get('regenerate_client_secret'): + secret = form.instance.set_client_secret() + messages.success( + self.request, + _('Your changes have been saved. Please note down the following client secret, it will never be shown ' + 'again: {secret}').format(secret=secret) + ) + else: + 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 SSOClientDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView): + model = CustomerSSOClient + template_name = 'pretixcontrol/organizers/ssoclient_delete.html' + permission = 'can_change_organizer_settings' + context_object_name = 'client' + + def get_object(self, queryset=None): + return get_object_or_404(CustomerSSOClient, organizer=self.request.organizer, pk=self.kwargs.get('client')) + + 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.ssoclients', 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.ssoclient.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' diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html index e59c3c94e..9025d1e19 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html @@ -22,7 +22,7 @@ {% endif %} - {% for provider in request.organizer.sso_providers.all %} + {% for provider in providers %} {% if provider.is_active %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index f2a916a50..0a63c6170 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -40,6 +40,7 @@ import pretix.presale.views.checkout import pretix.presale.views.customer import pretix.presale.views.event import pretix.presale.views.locale +import pretix.presale.views.oidc_op import pretix.presale.views.order import pretix.presale.views.organizer import pretix.presale.views.robots @@ -72,6 +73,7 @@ frame_wrapped_urls = [ re_path(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'), re_path(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'), ] + event_patterns = [ # Cart/checkout patterns are a bit more complicated, as they should have simple URLs like cart/clear in normal # cases, but need to have versions with unguessable URLs like w/8l4Y83XNonjLxoBb/cart/clear to be used in widget @@ -174,9 +176,11 @@ organizer_patterns = [ re_path(r'^events/ical/$', pretix.presale.views.organizer.OrganizerIcalDownload.as_view(), name='organizer.ical'), + 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[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'), re_path(r'^account/login/(?P[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'), @@ -192,6 +196,17 @@ organizer_patterns = [ re_path(r'^account/addresses/(?P\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'), re_path(r'^account/profiles/(?P\d+)/delete$', pretix.presale.views.customer.ProfileDeleteView.as_view(), name='organizer.customer.profile.delete'), re_path(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'), + + re_path(r'^oauth2/v1/authorize$', pretix.presale.views.oidc_op.AuthorizeView.as_view(), + name='organizer.oauth2.v1.authorize'), + re_path(r'^oauth2/v1/token$', pretix.presale.views.oidc_op.TokenView.as_view(), + name='organizer.oauth2.v1.token'), + re_path(r'^oauth2/v1/userinfo$', pretix.presale.views.oidc_op.UserInfoView.as_view(), + name='organizer.oauth2.v1.userinfo'), + re_path(r'^oauth2/v1/keys$', pretix.presale.views.oidc_op.KeysView.as_view(), + name='organizer.oauth2.v1.jwks'), + re_path(r'^.well-known/openid-configuration$', pretix.presale.views.oidc_op.ConfigurationView.as_view(), + name='organizer.oauth2.v1.configuration'), ] locale_patterns = [ diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index dd5939f26..959d08b97 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -32,6 +32,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. import re +import time import warnings from importlib import import_module from urllib.parse import urljoin @@ -153,9 +154,15 @@ def add_customer_to_request(request): request.customer = SimpleLazyObject(lambda: get_customer(request)) +def get_customer_auth_time(request): + auth_time_session_key = f'customer_auth_time:{request.organizer.pk}' + return request.session.get(auth_time_session_key) or 0 + + def customer_login(request, customer): session_key = f'customer_auth_id:{request.organizer.pk}' hash_session_key = f'customer_auth_hash:{request.organizer.pk}' + auth_time_session_key = f'customer_auth_time:{request.organizer.pk}' dependency_key = f'customer_auth_session_dependency:{request.organizer.pk}' session_auth_hash = customer.get_session_auth_hash() @@ -172,6 +179,7 @@ def customer_login(request, customer): request.session.pop(dependency_key, None) request.session[session_key] = customer.pk request.session[hash_session_key] = session_auth_hash + request.session[auth_time_session_key] = int(time.time()) request.customer = customer customer.last_login = now() @@ -183,6 +191,7 @@ def customer_login(request, customer): def customer_logout(request): session_key = f'customer_auth_id:{request.organizer.pk}' hash_session_key = f'customer_auth_hash:{request.organizer.pk}' + auth_time_session_key = f'customer_auth_time:{request.organizer.pk}' dependency_key = f'customer_auth_session_dependency:{request.organizer.pk}' # Remove dependency on parent session @@ -193,6 +202,7 @@ def customer_logout(request): # Remove user session customer_id = request.session.pop(session_key, None) request.session.pop(hash_session_key, None) + request.session.pop(auth_time_session_key, None) # Remove carts tied to this user carts = request.session.get('carts', {}) diff --git a/src/pretix/presale/views/customer.py b/src/pretix/presale/views/customer.py index 74c200fe6..fce373932 100644 --- a/src/pretix/presale/views/customer.py +++ b/src/pretix/presale/views/customer.py @@ -113,6 +113,12 @@ class LoginView(RedirectBackMixin, FormView): raise Http404('Feature not enabled') return super().post(request, *args, **kwargs) + def get_context_data(self, **kwargs): + return super().get_context_data( + **kwargs, + providers=self.request.organizer.sso_providers.all() + ) + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['request'] = self.request diff --git a/src/pretix/presale/views/oidc_op.py b/src/pretix/presale/views/oidc_op.py new file mode 100644 index 000000000..d8a8401c2 --- /dev/null +++ b/src/pretix/presale/views/oidc_op.py @@ -0,0 +1,526 @@ +# +# 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 . +# +# 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 +# . +# +import base64 +import hashlib +import time +from binascii import unhexlify +from datetime import timedelta +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from Crypto.PublicKey import RSA +from django.db import transaction +from django.http import Http404, HttpResponse, JsonResponse +from django.middleware.csrf import _compare_masked_tokens +from django.shortcuts import redirect, render +from django.utils.crypto import get_random_string +from django.utils.decorators import method_decorator +from django.utils.timezone import now +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.debug import sensitive_post_parameters + +from pretix.base.customersso.oidc import ( + _get_or_create_server_keypair, customer_claims, generate_id_token, +) +from pretix.base.models.customers import ( + CustomerSSOAccessToken, CustomerSSOClient, CustomerSSOGrant, +) +from pretix.multidomain.middlewares import CsrfViewMiddleware +from pretix.multidomain.urlreverse import build_absolute_uri +from pretix.presale.forms.customer import AuthenticationForm +from pretix.presale.utils import customer_login, get_customer_auth_time + +""" +We implement the OpenID Connect spec as per https://openid.net/specs/openid-connect-core-1_0.html +Based on the OAuth spec as per https://www.rfc-editor.org/rfc/rfc6749 + +We implement all three flows (authorization code, implicit, hybrid), as well as some typical standard +claims. + +We currently do not implement the following optional parts of the spec: + +- 4. Initiating Login from a Third Party +- 5.5. Requesting Claims using the "claims" Request Parameter +- 5.6.2. Aggregated and Distributed Claims +- 6. Passing Request Parameters as JWTs +- 8.1. Pairwise Identifier Algorithm +- 9. Client Authentication (except for client_secret_basic, client_secret_post) +- 10.2. Encryption +- 11. Offline Access +- 12. Using Refresh Tokens + +We also implement the Discovery extension (without issuer discovery) +as per https://openid.net/specs/openid-connect-discovery-1_0.html + +The implementation passed the certification tests against the following profiles, but we did not +acquire formal certification: + +- Basic OP +- Implicit OP +- Hybrid OP +- Config OP +""" + +RESPONSE_TYPES_SUPPORTED = ("code", "id_token token", "id_token", "code id_token", "code id_token token", "code token") + + +class AuthorizeView(View): + + # We need to be exempt from CSRF because the spec mandates that relying parties can send requests as POST. + # This is not a risk when we show a login form, because CSRF is pointless for a login form, if the attacker has + # the password, they don't need to resort to CSRF. We still to a minimal validation below. + # It would be a problem for a consent form, but we currently never show a consent form because it is not required + # for our intended use case where all relying parties are at least somewhat trusted. + @method_decorator(csrf_exempt) + @method_decorator(never_cache) + @method_decorator(sensitive_post_parameters()) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: + raise Http404('Feature not enabled') + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + return self._process_auth_request(request, request.GET) + + def post(self, request, *args, **kwargs): + request_token = CsrfViewMiddleware(lambda: None)._get_token(request) + if not request_token or not _compare_masked_tokens(request.POST.get('csrfmiddlewaretoken', ''), request_token): + # External request, we prefer GET and will redirect to prevent confusion with our login form + return redirect(request.path + '?' + request.POST.urlencode()) + return self._process_auth_request(request, request.GET) + + def _final_error(self, error, error_description): + return HttpResponse( + f'Error: {error_description} ({error})', + status=400, + ) + + def _construct_redirect_uri(self, redirect_uri, response_mode, params): + ru = urlparse(redirect_uri) + qs = parse_qs(ru.query) + fm = parse_qs(ru.fragment) + if response_mode == 'query': + qs.update(params) + elif response_mode == 'fragment': + fm.update(params) + query = urlencode(qs, doseq=True) + fragment = urlencode(fm, doseq=True) + return urlunparse((ru.scheme, ru.netloc, ru.path, ru.params, query, fragment)) + + def _redirect_error(self, error, error_description, redirect_uri, response_mode, state): + qs = {'error': error, 'error_description': error_description} + if state: + qs['state'] = state + return redirect( + self._construct_redirect_uri(redirect_uri, response_mode, qs) + ) + + def _require_login(self, request, client, scope, redirect_uri, response_type, response_mode, state, nonce): + form = AuthenticationForm(data=request.POST if "login-email" in request.POST else None, request=request, + prefix="login") + if "login-email" in request.POST and form.is_valid(): + customer_login(request, form.get_customer()) + return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, form.get_customer()) + else: + return render(request, 'pretixpresale/organizers/customer_login.html', { + 'providers': [], + 'form': form, + }) + + def _success(self, client, scope, redirect_uri, response_type, response_mode, state, nonce, customer): + response_type = response_type.split(' ') + qs = {} + id_token_kwargs = {} + + if 'code' in response_type: + grant = client.grants.create( + customer=customer, + scope=' '.join(scope), + redirect_uri=redirect_uri, + code=get_random_string(64), + expires=now() + timedelta(minutes=10), + auth_time=get_customer_auth_time(self.request), + nonce=nonce, + ) + qs['code'] = grant.code + id_token_kwargs['with_code'] = grant.code + + expires = now() + timedelta(hours=24) + + if 'token' in response_type: + token = client.access_tokens.create( + customer=customer, + token=get_random_string(128), + expires=expires, + scope=' '.join(scope), + ) + qs['access_token'] = token.token + qs['token_type'] = 'Bearer' + qs['expires_in'] = int((token.expires - now()).total_seconds()) + id_token_kwargs['with_access_token'] = token.token + + if 'id_token' in response_type: + qs['id_token'] = generate_id_token( + customer, + client, + get_customer_auth_time(self.request), + nonce, + ' '.join(scope), + expires, + scope_claims='token' not in response_type and 'code' not in response_type, + **id_token_kwargs, + ) + + if state: + qs['state'] = state + + r = redirect(self._construct_redirect_uri(redirect_uri, response_mode, qs)) + r['Cache-Control'] = 'no-store' + r['Pragma'] = 'no-cache' + return r + + def _process_auth_request(self, request, request_data): + response_mode = request_data.get("response_mode") + client_id = request_data.get("client_id") + state = request_data.get("state") + nonce = request_data.get("nonce") + max_age = request_data.get("max_age") + prompt = request_data.get("prompt") + response_type = request_data.get("response_type") + scope = request_data.get("scope", "").split(" ") + + if not client_id: + return self._final_error("invalid_request", "client_id missing") + + try: + client = self.request.organizer.sso_clients.get(is_active=True, client_id=client_id) + except CustomerSSOClient.DoesNotExist: + return self._final_error("unauthorized_client", "invalid client_id") + + redirect_uri = request_data.get("redirect_uri") + if not redirect_uri or not client.allow_redirect_uri(redirect_uri): + return self._final_error("invalid_request_uri", "invalid redirect_uri") + + if response_type not in RESPONSE_TYPES_SUPPORTED: + return self._final_error("unsupported_response_type", "response_type unsupported") + + if response_type != "code" and response_mode == "query": + return self._final_error("invalid_request", "response_mode query must not be used with implicit or hybrid flow") + elif not response_mode: + response_mode = "query" if response_type == "code" else "fragment" + elif response_mode not in ("query", "fragment"): + return self._final_error("invalid_request", "invalid response_mode") + + if "request" in request_data: + return self._redirect_error("request_not_supported", "request_not_supported", redirect_uri, response_mode, state) + + if response_type not in ("code", "code token") and not nonce: + return self._redirect_error("invalid_request", "nonce is required in implicit or hybrid flow", redirect_uri, + response_mode, state) + + if "openid" not in scope: + return self._redirect_error("invalid_scope", "scope 'openid' must be requested", redirect_uri, + response_mode, state) + + if "id_token_hint" in request_data: + self._redirect_error("invalid_request", "id_token_hint currently not supported by this server", + redirect_uri, response_mode, state) + + has_valid_session = bool(request.customer) + if has_valid_session and max_age: + try: + has_valid_session = int(time.time() - get_customer_auth_time(request)) < int(max_age) + except ValueError: + self._redirect_error("invalid_request", "invalid max_age value", redirect_uri, response_mode, state) + + if not has_valid_session and prompt and prompt == "none": + return self._redirect_error("interaction_required", "user is not logged in but no prompt is allowed", + redirect_uri, response_mode, state) + elif prompt in ("select_account", "login"): + has_valid_session = False + + if has_valid_session: + return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, request.customer) + else: + return self._require_login(request, client, scope, redirect_uri, response_type, response_mode, state, nonce) + + +class TokenView(View): + @method_decorator(csrf_exempt) + @method_decorator(never_cache) + @method_decorator(sensitive_post_parameters()) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: + raise Http404('Feature not enabled') + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + auth_header = request.headers.get('Authorization') + if auth_header: + encoded_credentials = auth_header.split(' ')[1] # Removes "Basic " to isolate credentials + decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8").split(':') + client_id = decoded_credentials[0] + client_secret = decoded_credentials[1] + try: + self.client = request.organizer.sso_clients.get(client_id=client_id, is_active=True) + except CustomerSSOClient.DoesNotExist: + return JsonResponse({ + "error": "invalid_client", + "error_description": "Unknown or inactive client_id" + }, status=401, headers={ + 'WWW-Authenticate': 'error="invalid_client"&error_description="Unknown or inactive client_id"' + }) + if not self.client.check_client_secret(client_secret): + return JsonResponse({ + "error": "invalid_client", + "error_description": "Wrong client_secret" + }, status=401, headers={ + 'WWW-Authenticate': 'error="invalid_client"&error_description="Wrong client_secret"' + }) + elif request.POST.get("client_id"): + try: + self.client = request.organizer.sso_clients.get(client_id=request.POST["client_id"], is_active=True) + except CustomerSSOClient.DoesNotExist: + return JsonResponse({ + "error": "invalid_client", + "error_description": "Unknown or inactive client_id" + }, status=400) + if "client_secret" in request.POST: + if not self.client.check_client_secret(request.POST.get("client_secret")): + return JsonResponse({ + "error": "invalid_client", + "error_description": "Wrong client_secret" + }, status=401, headers={ + 'WWW-Authenticate': 'error="invalid_client"&error_description="Wrong client_secret"' + }) + elif self.client.client_type != CustomerSSOClient.CLIENT_PUBLIC: + return JsonResponse({ + "error": "invalid_client", + "error_description": "Client is confidential, authentication required" + }, status=400) + else: + return JsonResponse({ + "error": "invalid_client", + "error_description": "Client is confidential, authentication required" + }, status=400) + + grant_type = request.POST.get("grant_type") + if grant_type == "authorization_code": + return self._handle_authorization_code(request) + else: + return JsonResponse({ + "error": "unsupported_grant_type" + }, status=400) + + def _handle_authorization_code(self, request): + code = request.POST.get("code") + redirect_uri = request.POST.get("redirect_uri") + if not code: + return JsonResponse({ + "error": "invalid_grant", + }, status=400) + + try: + grant = self.client.grants.get(code=code, expires__gt=now()) + except CustomerSSOGrant.DoesNotExist: + # The server must return an invalid_grant error as the authorization code has already been used. + # The originally issued access token should be revoked (as per RFC6749-4.1.2) + CustomerSSOAccessToken.objects.filter( + client=self.client, + from_code=code + ).update(expires=now() - timedelta(seconds=1)) + return JsonResponse({ + "error": "invalid_grant", + "error_description": "Unknown or expired authorization code" + }, status=400) + + if grant.redirect_uri != redirect_uri: + return JsonResponse({ + "error": "invalid_grant", + "error_description": "Mismatch of redirect_uri" + }, status=400) + + with transaction.atomic(): + token = self.client.access_tokens.create( + customer=grant.customer, + token=get_random_string(128), + expires=now() + timedelta(hours=24), + scope=grant.scope, + from_code=code, + ) + grant.delete() + + return JsonResponse({ + "access_token": token.token, + "token_type": "Bearer", + "expires_in": int((token.expires - now()).total_seconds()), + "id_token": generate_id_token(grant.customer, self.client, grant.auth_time, grant.nonce, grant.scope, token.expires) + }, headers={ + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + }) + + +class UserInfoView(View): + @method_decorator(csrf_exempt) + @method_decorator(never_cache) + @method_decorator(sensitive_post_parameters()) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: + raise Http404('Feature not enabled') + + auth_header = request.headers.get('Authorization') + if auth_header: + method, token = auth_header.split(' ', 1) + if method != 'Bearer': + return JsonResponse({ + "error": "invalid_request", + "error_description": "Unknown authorization method" + }, status=400, headers={ + 'Access-Control-Allow-Origin': '*', + }) + elif request.method == "POST" and "access_token" in request.POST: + token = request.POST.get("access_token") + else: + return HttpResponse(status=401, headers={ + 'WWW-Authenticate': 'Bearer realm="example"', + 'Access-Control-Allow-Origin': '*', + }) + + try: + access_token = CustomerSSOAccessToken.objects.get( + token=token, expires__gt=now(), client__organizer=self.request.organizer, + ) + except CustomerSSOAccessToken.DoesNotExist: + return JsonResponse({ + "error": "invalid_token", + "error_description": "Unknown access token" + }, status=401, headers={ + 'WWW-Authenticate': 'error="invalid_token"&error_description="Unknown access token"', + 'Access-Control-Allow-Origin': '*', + }) + else: + self.customer = access_token.customer + self.access_token = access_token + + r = super().dispatch(request, *args, **kwargs) + r['Access-Control-Allow-Origin'] = '*' + return r + + def post(self, request, *args, **kwargs): + return self._handle(request) + + def get(self, request, *args, **kwargs): + return self._handle(request) + + def _handle(self, request): + return JsonResponse(customer_claims(self.customer, self.access_token.client.evaluated_scope(self.access_token.scope))) + + +class KeysView(View): + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: + raise Http404('Feature not enabled') + r = super().dispatch(request, *args, **kwargs) + r['Access-Control-Allow-Origin'] = '*' + return r + + def _encode_int(self, i): + hexi = hex(i)[2:] + return base64.urlsafe_b64encode(unhexlify((len(hexi) % 2) * '0' + hexi)) + + def get(self, request, *args, **kwargs): + privkey, pubkey = _get_or_create_server_keypair(request.organizer) + kid = hashlib.sha256(pubkey.encode()).hexdigest()[:16] + pubkey = RSA.import_key(pubkey) + + return JsonResponse({ + 'keys': [ + { + 'kty': 'RSA', + 'alg': 'RS256', + 'kid': kid, + 'use': 'sig', + 'e': self._encode_int(pubkey.e).decode().rstrip("="), + 'n': self._encode_int(pubkey.n).decode().rstrip("="), + } + ] + }) + + +class ConfigurationView(View): + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: + raise Http404('Feature not enabled') + r = super().dispatch(request, *args, **kwargs) + r['Access-Control-Allow-Origin'] = '*' + return r + + def get(self, request, *args, **kwargs): + return JsonResponse({ + 'issuer': build_absolute_uri(request.organizer, 'presale:organizer.index').rstrip('/'), + 'authorization_endpoint': build_absolute_uri( + request.organizer, 'presale:organizer.oauth2.v1.authorize' + ), + 'token_endpoint': build_absolute_uri( + request.organizer, 'presale:organizer.oauth2.v1.token' + ), + 'userinfo_endpoint': build_absolute_uri( + request.organizer, 'presale:organizer.oauth2.v1.userinfo' + ), + 'jwks_uri': build_absolute_uri( + request.organizer, 'presale:organizer.oauth2.v1.jwks' + ), + 'scopes_supported': [k for k, v in CustomerSSOClient.SCOPE_CHOICES], + 'response_types_supported': RESPONSE_TYPES_SUPPORTED, + 'response_modes_supported': ['query', 'fragment'], + 'request_parameter_supported': False, + 'grant_types_supported': ['authorization_code', 'implicit'], + 'subject_types_supported': ['public'], + 'id_token_signing_alg_values_supported': ['RS256'], + 'token_endpoint_auth_methods_supported': [ + 'client_secret_post', 'client_secret_basic' + ], + 'claims_supported': [ + 'iss', + 'aud', + 'exp', + 'iat', + 'auth_time', + 'nonce', + 'c_hash', + 'at_hash', + 'sub', + 'locale', + 'name', + 'given_name', + 'family_name', + 'middle_name', + 'nickname', + 'email', + 'email_verified', + 'phone_number', + ], + 'request_uri_parameter_supported': False, + + }) diff --git a/src/setup.py b/src/setup.py index 8f95b91dc..537e9c46e 100644 --- a/src/setup.py +++ b/src/setup.py @@ -211,6 +211,7 @@ setup( 'psycopg2-binary', 'pycountry', 'pycparser==2.21', + 'pycryptodome==3.15.*', 'PyPDF2==2.9.*', 'python-bidi==0.4.*', # Support for Arabic in reportlab 'python-dateutil==2.8.*', diff --git a/src/tests/presale/test_oidc_op.py b/src/tests/presale/test_oidc_op.py new file mode 100644 index 000000000..c09760599 --- /dev/null +++ b/src/tests/presale/test_oidc_op.py @@ -0,0 +1,585 @@ +# +# 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 . +# +# 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 +# . +# +import base64 +import json +import re +import time +from binascii import hexlify +from datetime import timedelta +from urllib.parse import quote + +import jwt +import pytest +from bs4 import BeautifulSoup +from Crypto.PublicKey import RSA +from django.utils.timezone import now +from django_scopes import scopes_disabled +from freezegun import freeze_time +from tests.base import extract_form_fields + +from pretix.base.customersso.oidc import _hash_scheme +from pretix.base.models import Event, Organizer +from pretix.base.models.customers import ( + CustomerSSOAccessToken, CustomerSSOClient, +) + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Big Events LLC', slug='bigevents') + o.settings.customer_accounts = True + o.settings.customer_accounts_native = True + event = Event.objects.create( + organizer=o, name='Conference', slug='conf', + date_from=now() + timedelta(days=10), + live=True, is_public=False + ) + customer = o.customers.create(email='john@example.org', is_verified=True, identifier="ABC123", + name_parts={'_scheme': 'given_family', 'given_name': 'John', 'family_name': 'Doe'}, + phone='+49302270') + customer.set_password('foo') + customer.save() + return o, event + + +@pytest.fixture +def ssoclient(env, client): + c = CustomerSSOClient(organizer=env[0], name="Test", + redirect_uris="https://example.net https://example.org/path?query=value#hash=foo") + secret = c.set_client_secret() + c.save() + return c, secret + + +@pytest.mark.django_db +def test_authorize_final_errors(env, client, ssoclient): + r = client.get('/bigevents/oauth2/v1/authorize') + assert r.status_code == 400 + assert b'client_id missing' in r.content + + r = client.get('/bigevents/oauth2/v1/authorize?client_id=abc') + assert r.status_code == 400 + assert b'invalid client_id' in r.content + + r = client.get(f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&response_type=code') + assert r.status_code == 400 + assert b'invalid redirect_uri' in r.content + + r = client.get(f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&redirect_uri=https://google.com') + assert r.status_code == 400 + assert b'invalid redirect_uri' in r.content + + r = client.get( + f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' + f'redirect_uri={quote("https://example.org/path?query=value#hash=foo")}&scope=openid+profile' + ) + assert r.status_code == 400 + assert b"response_type unsupported" in r.content + + r = client.get( + f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&response_type=id_token&response_mode=query&' + f'redirect_uri={quote("https://example.org/path?query=value#hash=foo")}&scope=openid+profile' + ) + assert r.status_code == 400 + assert b"response_mode query" in r.content + + r = client.get( + f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&response_type=id_token&response_mode=bogus' + f'&redirect_uri=https://example.net') + assert r.status_code == 400 + assert b'invalid response_mode' in r.content + + +@pytest.mark.django_db +def test_authorize_basic_redirect_errors(env, client, ssoclient): + r = client.get( + f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&redirect_uri=https://example.net&response_type=code') + assert r.status_code == 302 + assert r.headers['Location'] == 'https://example.net?error=invalid_scope&' \ + 'error_description=scope+%27openid%27+must+be+requested' + + r = client.get( + f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&redirect_uri=https://example.net&response_type=id_token' + f'&scope=openid+email') + assert r.status_code == 302 + assert r.headers['Location'] == 'https://example.net#error=invalid_request&' \ + 'error_description=nonce+is+required+in+implicit+or+hybrid+flow' + + +@pytest.mark.django_db +def test_authorize_redirect_post_to_get(env, client, ssoclient): + r = client.post('/bigevents/oauth2/v1/authorize', { + 'client_id': ssoclient[0].client_id, + 'redirect_uri': 'https://example.net', + 'response_type': 'code', + 'scope': 'openid+profile', + }) + assert r.status_code == 302 + assert r.headers['Location'] == f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https%3A%2F%2Fexample.net&response_type=code&scope=openid%2Bprofile' + + +@pytest.mark.django_db +def test_authorize_success_with_login(env, client, ssoclient): + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&' \ + f'state=STATE&' \ + f'scope=openid+profile' + r = client.get(url) + assert r.status_code == 200 + assert b'login-email' in r.content + + doc = BeautifulSoup(r.content, "lxml") + d = extract_form_fields(doc) + d.update({ + 'login-email': 'john@example.org', + 'login-password': 'foo', + }) + + r = client.post(url, d) + assert r.status_code == 302 + assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location']) + + +@pytest.mark.django_db +def test_authorize_success_with_existing_session(env, client, ssoclient): + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&' \ + f'state=STATE&' \ + f'scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location']) + + +@pytest.mark.django_db +def test_authorize_with_prompt_none(env, client, ssoclient): + url = f'/bigevents/oauth2/v1/authorize?' \ + f'prompt=none&' \ + f'client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&state=STATE&scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + assert r.headers['Location'] == 'https://example.net?' \ + 'error=interaction_required&' \ + 'error_description=user+is+not+logged+in+but+no+prompt+is+allowed&' \ + 'state=STATE' + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + url = f'/bigevents/oauth2/v1/authorize?' \ + f'prompt=none&' \ + f'client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&state=STATE&scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location']) + + +@pytest.mark.django_db +def test_authorize_require_login_if_prompt_requires_it_or_is_expired(env, client, ssoclient): + with freeze_time("2021-04-10T11:00:00+02:00"): + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + url = f'/bigevents/oauth2/v1/authorize?' \ + f'prompt=login&' \ + f'client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&state=STATE&scope=openid+profile' + r = client.get(url) + assert r.status_code == 200 + assert b'login-email' in r.content + + with freeze_time("2021-04-10T11:59:00+02:00"): + url = f'/bigevents/oauth2/v1/authorize?' \ + f'max_age=3600&' \ + f'client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&state=STATE&scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + + with freeze_time("2021-04-10T12:01:00+02:00"): + url = f'/bigevents/oauth2/v1/authorize?' \ + f'max_age=3600&' \ + f'client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&state=STATE&scope=openid+profile' + r = client.get(url) + assert r.status_code == 200 + assert b'login-email' in r.content + + +@pytest.mark.django_db +def test_token_require_client_id(env, client, ssoclient): + r = client.post('/bigevents/oauth2/v1/token', { + }) + assert r.status_code == 400 + assert b'invalid_client' in r.content + + r = client.post('/bigevents/oauth2/v1/token', { + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode('wrong:wrong'.encode()).decode()) + assert r.status_code == 401 + assert b'invalid_client' in r.content + assert 'invalid_client' in r.headers['WWW-Authenticate'] + + r = client.post('/bigevents/oauth2/v1/token', { + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:wrong'.encode()).decode()) + assert r.status_code == 401 + assert b'invalid_client' in r.content + assert 'invalid_client' in r.headers['WWW-Authenticate'] + + r = client.post('/bigevents/oauth2/v1/token', { + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 400 + assert b'unsupported_grant_type' in r.content + + r = client.post('/bigevents/oauth2/v1/token', { + 'client_id': ssoclient[0].client_id + }) + assert r.status_code == 400 + assert b'confidential' in r.content + + ssoclient[0].client_type = CustomerSSOClient.CLIENT_PUBLIC + ssoclient[0].save() + + r = client.post('/bigevents/oauth2/v1/token', { + 'client_id': ssoclient[0].client_id + }) + assert r.status_code == 400 + assert b'unsupported_grant_type' in r.content + + +def _authorization_step(client, ssoclient): + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + url = f'/bigevents/oauth2/v1/authorize?' \ + f'prompt=none&' \ + f'nonce=NONCE123&' \ + f'client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code&state=STATE&scope=openid+profile+email+phone' + r = client.get(url) + assert r.status_code == 302 + m = re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location']) + assert m + return m.group(1) + + +@pytest.mark.django_db +def test_token_missing_or_mismatching_parameters(env, client, ssoclient): + code = _authorization_step(client, ssoclient) + + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'test' + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 400 + assert b'unsupported_grant_type' in r.content + + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'authorization_code', + 'code': 'fail', + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 400 + assert b'Unknown or expired authorization code' in r.content + + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': 'https://google.com' + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 400 + assert b'Mismatch of redirect_uri' in r.content + + +@pytest.mark.django_db +def test_token_success(env, client, ssoclient): + code = _authorization_step(client, ssoclient) + + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': 'https://example.net' + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 200 + d = json.loads(r.content) + assert d['access_token'] + assert d['token_type'].lower() == 'bearer' + assert 86390 < d['expires_in'] <= 86400 + token = d['id_token'] + + env[0].settings.flush() + decoded = jwt.decode(token, env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"], + audience=ssoclient[0].client_id) + assert decoded['iss'] == 'http://example.com/bigevents' + assert decoded['aud'] == ssoclient[0].client_id + assert decoded['sub'] == "ABC123" + assert time.time() + 86390 < decoded['exp'] <= time.time() + 86400 + assert time.time() - 10 < decoded['iat'] <= time.time() + assert time.time() - 10 < decoded['auth_time'] <= time.time() + assert 'email' not in decoded + assert decoded['nonce'] == 'NONCE123' + + # Assert that code can only be used once + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'code', + 'code': code, + 'redirect_uri': 'https://example.net' + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 400 + + # Assert that auth token is revoked after reuse + with scopes_disabled(): + CustomerSSOAccessToken.objects.get(token=d['access_token']).expires < now() + + +@pytest.mark.django_db +def test_scope_enforcement(env, client, ssoclient): + ssoclient[0].allowed_scopes = ['openid', 'profile'] + ssoclient[0].save() + code = _authorization_step(client, ssoclient) + + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': 'https://example.net' + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 200 + d = json.loads(r.content) + token = d['id_token'] + env[0].settings.flush() + decoded = jwt.decode(token, env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"], + audience=ssoclient[0].client_id) + assert 'email' not in decoded + assert decoded['nonce'] == 'NONCE123' + + +@pytest.mark.django_db +def test_token_client_secret_post(env, client, ssoclient): + code = _authorization_step(client, ssoclient) + + r = client.post('/bigevents/oauth2/v1/token', { + 'client_id': ssoclient[0].client_id, + 'client_secret': ssoclient[1], + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': 'https://example.net' + }) + assert r.status_code == 200 + d = json.loads(r.content) + assert d['access_token'] + assert d['token_type'].lower() == 'bearer' + assert 86390 < d['expires_in'] <= 86400 + + +@pytest.mark.django_db +def test_authorize_implicit_flow(env, client, ssoclient): + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=id_token&' \ + f'state=STATE&' \ + f'nonce=NONCE123&' \ + f'scope=openid+profile+email' + r = client.get(url) + assert r.status_code == 302 + match = re.match(r'https://example.net#id_token=([^&]+)&state=STATE', r.headers['Location']) + assert match + + env[0].settings.flush() + decoded = jwt.decode(match.group(1), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"], + audience=ssoclient[0].client_id) + assert decoded['iss'] == 'http://example.com/bigevents' + assert decoded['aud'] == ssoclient[0].client_id + assert decoded['sub'] == "ABC123" + assert time.time() + 86390 < decoded['exp'] <= time.time() + 86400 + assert time.time() - 10 < decoded['iat'] <= time.time() + assert time.time() - 10 < decoded['auth_time'] <= time.time() + assert 'email' in decoded + assert decoded['nonce'] == 'NONCE123' + + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=id_token+token&' \ + f'state=STATE&' \ + f'nonce=NONCE123&' \ + f'scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + match = re.match(r'https://example.net#access_token=([^&]+)&token_type=Bearer&' + r'expires_in=([^&]+)&id_token=([^&]+)&state=STATE', r.headers['Location']) + assert match + assert 86390 < int(match.group(2)) <= 86400 + decoded = jwt.decode(match.group(3), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"], + audience=ssoclient[0].client_id) + assert decoded['at_hash'] == _hash_scheme(match.group(1)) + + +@pytest.mark.django_db +def test_authorize_hybrid_flow(env, client, ssoclient): + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code+id_token&' \ + f'state=STATE&' \ + f'nonce=NONCE123&' \ + f'scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + match = re.match(r'https://example.net#code=([^&]+)&id_token=([^&]+)&state=STATE', r.headers['Location']) + assert match + env[0].settings.flush() + decoded = jwt.decode(match.group(2), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"], + audience=ssoclient[0].client_id) + assert decoded['c_hash'] == _hash_scheme(match.group(1)) + + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code+id_token+token&' \ + f'state=STATE&' \ + f'nonce=NONCE123&' \ + f'scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + match = re.match(r'https://example.net#code=([^&]+)&access_token=([^&]+)&token_type=Bearer&' + r'expires_in=([^&]+)&id_token=([^&]+)&state=STATE', r.headers['Location']) + assert match + decoded = jwt.decode(match.group(4), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"], + audience=ssoclient[0].client_id) + assert decoded['c_hash'] == _hash_scheme(match.group(1)) + assert decoded['at_hash'] == _hash_scheme(match.group(2)) + + url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \ + f'redirect_uri=https://example.net&' \ + f'response_type=code+token&' \ + f'state=STATE&' \ + f'nonce=NONCE123&' \ + f'scope=openid+profile' + r = client.get(url) + assert r.status_code == 302 + match = re.match(r'https://example.net#code=([^&]+)&access_token=([^&]+)&token_type=Bearer&' + r'expires_in=([^&]+)&state=STATE', r.headers['Location']) + assert match + + +def _acquire_token(client, ssoclient): + code = _authorization_step(client, ssoclient) + + r = client.post('/bigevents/oauth2/v1/token', { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': 'https://example.net' + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode()) + assert r.status_code == 200 + d = json.loads(r.content) + return d['access_token'] + + +@pytest.mark.django_db +def test_userinfo_auth_and_claims(env, client, ssoclient): + ssoclient[0].allowed_scopes = ['openid', 'profile', 'phone'] + ssoclient[0].save() + + r = client.get('/bigevents/oauth2/v1/userinfo') + assert r.status_code == 401 + + r = client.get('/bigevents/oauth2/v1/userinfo', HTTP_AUTHORIZATION='Basic foo') + assert r.status_code == 400 + + r = client.get('/bigevents/oauth2/v1/userinfo', HTTP_AUTHORIZATION='Bearer invalid') + assert r.status_code == 401 + + token = _acquire_token(client, ssoclient) + + r = client.get('/bigevents/oauth2/v1/userinfo', HTTP_AUTHORIZATION=f'Bearer {token}') + assert r.status_code == 200 + + r = client.post('/bigevents/oauth2/v1/userinfo', {'access_token': token}) + assert r.status_code == 200 + + data = json.loads(r.content) + assert data == { + 'sub': 'ABC123', + 'locale': 'en', + 'name': 'John Doe', + 'given_name': 'John', + 'family_name': 'Doe', + 'phone_number': '+49 30 2270' + } + + +@pytest.mark.django_db +def test_config_endpoint(env, client, ssoclient): + r = client.get('/bigevents/.well-known/openid-configuration') + assert r.status_code == 200 + data = json.loads(r.content) + assert data['issuer'] == 'http://example.com/bigevents' + + +@pytest.mark.django_db +def test_keys_endpoint(env, client, ssoclient): + r = client.get('/bigevents/oauth2/v1/keys') + assert r.status_code == 200 + data = json.loads(r.content) + env[0].settings.flush() + + def decode_int(d: str): + b = d.encode() + padded = b + (b'=' * (-len(b) % 4)) + return int(hexlify(base64.urlsafe_b64decode(padded)), 16) + + e = decode_int(data['keys'][0]['e']) + n = decode_int(data['keys'][0]['n']) + pubkey = RSA.construct((n, e)) + representation = pubkey.export_key('PEM').decode() + assert representation.strip() == env[0].settings.sso_server_signing_key_rsa256_public.strip()