OpenID Connect OP support for customer accounts

This commit is contained in:
Raphael Michel
2022-08-10 14:22:30 +02:00
committed by Raphael Michel
parent 7f5518dbf6
commit a4171ef819
20 changed files with 1735 additions and 23 deletions

View File

@@ -19,17 +19,34 @@
# 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 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",
)

View File

@@ -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')),
],
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.'),

View File

@@ -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'),

View File

@@ -0,0 +1,25 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Delete SSO client:" %} {{ client.name }}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if is_allowed %}
<p>{% blocktrans %}Are you sure you want to delete this SSO client?{% endblocktrans %}
{% else %}
<p>{% blocktrans %}This SSO client cannot be deleted since it has already been used.{% endblocktrans %}
{% endif %}
</p>
<div class="form-group submit-group">
<a href="{% url "control:organizer.ssoclients" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if is_allowed %}
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
{% if client %}
<h1>{% trans "SSO client:" %} {{ client.name }}</h1>
{% else %}
<h1>{% trans "Create a new SSO client" %}</h1>
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "SSO clients" %}{% endblock %}
{% block inner %}
<h1>{% trans "SSO clients" %}</h1>
<p>
{% 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 %}
</p>
<a href="{% url "control:organizer.ssoclient.add" organizer=request.organizer.slug %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new SSO client" %}
</a>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><strong>
<a href="{% url "control:organizer.ssoclient.edit" organizer=request.organizer.slug client=c.id %}">
{% if not c.is_active %}<del>{% endif %}
{{ c.name }}
{% if not c.is_active %}</del>{% endif %}
</a>
</strong></td>
<td class="text-right flip">
<a href="{% url "control:organizer.ssoclient.edit" organizer=request.organizer.slug client=c.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.ssoclient.delete" organizer=request.organizer.slug client=c.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -140,6 +140,13 @@ urlpatterns = [
name='organizer.ssoprovider.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoprovider/(?P<provider>[^/]+)/delete$', organizer.SSOProviderDeleteView.as_view(),
name='organizer.ssoprovider.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoclients$', organizer.SSOClientListView.as_view(), name='organizer.ssoclients'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoclient/add$', organizer.SSOClientCreateView.as_view(),
name='organizer.ssoclient.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoclient/(?P<client>[^/]+)/edit$', organizer.SSOClientUpdateView.as_view(),
name='organizer.ssoclient.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ssoclient/(?P<client>[^/]+)/delete$', organizer.SSOClientDeleteView.as_view(),
name='organizer.ssoclient.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/add$',

View File

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

View File

@@ -22,7 +22,7 @@
</button>
</div>
{% endif %}
{% for provider in request.organizer.sso_providers.all %}
{% for provider in providers %}
{% if provider.is_active %}
<a href="{% eventurl request.organizer "presale:organizer.customer.login" provider=provider.pk %}?{{ request.META.QUERY_STRING }}"
class="btn btn-primary btn-lg btn-block">

View File

@@ -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<provider>[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'),
re_path(r'^account/login/(?P<provider>[0-9]+)/return$', pretix.presale.views.customer.SSOLoginReturnView.as_view(), name='organizer.customer.login.return'),
re_path(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
@@ -192,6 +196,17 @@ organizer_patterns = [
re_path(r'^account/addresses/(?P<id>\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'),
re_path(r'^account/profiles/(?P<id>\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 = [

View File

@@ -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', {})

View File

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

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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,
})

View File

@@ -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.*',

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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()