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
non-canceled fees are included.
├ id integer Internal ID of the fee record
├ fee_type string Type of fee (currently ``payment``, ``shipping``,
``service``, ``cancellation``, ``insurance``, ``late``,
``other``, ``giftcard``)
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``)
├ value money (string) Fee amount
├ description string Human-readable string with more details (can be empty)
├ 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
reliable for orders that have been cancelled,
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.
.. versionchanged:: 2025.2
The ``plugin_data`` attribute has been added.
.. _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,
this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request.
plugin_data object Additional data added by plugins.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
@@ -272,10 +265,6 @@ plugin_data object Additional data
The ``tax_code`` attribute has been added.
.. versionchanged:: 2025.2
The ``plugin_data`` attribute has been added.
.. _order-payment-resource:
Order payment resource
@@ -472,8 +461,7 @@ List of all orders
"output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
}
],
"plugin_data": {}
]
}
],
"downloads": [
@@ -495,8 +483,7 @@ List of all orders
}
],
"refunds": [],
"cancellation_date": null,
"plugin_data": {}
"cancellation_date": null
}
]
}
@@ -715,8 +702,7 @@ Fetching individual orders
"output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
}
],
"plugin_data": {}
]
}
],
"downloads": [
@@ -738,8 +724,7 @@ Fetching individual orders
}
],
"refunds": [],
"cancellation_date": null,
"plugin_data": {}
"cancellation_date": null
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -1686,8 +1671,7 @@ List of all order positions
"output": "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",
"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
@@ -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.
* ``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
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.
@@ -2267,12 +2247,17 @@ otherwise, such as splitting an order or changing fees.
Content-Type: application/json
{
"cancel_positions": [
{
"position": 12373
}
],
"patch_positions": [
{
"position": 12374,
"body": {
"item": 12,
"variation": null,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
@@ -2280,11 +2265,6 @@ otherwise, such as splitting an order or changing fees.
}
}
],
"cancel_positions": [
{
"position": 12373
}
],
"split_positions": [
{
"position": 12375
@@ -2293,7 +2273,7 @@ otherwise, such as splitting an order or changing fees.
"create_positions": [
{
"item": 12,
"variation": null,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
@@ -2301,26 +2281,17 @@ otherwise, such as splitting an order or changing fees.
"attendee_name": "Peter",
}
],
"patch_fees": [
{
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"cancel_fees": [
{
"fee": 49
}
],
"create_fees": [
"change_fees": [
{
"fee_type": "other",
"value": "1.50",
"description": "Example Fee",
"internal_type": "",
"tax_rule": 15
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"reissue_invoice": true,

View File

@@ -103,4 +103,4 @@ API
.. automodule:: pretix.api.signals
: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'
}
LANGUAGES_INCUBATING = {
'pt-br', 'gl',
'fi', 'pt-br', 'gl',
}
LANGUAGES = ALL_LANGUAGES
LOCALE_PATHS = [

View File

@@ -46,7 +46,6 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
)
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import (
@@ -495,18 +494,6 @@ class OrderPositionListSerializer(serializers.ListSerializer):
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):
checkins = CheckinSerializer(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)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
class Meta:
list_serializer_class = OrderPositionListSerializer
@@ -527,7 +513,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
'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', '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 = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id',
@@ -744,18 +730,6 @@ class OrderListSerializer(serializers.ListSerializer):
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):
event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True)
@@ -773,7 +747,6 @@ class OrderSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(),
required=False,
)
plugin_data = OrderPluginDataField(source='*', allow_null=True, read_only=True)
class Meta:
model = Order
@@ -782,7 +755,7 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'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 = (
'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 (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError
@@ -104,54 +104,6 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
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):
answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*')
@@ -449,9 +401,6 @@ class OrderChangeOperationSerializer(serializers.Serializer):
self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['create_fees'] = OrderFeeCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer(
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 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
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.
"""
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)
@scopes_disabled()

View File

@@ -63,8 +63,7 @@ from pretix.api.serializers.order import (
)
from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer,
OrderFeeChangeSerializer, OrderFeeCreateForExistingOrderSerializer,
OrderPositionChangeSerializer,
OrderFeeChangeSerializer, OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
@@ -989,12 +988,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
ocm.cancel_fee(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', []):
if r['fee'] in canceled_fees:
continue

View File

@@ -148,7 +148,7 @@ def oidc_validate_and_complete_config(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']
params = {
# 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"]:
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)
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']
# Wall of shame and RFC ignorant IDPs
@@ -188,6 +192,9 @@ def oidc_validate_authorization(provider, code, 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':
params['client_id'] = provider.configuration['client_id']
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(
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(
blank=False,
verbose_name=_("Redirection URIs"),
@@ -481,6 +485,8 @@ class CustomerSSOGrant(models.Model):
expires = models.DateTimeField()
redirect_uri = models.TextField()
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):

View File

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

View File

@@ -325,7 +325,7 @@ class OrderChangedSplitFrom(OrderLogEntryType):
})
class CheckinErrorLogEntryType(OrderLogEntryType):
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):
if isinstance(plain, tuple):

View File

@@ -125,15 +125,6 @@
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</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>
{% endif %}
</form>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -292,11 +292,11 @@ def _handle_transaction(trans: BankTransaction, matches: tuple, event: Event = N
trans.save()
def parse_date(date_str, region=None):
def parse_date(date_str):
try:
return dateutil.parser.parse(
date_str,
dayfirst="." in date_str or region in ["GB"],
dayfirst="." in date_str,
).date()
except (ValueError, OverflowError):
pass
@@ -339,7 +339,7 @@ def _get_unknown_transactions(job: BankImportJob, data: list, event: Event = Non
external_id=row.get('external_id'),
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()
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
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}_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"
@@ -684,7 +686,7 @@ class SSOLoginView(RedirectBackMixin, View):
})
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:
raise Http404("Unknown SSO method.")
@@ -718,6 +720,7 @@ class SSOLoginReturnView(RedirectBackMixin, View):
)
return HttpResponseRedirect(redirect_to)
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}_popup_origin', None)
request.session.pop(f'pretix_customerauth_{self.provider.pk}_cross_domain_requested', None)
@@ -763,6 +766,7 @@ class SSOLoginReturnView(RedirectBackMixin, View):
self.provider,
request.GET.get('code'),
redirect_uri,
request.session.get(f'pretix_customerauth_{self.provider.pk}_pkce_code_verifier'),
)
except ValidationError as 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)
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
acquire formal certification:
@@ -136,19 +139,22 @@ class AuthorizeView(View):
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,
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())
return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce,
code_challenge, code_challenge_method, 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):
def _success(self, client, scope, redirect_uri, response_type, response_mode, state, nonce, code_challenge,
code_challenge_method, customer):
response_type = response_type.split(' ')
qs = {}
id_token_kwargs = {}
@@ -162,6 +168,8 @@ class AuthorizeView(View):
expires=now() + timedelta(minutes=10),
auth_time=get_customer_auth_time(self.request),
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
qs['code'] = grant.code
id_token_kwargs['with_code'] = grant.code
@@ -209,6 +217,8 @@ class AuthorizeView(View):
prompt = request_data.get("prompt")
response_type = request_data.get("response_type")
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:
return self._final_error("invalid_request", "client_id missing")
@@ -244,7 +254,17 @@ class AuthorizeView(View):
response_mode, state)
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)
has_valid_session = bool(request.customer)
@@ -252,8 +272,7 @@ class AuthorizeView(View):
try:
has_valid_session = int(time.time() - get_customer_auth_time(request)) < int(max_age)
except ValueError:
return self._redirect_error("invalid_request", "invalid max_age value", redirect_uri,
response_mode, state)
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",
@@ -262,9 +281,11 @@ class AuthorizeView(View):
has_valid_session = False
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:
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):
@@ -362,6 +383,24 @@ class TokenView(View):
"error_description": "Mismatch of redirect_uri"
}, 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():
token = self.client.access_tokens.create(
customer=grant.customer,
@@ -503,6 +542,7 @@ class ConfigurationView(View):
'token_endpoint_auth_methods_supported': [
'client_secret_post', 'client_secret_basic'
],
'code_challenge_methods_supported': ['S256'],
'claims_supported': [
'iss',
'aud',

View File

@@ -162,7 +162,6 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard):
"tax_rule": None,
"pseudonymization_id": op.pseudonymization_id,
"pdf_data": {},
"plugin_data": {},
"seat": None,
"canceled": False,
"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'
},
],
'create_fees': [
{
'value': '5.99',
'fee_type': 'service',
'description': 'Service fee',
},
],
'cancel_fees': [
{
'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')
f.refresh_from_db()
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

View File

@@ -477,8 +477,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'zipcode': None,
'state': None,
'country': None,
'canceled': False,
'plugin_data': {},
'canceled': False
}
],
'downloads': [],
@@ -488,8 +487,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'refunds': [],
'require_approval': False,
'sales_channel': 'web',
'cancellation_date': None,
'plugin_data': {},
'cancellation_date': None
}
@@ -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,
'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,
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None,
'plugin_data': {}},
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None},
{'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,
'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,
'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,
'plugin_data': {}}
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None}
]

View File

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

View File

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

View File

@@ -236,7 +236,8 @@ def provider(organizer):
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code"],
"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
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 (
"https://example.com/authorize?"
"response_type=code&"
@@ -251,7 +267,7 @@ def test_authorize_url(provider):
"scope=openid+email+profile&"
"state=state_val&"
"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
@@ -264,7 +280,7 @@ def test_validate_authorization_invalid(provider):
status=400,
)
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
@@ -281,6 +297,7 @@ def test_validate_authorization_userinfo_invalid(provider):
"grant_type": "authorization_code",
"code": "code_received",
"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:
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)
@@ -314,6 +331,7 @@ def test_validate_authorization_valid(provider):
"grant_type": "authorization_code",
"code": "code_received",
"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"})
],
)
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():
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-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'])
@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
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"):
@@ -286,7 +317,7 @@ def test_token_require_client_id(env, client, ssoclient):
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', {
'email': 'john@example.org',
'password': 'foo',
@@ -299,6 +330,8 @@ def _authorization_step(client, ssoclient):
f'client_id={ssoclient[0].client_id}&' \
f'redirect_uri=https://example.net&' \
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)
assert r.status_code == 302
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()
@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
def test_scope_enforcement(env, client, ssoclient):
ssoclient[0].allowed_scopes = ['openid', 'profile']