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,
})