Compare commits

..

3 Commits

Author SHA1 Message Date
Raphael Michel
a5beda77f8 Auto-detection 2025-02-21 12:56:42 +01:00
Raphael Michel
ef7f212063 OIDC: Implement PKCE as server 2025-02-07 18:02:43 +01:00
Raphael Michel
26a5eab47f OIDC: Implement PKCE in client implementation 2025-02-07 18:02:43 +01:00
27 changed files with 2127 additions and 3073 deletions

View File

@@ -75,9 +75,8 @@ positions list of objects List of order p
fees list of objects List of fees included in the order total. By default, only fees list of objects List of fees included in the order total. By default, only
non-canceled fees are included. non-canceled fees are included.
├ id integer Internal ID of the fee record ├ id integer Internal ID of the fee record
├ fee_type string Type of fee (currently ``payment``, ``shipping``, ├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``service``, ``cancellation``, ``insurance``, ``late``, ``other``)
``other``, ``giftcard``)
├ value money (string) Fee amount ├ value money (string) Fee amount
├ description string Human-readable string with more details (can be empty) ├ description string Human-readable string with more details (can be empty)
├ internal_type string Internal string (i.e. ID of the payment provider), ├ internal_type string Internal string (i.e. ID of the payment provider),
@@ -110,7 +109,6 @@ cancellation_date datetime Time of order c
Will not be set for partial cancellations and is not Will not be set for partial cancellations and is not
reliable for orders that have been cancelled, reliable for orders that have been cancelled,
reactivated and cancelled again. reactivated and cancelled again.
plugin_data object Additional data added by plugins.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -166,10 +164,6 @@ plugin_data object Additional data
The ``tax_code`` attribute has been added. The ``tax_code`` attribute has been added.
.. versionchanged:: 2025.2
The ``plugin_data`` attribute has been added.
.. _order-position-resource: .. _order-position-resource:
Order position resource Order position resource
@@ -257,7 +251,6 @@ seat objects The assigned se
pdf_data object Data object required for ticket PDF generation. By default, pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request. ``pdf_data=true`` query parameter to your request.
plugin_data object Additional data added by plugins.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 4.16 .. versionchanged:: 4.16
@@ -272,10 +265,6 @@ plugin_data object Additional data
The ``tax_code`` attribute has been added. The ``tax_code`` attribute has been added.
.. versionchanged:: 2025.2
The ``plugin_data`` attribute has been added.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -472,8 +461,7 @@ List of all orders
"output": "pdf", "output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
} }
], ]
"plugin_data": {}
} }
], ],
"downloads": [ "downloads": [
@@ -495,8 +483,7 @@ List of all orders
} }
], ],
"refunds": [], "refunds": [],
"cancellation_date": null, "cancellation_date": null
"plugin_data": {}
} }
] ]
} }
@@ -715,8 +702,7 @@ Fetching individual orders
"output": "pdf", "output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
} }
], ]
"plugin_data": {}
} }
], ],
"downloads": [ "downloads": [
@@ -738,8 +724,7 @@ Fetching individual orders
} }
], ],
"refunds": [], "refunds": [],
"cancellation_date": null, "cancellation_date": null
"plugin_data": {}
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -1686,8 +1671,7 @@ List of all order positions
"output": "pdf", "output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
} }
], ]
"plugin_data": {}
} }
] ]
} }
@@ -1814,8 +1798,7 @@ Fetching individual positions
"output": "pdf", "output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
} }
], ]
"plugin_data": {}
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -2245,9 +2228,6 @@ otherwise, such as splitting an order or changing fees.
* ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID. * ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID.
* ``create_fees``: A list of objects describing new order fees with the fields ``fee_type``, ``value``, ``description``,
``internal_type``, ``tax_rule``
* ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice * ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice
address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null`` address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null``
(the default) the taxes are not recalculated. (the default) the taxes are not recalculated.
@@ -2267,12 +2247,17 @@ otherwise, such as splitting an order or changing fees.
Content-Type: application/json Content-Type: application/json
{ {
"cancel_positions": [
{
"position": 12373
}
],
"patch_positions": [ "patch_positions": [
{ {
"position": 12374, "position": 12374,
"body": { "body": {
"item": 12, "item": 12,
"variation": null, "variation": None,
"subevent": 562, "subevent": 562,
"seat": "seat-guid-2", "seat": "seat-guid-2",
"price": "99.99", "price": "99.99",
@@ -2280,11 +2265,6 @@ otherwise, such as splitting an order or changing fees.
} }
} }
], ],
"cancel_positions": [
{
"position": 12373
}
],
"split_positions": [ "split_positions": [
{ {
"position": 12375 "position": 12375
@@ -2293,7 +2273,7 @@ otherwise, such as splitting an order or changing fees.
"create_positions": [ "create_positions": [
{ {
"item": 12, "item": 12,
"variation": null, "variation": None,
"subevent": 562, "subevent": 562,
"seat": "seat-guid-2", "seat": "seat-guid-2",
"price": "99.99", "price": "99.99",
@@ -2301,26 +2281,17 @@ otherwise, such as splitting an order or changing fees.
"attendee_name": "Peter", "attendee_name": "Peter",
} }
], ],
"patch_fees": [
{
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"cancel_fees": [ "cancel_fees": [
{ {
"fee": 49 "fee": 49
} }
], ],
"create_fees": [ "change_fees": [
{ {
"fee_type": "other", "fee": 51,
"value": "1.50", "body": {
"description": "Example Fee", "value": "12.00"
"internal_type": "", }
"tax_rule": 15
} }
], ],
"reissue_invoice": true, "reissue_invoice": true,

View File

@@ -103,4 +103,4 @@ API
.. automodule:: pretix.api.signals .. automodule:: pretix.api.signals
:no-index: :no-index:
:members: register_device_security_profile, order_api_details, orderposition_api_details :members: register_device_security_profile

View File

@@ -123,7 +123,7 @@ LANGUAGES_RTL = {
'ar', 'hw' 'ar', 'hw'
} }
LANGUAGES_INCUBATING = { LANGUAGES_INCUBATING = {
'pt-br', 'gl', 'fi', 'pt-br', 'gl',
} }
LANGUAGES = ALL_LANGUAGES LANGUAGES = ALL_LANGUAGES
LOCALE_PATHS = [ LOCALE_PATHS = [

View File

@@ -46,7 +46,6 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import ( from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer, InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
) )
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
@@ -495,18 +494,6 @@ class OrderPositionListSerializer(serializers.ListSerializer):
return data return data
class OrderPositionPluginDataField(serializers.Field):
def to_representation(self, value: OrderPosition):
d = {}
if value and value.pk:
for recv, resp in orderposition_api_details.send(
sender=self.context.get("event") or value.order.event,
orderposition=value
):
d.update(resp)
return d
class OrderPositionSerializer(I18nAwareModelSerializer): class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True, read_only=True) checkins = CheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True) print_logs = PrintLogSerializer(many=True, read_only=True)
@@ -517,7 +504,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
seat = InlineSeatSerializer(read_only=True) seat = InlineSeatSerializer(read_only=True)
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False) attendee_name = serializers.CharField(required=False)
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
class Meta: class Meta:
list_serializer_class = OrderPositionListSerializer list_serializer_class = OrderPositionListSerializer
@@ -527,7 +513,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', 'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
'print_logs', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'pdf_data', 'seat', 'print_logs', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'pdf_data', 'seat',
'canceled', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use', 'plugin_data') 'canceled', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
read_only_fields = ( read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id',
@@ -744,18 +730,6 @@ class OrderListSerializer(serializers.ListSerializer):
return data return data
class OrderPluginDataField(serializers.Field):
def to_representation(self, value: Order):
d = {}
if value and value.pk:
for recv, resp in order_api_details.send(
sender=self.context.get("event") or value.event,
order=value
):
d.update(resp)
return d
class OrderSerializer(I18nAwareModelSerializer): class OrderSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True) event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True) invoice_address = InvoiceAddressSerializer(allow_null=True)
@@ -773,7 +747,6 @@ class OrderSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(), queryset=SalesChannel.objects.none(),
required=False, required=False,
) )
plugin_data = OrderPluginDataField(source='*', allow_null=True, read_only=True)
class Meta: class Meta:
model = Order model = Order
@@ -782,7 +755,7 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date'
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',

View File

@@ -30,7 +30,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField, AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderFeeCreateSerializer, OrderPositionCreateSerializer, OrderPositionCreateSerializer,
) )
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError from pretix.base.services.orders import OrderError
@@ -104,54 +104,6 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
raise ValidationError(str(e)) raise ValidationError(str(e))
class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
value = serializers.DecimalField(required=True, allow_null=False, decimal_places=2,
max_digits=13)
internal_type = serializers.CharField(required=False, default="")
class Meta:
model = OrderFee
fields = ('order', 'fee_type', 'value', 'description', 'internal_type', 'tax_rule')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['order'].queryset = self.context['event'].orders.all()
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
if 'order' in self.context:
del self.fields['order']
def validate(self, data):
data = super().validate(data)
if 'order' in self.context:
data['order'] = self.context['order']
return data
def create(self, validated_data):
ocm = self.context['ocm']
try:
f = OrderFee(
order=validated_data['order'],
fee_type=validated_data['fee_type'],
value=validated_data.get('value'),
description=validated_data.get('description'),
internal_type=validated_data.get('internal_type'),
tax_rule=validated_data.get('tax_rule'),
)
f._calculate_tax()
ocm.add_fee(f)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].fees.order_by('-pk').first()
else:
return OrderFee() # fake to appease DRF
except OrderError as e:
raise ValidationError(str(e))
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer): class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
@@ -449,9 +401,6 @@ class OrderChangeOperationSerializer(serializers.Serializer):
self.fields['split_positions'] = SelectPositionSerializer( self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context many=True, required=False, context=self.context
) )
self.fields['create_fees'] = OrderFeeCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer( self.fields['patch_fees'] = PatchFeeSerializer(
many=True, required=False, context=self.context many=True, required=False, context=self.context
) )

View File

@@ -26,7 +26,7 @@ from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import EventPluginSignal, periodic_task from pretix.base.signals import periodic_task
from pretix.helpers.periodic import minimum_interval from pretix.helpers.periodic import minimum_interval
register_webhook_events = Signal() register_webhook_events = Signal()
@@ -43,28 +43,6 @@ return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurit
or a list of such instances. or a list of such instances.
""" """
order_api_details = EventPluginSignal()
"""
Arguments: ``order``
This signal is sent out to fill the ``plugin_details`` field of the order API. Receivers
should return a dictionary that is combined with the dictionaries of all other plugins.
Note that doing database or network queries in receivers to this signal is discouraged
and could cause serious performance issues. The main purpose is to provide information
from e.g. ``meta_info`` to the API consumer,
"""
orderposition_api_details = EventPluginSignal()
"""
Arguments: ``orderposition``
This signal is sent out to fill the ``plugin_details`` field of the order API. Receivers
should return a dictionary that is combined with the dictionaries of all other plugins.
Note that doing database or network queries in receivers to this signal is discouraged
and could cause serious performance issues. The main purpose is to provide information
from e.g. ``meta_info`` to the API consumer,
"""
@receiver(periodic_task) @receiver(periodic_task)
@scopes_disabled() @scopes_disabled()

View File

@@ -63,8 +63,7 @@ from pretix.api.serializers.order import (
) )
from pretix.api.serializers.orderchange import ( from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer, BlockNameSerializer, OrderChangeOperationSerializer,
OrderFeeChangeSerializer, OrderFeeCreateForExistingOrderSerializer, OrderFeeChangeSerializer, OrderPositionChangeSerializer,
OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer, OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer, OrderPositionInfoPatchSerializer,
) )
@@ -989,12 +988,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
ocm.cancel_fee(r['fee']) ocm.cancel_fee(r['fee'])
canceled_fees.add(r['fee']) canceled_fees.add(r['fee'])
for r in serializer.validated_data.get('create_fees', []):
pos_serializer = OrderFeeCreateForExistingOrderSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.create(r)
for r in serializer.validated_data.get('patch_fees', []): for r in serializer.validated_data.get('patch_fees', []):
if r['fee'] in canceled_fees: if r['fee'] in canceled_fees:
continue continue

View File

@@ -148,7 +148,7 @@ def oidc_validate_and_complete_config(config):
return config return config
def oidc_authorize_url(provider, state, redirect_uri): def oidc_authorize_url(provider, state, redirect_uri, pkce_code_verifier):
endpoint = provider.configuration['provider_config']['authorization_endpoint'] endpoint = provider.configuration['provider_config']['authorization_endpoint']
params = { params = {
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
@@ -163,10 +163,14 @@ def oidc_authorize_url(provider, state, redirect_uri):
if "query_parameters" in provider.configuration and provider.configuration["query_parameters"]: if "query_parameters" in provider.configuration and provider.configuration["query_parameters"]:
params.update(parse_qsl(provider.configuration["query_parameters"])) params.update(parse_qsl(provider.configuration["query_parameters"]))
if pkce_code_verifier and "S256" in provider.configuration['provider_config'].get('code_challenge_methods_supported', []):
params["code_challenge"] = base64.urlsafe_b64encode(hashlib.sha256(pkce_code_verifier.encode()).digest()).decode().rstrip("=")
params["code_challenge_method"] = "S256"
return endpoint + '?' + urlencode(params) return endpoint + '?' + urlencode(params)
def oidc_validate_authorization(provider, code, redirect_uri): def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier):
endpoint = provider.configuration['provider_config']['token_endpoint'] endpoint = provider.configuration['provider_config']['token_endpoint']
# Wall of shame and RFC ignorant IDPs # Wall of shame and RFC ignorant IDPs
@@ -188,6 +192,9 @@ def oidc_validate_authorization(provider, code, redirect_uri):
'redirect_uri': redirect_uri, 'redirect_uri': redirect_uri,
} }
if pkce_code_verifier and "S256" in provider.configuration['provider_config'].get('code_challenge_methods_supported', []):
params["code_verifier"] = pkce_code_verifier
if token_endpoint_auth_method == 'client_secret_post': if token_endpoint_auth_method == 'client_secret_post':
params['client_id'] = provider.configuration['client_id'] params['client_id'] = provider.configuration['client_id']
params['client_secret'] = provider.configuration['client_secret'] params['client_secret'] = provider.configuration['client_secret']

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.17 on 2025-02-07 16:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0276_item_hidden_if_item_available_mode"),
]
operations = [
migrations.AddField(
model_name="customerssoclient",
name="require_pkce",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="customerssogrant",
name="code_challenge",
field=models.TextField(null=True),
),
migrations.AddField(
model_name="customerssogrant",
name="code_challenge_method",
field=models.CharField(max_length=255, null=True),
),
]

View File

@@ -416,6 +416,10 @@ class CustomerSSOClient(LoggedModel):
authorization_grant_type = models.CharField( authorization_grant_type = models.CharField(
max_length=32, choices=GRANT_TYPES, verbose_name=_("Grant type"), default=GRANT_AUTHORIZATION_CODE, max_length=32, choices=GRANT_TYPES, verbose_name=_("Grant type"), default=GRANT_AUTHORIZATION_CODE,
) )
require_pkce = models.BooleanField(
verbose_name=_("Require PKCE extension"),
default=False,
)
redirect_uris = models.TextField( redirect_uris = models.TextField(
blank=False, blank=False,
verbose_name=_("Redirection URIs"), verbose_name=_("Redirection URIs"),
@@ -481,6 +485,8 @@ class CustomerSSOGrant(models.Model):
expires = models.DateTimeField() expires = models.DateTimeField()
redirect_uri = models.TextField() redirect_uri = models.TextField()
scope = models.TextField(blank=True) scope = models.TextField(blank=True)
code_challenge = models.TextField(blank=True, null=True)
code_challenge_method = models.CharField(max_length=255, blank=True, null=True)
class CustomerSSOAccessToken(models.Model): class CustomerSSOAccessToken(models.Model):

View File

@@ -1113,7 +1113,7 @@ class SSOClientForm(I18nModelForm):
class Meta: class Meta:
model = CustomerSSOClient model = CustomerSSOClient
fields = ['is_active', 'name', 'client_id', 'client_type', 'authorization_grant_type', 'redirect_uris', fields = ['is_active', 'name', 'client_id', 'client_type', 'authorization_grant_type', 'redirect_uris',
'allowed_scopes'] 'allowed_scopes', 'require_pkce']
widgets = { widgets = {
'authorization_grant_type': forms.RadioSelect, 'authorization_grant_type': forms.RadioSelect,
'client_type': forms.RadioSelect, 'client_type': forms.RadioSelect,

View File

@@ -325,7 +325,7 @@ class OrderChangedSplitFrom(OrderLogEntryType):
}) })
class CheckinErrorLogEntryType(OrderLogEntryType): class CheckinErrorLogEntryType(OrderLogEntryType):
def display(self, logentry: LogEntry, data): def display(self, logentry: LogEntry, data):
return self.display_plain(self.plain, logentry, data) self.display_plain(self.plain, logentry, data)
def display_plain(self, plain, logentry: LogEntry, data): def display_plain(self, plain, logentry: LogEntry, data):
if isinstance(plain, tuple): if isinstance(plain, tuple):

View File

@@ -125,15 +125,6 @@
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %} {% trans "Save" %}
</button> </button>
{% if voucher.pk %}
<div class="pull-left">
<a href="{% url "control:event.voucher.delete" organizer=request.organizer.slug event=request.event.slug voucher=voucher.pk %}"
class="btn btn-danger btn-lg">
<span class="fa fa-trash"></span>
{% trans "Delete voucher" %}
</a>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</form> </form>

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-29 13:18+0000\n" "POT-Creation-Date: 2025-01-29 13:18+0000\n"
"PO-Revision-Date: 2025-02-06 18:00+0000\n" "PO-Revision-Date: 2025-02-05 20:00+0000\n"
"Last-Translator: 조정화 <junghwa.jo@om.org>\n" "Last-Translator: 조정화 <junghwa.jo@om.org>\n"
"Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/ko/" "Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/ko/"
">\n" ">\n"
@@ -516,53 +516,52 @@ msgstr "이벤트 생성"
#: pretix/api/webhooks.py:329 #: pretix/api/webhooks.py:329
msgid "Event details changed" msgid "Event details changed"
msgstr "이벤트 세부 정보가 변경되었습니다" msgstr ""
#: pretix/api/webhooks.py:333 #: pretix/api/webhooks.py:333
msgid "Event deleted" msgid "Event deleted"
msgstr "이벤트 삭제" msgstr ""
#: pretix/api/webhooks.py:337 #: pretix/api/webhooks.py:337
msgctxt "subevent" msgctxt "subevent"
msgid "Event series date added" msgid "Event series date added"
msgstr "이벤트 시리즈 날짜 추가" msgstr ""
#: pretix/api/webhooks.py:341 #: pretix/api/webhooks.py:341
msgctxt "subevent" msgctxt "subevent"
msgid "Event series date changed" msgid "Event series date changed"
msgstr "이벤트 시리즈 날짜 변경" msgstr ""
#: pretix/api/webhooks.py:345 #: pretix/api/webhooks.py:345
msgctxt "subevent" msgctxt "subevent"
msgid "Event series date deleted" msgid "Event series date deleted"
msgstr "이벤트 시리즈 날짜 삭제" msgstr ""
#: pretix/api/webhooks.py:349 #: pretix/api/webhooks.py:349
msgid "" msgid ""
"Product changed (including product added or deleted and including changes to " "Product changed (including product added or deleted and including changes to "
"nested objects like variations or bundles)" "nested objects like variations or bundles)"
msgstr "제품 변경(제품 추가 또는 삭제, 변형 또는 번들과 같은 중첩된 객체에 대한 변경 " msgstr ""
"포함)"
#: pretix/api/webhooks.py:354 #: pretix/api/webhooks.py:354
msgid "Shop taken live" msgid "Shop taken live"
msgstr "라이브 촬영 상점" msgstr ""
#: pretix/api/webhooks.py:358 #: pretix/api/webhooks.py:358
msgid "Shop taken offline" msgid "Shop taken offline"
msgstr "오프라인 쇼핑" msgstr ""
#: pretix/api/webhooks.py:362 #: pretix/api/webhooks.py:362
msgid "Test-Mode of shop has been activated" msgid "Test-Mode of shop has been activated"
msgstr "상점의 테스트 모드가 활성화되었습니다" msgstr ""
#: pretix/api/webhooks.py:366 #: pretix/api/webhooks.py:366
msgid "Test-Mode of shop has been deactivated" msgid "Test-Mode of shop has been deactivated"
msgstr "상점의 테스트 모드가 비활성화되었습니다" msgstr ""
#: pretix/api/webhooks.py:370 #: pretix/api/webhooks.py:370
msgid "Waiting list entry added" msgid "Waiting list entry added"
msgstr "대기자 명단 항목 추가" msgstr ""
#: pretix/api/webhooks.py:374 #: pretix/api/webhooks.py:374
msgid "Waiting list entry changed" msgid "Waiting list entry changed"

View File

@@ -292,11 +292,11 @@ def _handle_transaction(trans: BankTransaction, matches: tuple, event: Event = N
trans.save() trans.save()
def parse_date(date_str, region=None): def parse_date(date_str):
try: try:
return dateutil.parser.parse( return dateutil.parser.parse(
date_str, date_str,
dayfirst="." in date_str or region in ["GB"], dayfirst="." in date_str,
).date() ).date()
except (ValueError, OverflowError): except (ValueError, OverflowError):
pass pass
@@ -339,7 +339,7 @@ def _get_unknown_transactions(job: BankImportJob, data: list, event: Event = Non
external_id=row.get('external_id'), external_id=row.get('external_id'),
currency=event.currency if event else job.currency) currency=event.currency if event else job.currency)
trans.date_parsed = parse_date(trans.date, (event and event.settings.region) or (organizer and organizer.settings.region) or None) trans.date_parsed = parse_date(trans.date)
trans.checksum = trans.calculate_checksum() trans.checksum = trans.calculate_checksum()
if trans.checksum not in known_checksums and (not trans.external_id or (trans.external_id, trans.date, trans.amount) not in known_by_external_id): if trans.checksum not in known_checksums and (not trans.external_id or (trans.external_id, trans.date, trans.amount) not in known_by_external_id):

View File

@@ -676,6 +676,8 @@ class SSOLoginView(RedirectBackMixin, View):
popup_origin = None popup_origin = None
nonce = get_random_string(32) nonce = get_random_string(32)
pkce_code_verifier = get_random_string(64)
request.session[f'pretix_customerauth_{self.provider.pk}_pkce_code_verifier'] = pkce_code_verifier
request.session[f'pretix_customerauth_{self.provider.pk}_nonce'] = nonce request.session[f'pretix_customerauth_{self.provider.pk}_nonce'] = nonce
request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin'] = popup_origin request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin'] = popup_origin
request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'] = self.request.GET.get("request_cross_domain_customer_auth") == "true" request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'] = self.request.GET.get("request_cross_domain_customer_auth") == "true"
@@ -684,7 +686,7 @@ class SSOLoginView(RedirectBackMixin, View):
}) })
if self.provider.method == "oidc": if self.provider.method == "oidc":
return redirect_to_url(oidc_authorize_url(self.provider, f'{nonce}%{next_url}', redirect_uri)) return redirect_to_url(oidc_authorize_url(self.provider, f'{nonce}%{next_url}', redirect_uri, pkce_code_verifier))
else: else:
raise Http404("Unknown SSO method.") raise Http404("Unknown SSO method.")
@@ -718,6 +720,7 @@ class SSOLoginReturnView(RedirectBackMixin, View):
) )
return HttpResponseRedirect(redirect_to) return HttpResponseRedirect(redirect_to)
r = super().dispatch(request, *args, **kwargs) r = super().dispatch(request, *args, **kwargs)
request.session.pop(f'pretix_customerauth_{self.provider.pk}_pkce_code_verifier', None)
request.session.pop(f'pretix_customerauth_{self.provider.pk}_nonce', None) request.session.pop(f'pretix_customerauth_{self.provider.pk}_nonce', None)
request.session.pop(f'pretix_customerauth_{self.provider.pk}_popup_origin', None) request.session.pop(f'pretix_customerauth_{self.provider.pk}_popup_origin', None)
request.session.pop(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested', None) request.session.pop(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested', None)
@@ -763,6 +766,7 @@ class SSOLoginReturnView(RedirectBackMixin, View):
self.provider, self.provider,
request.GET.get('code'), request.GET.get('code'),
redirect_uri, redirect_uri,
request.session.get(f'pretix_customerauth_{self.provider.pk}_pkce_code_verifier'),
) )
except ValidationError as e: except ValidationError as e:
for msg in e: for msg in e:

View File

@@ -72,6 +72,9 @@ We currently do not implement the following optional parts of the spec:
We also implement the Discovery extension (without issuer discovery) We also implement the Discovery extension (without issuer discovery)
as per https://openid.net/specs/openid-connect-discovery-1_0.html as per https://openid.net/specs/openid-connect-discovery-1_0.html
We also implement the PKCE extension for OAuth:
https://www.rfc-editor.org/rfc/rfc7636
The implementation passed the certification tests against the following profiles, but we did not The implementation passed the certification tests against the following profiles, but we did not
acquire formal certification: acquire formal certification:
@@ -136,19 +139,22 @@ class AuthorizeView(View):
self._construct_redirect_uri(redirect_uri, response_mode, qs) self._construct_redirect_uri(redirect_uri, response_mode, qs)
) )
def _require_login(self, request, client, scope, redirect_uri, response_type, response_mode, state, nonce): def _require_login(self, request, client, scope, redirect_uri, response_type, response_mode, state, nonce,
code_challenge, code_challenge_method):
form = AuthenticationForm(data=request.POST if "login-email" in request.POST else None, request=request, form = AuthenticationForm(data=request.POST if "login-email" in request.POST else None, request=request,
prefix="login") prefix="login")
if "login-email" in request.POST and form.is_valid(): if "login-email" in request.POST and form.is_valid():
customer_login(request, form.get_customer()) customer_login(request, form.get_customer())
return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, form.get_customer()) return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce,
code_challenge, code_challenge_method, form.get_customer())
else: else:
return render(request, 'pretixpresale/organizers/customer_login.html', { return render(request, 'pretixpresale/organizers/customer_login.html', {
'providers': [], 'providers': [],
'form': form, 'form': form,
}) })
def _success(self, client, scope, redirect_uri, response_type, response_mode, state, nonce, customer): def _success(self, client, scope, redirect_uri, response_type, response_mode, state, nonce, code_challenge,
code_challenge_method, customer):
response_type = response_type.split(' ') response_type = response_type.split(' ')
qs = {} qs = {}
id_token_kwargs = {} id_token_kwargs = {}
@@ -162,6 +168,8 @@ class AuthorizeView(View):
expires=now() + timedelta(minutes=10), expires=now() + timedelta(minutes=10),
auth_time=get_customer_auth_time(self.request), auth_time=get_customer_auth_time(self.request),
nonce=nonce, nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
) )
qs['code'] = grant.code qs['code'] = grant.code
id_token_kwargs['with_code'] = grant.code id_token_kwargs['with_code'] = grant.code
@@ -209,6 +217,8 @@ class AuthorizeView(View):
prompt = request_data.get("prompt") prompt = request_data.get("prompt")
response_type = request_data.get("response_type") response_type = request_data.get("response_type")
scope = request_data.get("scope", "").split(" ") scope = request_data.get("scope", "").split(" ")
code_challenge = request_data.get("code_challenge")
code_challenge_method = request_data.get("code_challenge_method")
if not client_id: if not client_id:
return self._final_error("invalid_request", "client_id missing") return self._final_error("invalid_request", "client_id missing")
@@ -244,7 +254,17 @@ class AuthorizeView(View):
response_mode, state) response_mode, state)
if "id_token_hint" in request_data: if "id_token_hint" in request_data:
return self._redirect_error("invalid_request", "id_token_hint currently not supported by this server", self._redirect_error("invalid_request", "id_token_hint currently not supported by this server",
redirect_uri, response_mode, state)
if code_challenge and code_challenge_method != "S256":
# "Clients re permitted to use "plain" only if they cannot support "S256" for some technical reason and
# know via out-of-band configuration that the S256 MUST be implemented, plain is not mandatory."
return self._redirect_error("invalid_request", "code_challenge transform algorithm not supported",
redirect_uri, response_mode, state)
if client.require_pkce and not code_challenge:
return self._redirect_error("invalid_request", "code_challenge (PKCE) required",
redirect_uri, response_mode, state) redirect_uri, response_mode, state)
has_valid_session = bool(request.customer) has_valid_session = bool(request.customer)
@@ -252,8 +272,7 @@ class AuthorizeView(View):
try: try:
has_valid_session = int(time.time() - get_customer_auth_time(request)) < int(max_age) has_valid_session = int(time.time() - get_customer_auth_time(request)) < int(max_age)
except ValueError: except ValueError:
return self._redirect_error("invalid_request", "invalid max_age value", redirect_uri, self._redirect_error("invalid_request", "invalid max_age value", redirect_uri, response_mode, state)
response_mode, state)
if not has_valid_session and prompt and prompt == "none": 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", return self._redirect_error("interaction_required", "user is not logged in but no prompt is allowed",
@@ -262,9 +281,11 @@ class AuthorizeView(View):
has_valid_session = False has_valid_session = False
if has_valid_session: if has_valid_session:
return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, request.customer) return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, code_challenge,
code_challenge_method, request.customer)
else: else:
return self._require_login(request, client, scope, redirect_uri, response_type, response_mode, state, nonce) return self._require_login(request, client, scope, redirect_uri, response_type, response_mode, state, nonce,
code_challenge, code_challenge_method)
class TokenView(View): class TokenView(View):
@@ -362,6 +383,24 @@ class TokenView(View):
"error_description": "Mismatch of redirect_uri" "error_description": "Mismatch of redirect_uri"
}, status=400) }, status=400)
if grant.code_challenge:
if not request.POST.get("code_verifier"):
return JsonResponse({
"error": "invalid_grant",
"error_description": "Missing of code_verifier"
}, status=400)
if grant.code_challenge_method == "S256":
expected_challenge = base64.urlsafe_b64encode(hashlib.sha256(request.POST["code_verifier"].encode()).digest()).decode().rstrip("=")
print(grant.code_challenge, expected_challenge)
if expected_challenge != grant.code_challenge:
return JsonResponse({
"error": "invalid_grant",
"error_description": "Mismatch of code_verifier with code_challenge"
}, status=400)
else:
raise ValueError("Unsupported code_challenge_method in database")
with transaction.atomic(): with transaction.atomic():
token = self.client.access_tokens.create( token = self.client.access_tokens.create(
customer=grant.customer, customer=grant.customer,
@@ -503,6 +542,7 @@ class ConfigurationView(View):
'token_endpoint_auth_methods_supported': [ 'token_endpoint_auth_methods_supported': [
'client_secret_post', 'client_secret_basic' 'client_secret_post', 'client_secret_basic'
], ],
'code_challenge_methods_supported': ['S256'],
'claims_supported': [ 'claims_supported': [
'iss', 'iss',
'aud', 'aud',

View File

@@ -162,7 +162,6 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard):
"tax_rule": None, "tax_rule": None,
"pseudonymization_id": op.pseudonymization_id, "pseudonymization_id": op.pseudonymization_id,
"pdf_data": {}, "pdf_data": {},
"plugin_data": {},
"seat": None, "seat": None,
"canceled": False, "canceled": False,
"valid_from": None, "valid_from": None,

View File

@@ -1797,13 +1797,6 @@ def test_order_change_cancel_and_create(token_client, organizer, event, order, q
'price': '99.99' 'price': '99.99'
}, },
], ],
'create_fees': [
{
'value': '5.99',
'fee_type': 'service',
'description': 'Service fee',
},
],
'cancel_fees': [ 'cancel_fees': [
{ {
'fee': f.pk, 'fee': f.pk,
@@ -1825,11 +1818,6 @@ def test_order_change_cancel_and_create(token_client, organizer, event, order, q
assert p_new.price == Decimal('99.99') assert p_new.price == Decimal('99.99')
f.refresh_from_db() f.refresh_from_db()
assert f.canceled assert f.canceled
f_new = order.all_fees.get(fee_type=OrderFee.FEE_TYPE_SERVICE)
assert f_new.value == Decimal('5.99')
assert f_new.description == "Service fee"
assert f_new.internal_type == ""
assert f_new.tax_value == Decimal('0.00')
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -477,8 +477,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'zipcode': None, 'zipcode': None,
'state': None, 'state': None,
'country': None, 'country': None,
'canceled': False, 'canceled': False
'plugin_data': {},
} }
], ],
'downloads': [], 'downloads': [],
@@ -488,8 +487,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'refunds': [], 'refunds': [],
'require_approval': False, 'require_approval': False,
'sales_channel': 'web', 'sales_channel': 'web',
'cancellation_date': None, 'cancellation_date': None
'plugin_data': {},
} }
@@ -535,15 +533,13 @@ def test_order_create_positionids_addons_simulated(token_client, organizer, even
'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None,
'voucher': None, 'tax_rate': '19.00', 'tax_code': 'S/standard', 'tax_value': '3.67', 'discount': None, 'voucher_budget_use': None, 'voucher': None, 'tax_rate': '19.00', 'tax_code': 'S/standard', 'tax_value': '3.67', 'discount': None, 'voucher_budget_use': None,
'addon_to': None, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': item.tax_rule_id, 'addon_to': None, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': item.tax_rule_id,
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None, 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None},
'plugin_data': {}},
{'id': 0, 'order': '', 'positionid': 2, 'item': item.pk, 'variation': None, 'price': '23.00', {'id': 0, 'order': '', 'positionid': 2, 'item': item.pk, 'variation': None, 'price': '23.00',
'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None,
'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None,
'voucher': None, 'tax_rate': '19.00', 'tax_code': 'S/standard', 'tax_value': '3.67', 'discount': None, 'voucher_budget_use': None, 'voucher': None, 'tax_rate': '19.00', 'tax_code': 'S/standard', 'tax_value': '3.67', 'discount': None, 'voucher_budget_use': None,
'addon_to': 1, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': item.tax_rule_id, 'addon_to': 1, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': item.tax_rule_id,
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None, 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None}
'plugin_data': {}}
] ]

View File

@@ -236,7 +236,6 @@ TEST_ORDERPOSITION_RES = {
], ],
"subevent": None, "subevent": None,
"canceled": False, "canceled": False,
"plugin_data": {},
} }
TEST_PAYMENTS_RES = [ TEST_PAYMENTS_RES = [
{ {
@@ -334,7 +333,6 @@ TEST_ORDER_RES = {
"payments": TEST_PAYMENTS_RES, "payments": TEST_PAYMENTS_RES,
"refunds": TEST_REFUNDS_RES, "refunds": TEST_REFUNDS_RES,
"cancellation_date": None, "cancellation_date": None,
"plugin_data": {},
} }

View File

@@ -203,8 +203,7 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
"canceled": False, "canceled": False,
"valid_from": None, "valid_from": None,
"valid_until": None, "valid_until": None,
"blocked": None, "blocked": None
"plugin_data": {},
} }
assert resp.data["linked_giftcard"] == { assert resp.data["linked_giftcard"] == {
"id": giftcard.pk, "id": giftcard.pk,

View File

@@ -236,7 +236,8 @@ def provider(organizer):
"response_modes_supported": ["query"], "response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code"], "grant_types_supported": ["authorization_code"],
"scopes_supported": ["openid", "email", "profile"], "scopes_supported": ["openid", "email", "profile"],
"claims_supported": ["email", "sub"] "claims_supported": ["email", "sub"],
"code_challenge_methods_supported": ["plain", "S256"],
} }
} }
) )
@@ -244,6 +245,21 @@ def provider(organizer):
@pytest.mark.django_db @pytest.mark.django_db
def test_authorize_url(provider): def test_authorize_url(provider):
assert (
"https://example.com/authorize?"
"response_type=code&"
"client_id=abc123&"
"scope=openid+email+profile&"
"state=state_val&"
"redirect_uri=https%3A%2F%2Fredirect%3Ffoo%3Dbar&"
"code_challenge=S1ZnvzwMZHrWOO62nENdJ6jhODhf7VfyZFBIXQyrTKo&"
"code_challenge_method=S256"
) == oidc_authorize_url(provider, "state_val", "https://redirect?foo=bar", "pkce_value")
@pytest.mark.django_db
def test_authorize_url_no_pkce(provider):
del provider.configuration["provider_config"]["code_challenge_methods_supported"]
assert ( assert (
"https://example.com/authorize?" "https://example.com/authorize?"
"response_type=code&" "response_type=code&"
@@ -251,7 +267,7 @@ def test_authorize_url(provider):
"scope=openid+email+profile&" "scope=openid+email+profile&"
"state=state_val&" "state=state_val&"
"redirect_uri=https%3A%2F%2Fredirect%3Ffoo%3Dbar" "redirect_uri=https%3A%2F%2Fredirect%3Ffoo%3Dbar"
) == oidc_authorize_url(provider, "state_val", "https://redirect?foo=bar") ) == oidc_authorize_url(provider, "state_val", "https://redirect?foo=bar", "pkce_value")
@pytest.mark.django_db @pytest.mark.django_db
@@ -264,7 +280,7 @@ def test_validate_authorization_invalid(provider):
status=400, status=400,
) )
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar") oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar", "pkce_value")
@pytest.mark.django_db @pytest.mark.django_db
@@ -281,6 +297,7 @@ def test_validate_authorization_userinfo_invalid(provider):
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": "code_received", "code": "code_received",
"redirect_uri": "https://redirect?foo=bar", "redirect_uri": "https://redirect?foo=bar",
"code_verifier": "pkce_value",
}) })
], ],
) )
@@ -296,7 +313,7 @@ def test_validate_authorization_userinfo_invalid(provider):
], ],
) )
with pytest.raises(ValidationError) as e: with pytest.raises(ValidationError) as e:
oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar") oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar", "pkce_value")
assert 'could not fetch' in str(e.value) assert 'could not fetch' in str(e.value)
@@ -314,6 +331,7 @@ def test_validate_authorization_valid(provider):
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": "code_received", "code": "code_received",
"redirect_uri": "https://redirect?foo=bar", "redirect_uri": "https://redirect?foo=bar",
"code_verifier": "pkce_value",
}) })
], ],
) )
@@ -328,4 +346,4 @@ def test_validate_authorization_valid(provider):
matchers.header_matcher({"Authorization": "Bearer test_access_token"}) matchers.header_matcher({"Authorization": "Bearer test_access_token"})
], ],
) )
oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar") oidc_validate_authorization(provider, "code_received", "https://redirect?foo=bar", "pkce_value")

View File

@@ -790,33 +790,3 @@ def test_ignore_by_external_id(env, job):
}]) }])
with scopes_disabled(): with scopes_disabled():
assert BankTransaction.objects.count() == 2 assert BankTransaction.objects.count() == 2
@pytest.mark.django_db
def test_ambigious_date_without_region(env, job):
process_banktransfers(job, [{
'payer': 'Karla Kundin',
'reference': 'Bestellung DUMMY1Z3AS',
'date': '03/05/2016',
'amount': '23.00'
}])
env[2].refresh_from_db()
with scopes_disabled():
assert env[2].payments.last().info_data["date"] == "2016-03-05"
@pytest.mark.django_db
def test_ambigious_date_with_region(env, job):
env[0].settings.region = "GB"
process_banktransfers(job, [{
'payer': 'Karla Kundin',
'reference': 'Bestellung DUMMY1Z3AS',
'date': '03/05/2016',
'amount': '23.00'
}])
env[2].refresh_from_db()
with scopes_disabled():
assert env[2].payments.last().info_data["date"] == "2016-05-03"

View File

@@ -40,5 +40,3 @@ def test_date_formats():
assert dt == parse_date("2020-07-01") assert dt == parse_date("2020-07-01")
assert dt == parse_date("2020-7-1") assert dt == parse_date("2020-7-1")
assert dt == parse_date("01/07/2020", "GB")

View File

@@ -208,6 +208,37 @@ def test_authorize_with_prompt_none(env, client, ssoclient):
assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location']) 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_invalid_pkce_method(env, client, ssoclient):
url = f'/bigevents/oauth2/v1/authorize?' \
f'client_id={ssoclient[0].client_id}&' \
f'redirect_uri=https://example.net&' \
f'response_type=code&state=STATE&scope=openid+profile&' \
f'code_challenge=pkce_value&code_challenge_method=plain'
r = client.get(url)
assert r.status_code == 302
assert r.headers['Location'] == 'https://example.net?' \
'error=invalid_request&' \
'error_description=code_challenge+transform+algorithm+not+supported&' \
'state=STATE'
@pytest.mark.django_db
def test_authorize_with_missing_pkce_if_required(env, client, ssoclient):
ssoclient[0].require_pkce = True
ssoclient[0].save()
url = f'/bigevents/oauth2/v1/authorize?' \
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=invalid_request&' \
'error_description=code_challenge+%28PKCE%29+required&' \
'state=STATE'
@pytest.mark.django_db @pytest.mark.django_db
def test_authorize_require_login_if_prompt_requires_it_or_is_expired(env, client, ssoclient): 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"): with freeze_time("2021-04-10T11:00:00+02:00"):
@@ -286,7 +317,7 @@ def test_token_require_client_id(env, client, ssoclient):
assert b'unsupported_grant_type' in r.content assert b'unsupported_grant_type' in r.content
def _authorization_step(client, ssoclient): def _authorization_step(client, ssoclient, code_challenge=None):
r = client.post('/bigevents/account/login', { r = client.post('/bigevents/account/login', {
'email': 'john@example.org', 'email': 'john@example.org',
'password': 'foo', 'password': 'foo',
@@ -299,6 +330,8 @@ def _authorization_step(client, ssoclient):
f'client_id={ssoclient[0].client_id}&' \ f'client_id={ssoclient[0].client_id}&' \
f'redirect_uri=https://example.net&' \ f'redirect_uri=https://example.net&' \
f'response_type=code&state=STATE&scope=openid+profile+email+phone' f'response_type=code&state=STATE&scope=openid+profile+email+phone'
if code_challenge:
url += f'&code_challenge={code_challenge}&code_challenge_method=S256'
r = client.get(url) r = client.get(url)
assert r.status_code == 302 assert r.status_code == 302
m = re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location']) m = re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location'])
@@ -373,6 +406,55 @@ def test_token_success(env, client, ssoclient):
CustomerSSOAccessToken.objects.get(token=d['access_token']).expires < now() CustomerSSOAccessToken.objects.get(token=d['access_token']).expires < now()
@pytest.mark.django_db
def test_token_pkce_required_if_used_in_authorization(env, client, ssoclient):
code = _authorization_step(client, ssoclient, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
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 == 400
d = json.loads(r.content)
assert d['error'] == 'invalid_grant'
assert d['error_description'] == 'Missing of code_verifier'
@pytest.mark.django_db
def test_token_pkce_incorrect(env, client, ssoclient):
code = _authorization_step(client, ssoclient, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
r = client.post('/bigevents/oauth2/v1/token', {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': 'https://example.net',
'code_verifier': "WRONG",
}, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
assert r.status_code == 400
d = json.loads(r.content)
assert d['error'] == 'invalid_grant'
assert d['error_description'] == 'Mismatch of code_verifier with code_challenge'
@pytest.mark.django_db
def test_token_success_pkce(env, client, ssoclient):
# this is the sample from the actual RFC
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code = _authorization_step(client, ssoclient, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
r = client.post('/bigevents/oauth2/v1/token', {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': 'https://example.net',
'code_verifier': code_verifier,
}, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
print(r.content)
assert r.status_code == 200
d = json.loads(r.content)
assert d['access_token']
@pytest.mark.django_db @pytest.mark.django_db
def test_scope_enforcement(env, client, ssoclient): def test_scope_enforcement(env, client, ssoclient):
ssoclient[0].allowed_scopes = ['openid', 'profile'] ssoclient[0].allowed_scopes = ['openid', 'profile']