forked from CGM_Public/pretix_original
OpenID Connect OP support for customer accounts
This commit is contained in:
committed by
Raphael Michel
parent
7f5518dbf6
commit
a4171ef819
@@ -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",
|
||||
)
|
||||
|
||||
68
src/pretix/base/migrations/0220_auto_20220811_1002.py
Normal file
68
src/pretix/base/migrations/0220_auto_20220811_1002.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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$',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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', {})
|
||||
|
||||
@@ -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
|
||||
|
||||
526
src/pretix/presale/views/oidc_op.py
Normal file
526
src/pretix/presale/views/oidc_op.py
Normal 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,
|
||||
|
||||
})
|
||||
@@ -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.*',
|
||||
|
||||
585
src/tests/presale/test_oidc_op.py
Normal file
585
src/tests/presale/test_oidc_op.py
Normal 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()
|
||||
Reference in New Issue
Block a user