Compare commits

...

7 Commits

Author SHA1 Message Date
Raphael Michel
23ea8960cf Login: Detect redirect loop and give users useful advice 2026-02-20 10:53:48 +01:00
Raphael Michel
769e1312d4 Revert "Disable partitioned cookies for Safari due to WebKit bugs (#5843)"
This reverts commit fbd8bbbeaa.
2026-02-20 10:08:51 +01:00
Martin Gross
3d53c03906 Stripe: isort 2026-02-19 14:43:27 +01:00
Martin Gross
59d1d2cb16 Stripe: Add Wero as a hidden payment method (private beta; requires MoR) 2026-02-19 14:40:01 +01:00
luelista
7e45837295 Security hardening for 2FA configuration (#5685)
* reduce default RecentAuthenticationRequiredMixin timeout to 15 min
* never cache pages with RecentAuthenticationRequiredMixin
* show emergency codes only once after generating
2026-02-19 12:43:23 +01:00
Lukas Bockstaller
fd9ed15065 include acceptor slug in log/webhook event (#5906) 2026-02-19 10:00:11 +01:00
Richard Schreiber
2df3d9206b Add voucher tag to orderlist positions export 2026-02-19 09:42:00 +01:00
14 changed files with 101 additions and 55 deletions

View File

@@ -259,7 +259,14 @@ class GiftCardViewSet(viewsets.ModelViewSet):
action='pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id})
data=merge_dicts(
self.request.data,
{
'id': inst.pk,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
)
)
@transaction.atomic()
@@ -290,7 +297,11 @@ class GiftCardViewSet(viewsets.ModelViewSet):
action='pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data={'value': diff, 'acceptor_id': self.request.organizer.id}
data={
'value': diff,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
)
return inst
@@ -320,7 +331,8 @@ class GiftCardViewSet(viewsets.ModelViewSet):
data={
'value': value,
'text': text,
'acceptor_id': self.request.organizer.id
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
)
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)

View File

@@ -198,6 +198,7 @@ class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
'acceptor_slug': logentry.parsed_data.get('acceptor_slug'),
'giftcard': giftcard.pk,
'action': logentry.action_type,
}

View File

@@ -651,6 +651,7 @@ class OrderListExporter(MultiSheetListExporter):
pgettext('address', 'State'),
_('Voucher'),
_('Voucher budget usage'),
_('Voucher tag'),
_('Pseudonymization ID'),
_('Ticket secret'),
_('Seat ID'),
@@ -769,6 +770,7 @@ class OrderListExporter(MultiSheetListExporter):
op.state_for_address or '',
op.voucher.code if op.voucher else '',
op.voucher_budget_use if op.voucher_budget_use else '',
op.voucher.tag if op.voucher else '',
op.pseudonymization_id,
op.secret,
]

View File

@@ -1650,7 +1650,8 @@ class GiftCardPayment(BasePaymentProvider):
action='pretix.giftcards.transaction.payment',
data={
'value': trans.value,
'acceptor_id': self.event.organizer.id
'acceptor_id': self.event.organizer.id,
'acceptor_slug': self.event.organizer.slug
}
)
except PaymentException as e:
@@ -1682,6 +1683,7 @@ class GiftCardPayment(BasePaymentProvider):
data={
'value': refund.amount,
'acceptor_id': self.event.organizer.id,
'acceptor_slug': self.event.organizer.slug,
'text': refund.comment,
}
)

View File

@@ -253,7 +253,8 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
auth=auth,
data={
'value': position.price,
'acceptor_id': order.event.organizer.id
'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
}
)
break
@@ -563,6 +564,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
data={
'value': -position.price,
'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
}
)
@@ -2457,7 +2459,8 @@ class OrderChangeManager:
auth=self.auth,
data={
'value': -position.price,
'acceptor_id': self.order.event.organizer.id
'acceptor_id': self.order.event.organizer.id,
'acceptor_slug': self.order.event.organizer.slug
}
)
@@ -2483,7 +2486,8 @@ class OrderChangeManager:
auth=self.auth,
data={
'value': -opa.position.price,
'acceptor_id': self.order.event.organizer.id
'acceptor_id': self.order.event.organizer.id,
'acceptor_slug': self.order.event.organizer.slug
}
)
@@ -3453,6 +3457,7 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
data={
'value': trans.value,
'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
}
)
any_giftcards = True

View File

@@ -19,6 +19,14 @@
</ul>
<br>
{% endif %}
{% if possible_cookie_problem %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
It looks like your browser is not accepting our cookie and you need to log in repeatedly. Please
check if your browser is set to block cookies, or delete all existing cookies and retry.
{% endblocktrans %}
</div>
{% endif %}
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">

View File

@@ -144,14 +144,23 @@
</div>
<div class="panel-body">
<p>
{% trans "If you lose access to your devices, you can use one of the following keys to log in. We recommend to store them in a safe place, e.g. printed out or in a password manager. Every token can be used at most once." %}
{% blocktrans trimmed %}
If you lose access to your devices, you can use one of your emergency tokens to log in.
We recommend to store them in a safe place, e.g. printed out or in a password manager.
Every token can be used at most once.
{% endblocktrans %}
</p>
<p>{% trans "Unused tokens:" %}</p>
<ul>
{% for t in static_tokens %}
<li><code>{{ t.token }}</code></li>
{% endfor %}
</ul>
{% if static_tokens_device %}
<p>
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
You generated your emergency tokens on {{ generation_date_time }}.
{% endblocktrans %}
</p>
{% else %}
<p>
{% trans "You don't have any emergency tokens yet." %}
</p>
{% endif %}
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate new emergency tokens" %}

View File

@@ -149,6 +149,8 @@ def login(request):
return process_login(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False))
else:
form = LoginForm(backend=backend, request=request)
# Detect redirection loop (usually means cookie not accepted)
ctx['possible_cookie_problem'] = request.path in request.headers.get("Referer", "")
ctx['form'] = form
ctx['can_register'] = settings.PRETIX_REGISTRATION
ctx['can_reset'] = settings.PRETIX_PASSWORD_RESET

View File

@@ -1850,7 +1850,8 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
data={
'value': value,
'text': request.POST.get('text'),
'acceptor_id': self.request.organizer.id
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
},
user=self.request.user,
)
@@ -1913,7 +1914,8 @@ class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
user=self.request.user,
data={
'value': form.cleaned_data['value'],
'acceptor_id': self.request.organizer.id
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
)
return redirect(reverse(

View File

@@ -49,12 +49,14 @@ from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
@@ -85,8 +87,9 @@ logger = logging.getLogger(__name__)
class RecentAuthenticationRequiredMixin:
max_time = 3600
max_time = 900
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
tdelta = time.time() - request.session.get('pretix_auth_login_time', 0)
if tdelta > self.max_time:
@@ -289,16 +292,13 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
ctx = super().get_context_data()
try:
ctx['static_tokens'] = StaticDevice.objects.get(user=self.request.user, name='emergency').token_set.all()
ctx['static_tokens_device'] = StaticDevice.objects.get(user=self.request.user, name='emergency')
except StaticDevice.MultipleObjectsReturned:
ctx['static_tokens'] = StaticDevice.objects.filter(
ctx['static_tokens_device'] = StaticDevice.objects.filter(
user=self.request.user, name='emergency'
).first().token_set.all()
).first()
except StaticDevice.DoesNotExist:
d = StaticDevice.objects.create(user=self.request.user, name='emergency')
for i in range(10):
d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
ctx['static_tokens'] = d.token_set.all()
ctx['static_tokens_device'] = None
ctx['devices'] = []
for dt in REAL_DEVICE_TYPES:
@@ -631,7 +631,8 @@ class User2FARegenerateEmergencyView(RecentAuthenticationRequiredMixin, Template
self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user)
messages.success(request, _('Your emergency codes have been newly generated. Remember to store them in a safe '
'place in case you lose access to your devices.'))
'place in case you lose access to your devices. You will not be able to view them '
'again here.\n\nYour emergency codes:\n- ' + '\n- '.join(t.token for t in d.token_set.all())))
return redirect(reverse('control:user.settings.2fa'))

View File

@@ -34,10 +34,7 @@ def set_cookie_without_samesite(request, response, key, *args, **kwargs):
if not is_secure:
# https://www.chromestatus.com/feature/5633521622188032
return
useragent = request.headers.get('User-Agent', '')
if should_send_same_site_none(useragent):
if should_send_same_site_none(request.headers.get('User-Agent', '')):
# Chromium is rolling out SameSite=Lax as a default
# https://www.chromestatus.com/feature/5088147346030592
# This however breaks all pretix-in-an-iframe things, such as the pretix Widget.
@@ -47,29 +44,8 @@ def set_cookie_without_samesite(request, response, key, *args, **kwargs):
# This will only work on secure cookies as well
# https://www.chromestatus.com/feature/5633521622188032
response.cookies[key]['secure'] = is_secure
if can_send_partitioned_cookie(useragent):
# CHIPS
response.cookies[key]['Partitioned'] = True
def can_send_partitioned_cookie(useragent):
# Safari currently exhibits a bug where Partitioned cookies (CHIPS) are not
# sent back to the originating site after multi-hop cross-site redirects,
# breaking SSO login flows in pretix.
#
# Partitioned cookies were initially introduced in Safari 18.4, removed
# again in 18.5 due to a bug, and reintroduced in Safari 26.2, where the
# current issue is present.
#
# Once the Safari issue is fixed, this check should be refined to be
# conditional on the affected versions only.
#
# WebKit issues:
#
# - https://bugs.webkit.org/show_bug.cgi?id=292975
# - https://bugs.webkit.org/show_bug.cgi?id=306194
return not is_safari(useragent)
# CHIPS
response.cookies[key]['Partitioned'] = True
# Based on https://www.chromium.org/updates/same-site/incompatible-clients

View File

@@ -118,6 +118,7 @@ logger = logging.getLogger('pretix.plugins.stripe')
# - UPI: ✗
# - Netbanking: ✗
# - TWINT: ✓
# - Wero: ✓ (No settings UI yet)
#
# Bank transfers
# - ACH Bank Transfer: ✗
@@ -509,6 +510,15 @@ class StripeSettingsHolder(BasePaymentProvider):
'before they work properly.'),
required=False,
)),
# Disabled for now, since still in closed Beta and only available to dedicated boarded accounts.
# ('method_wero',
# forms.BooleanField(
# label=_('Wero'),
# disabled=self.event.currency not in 'EUR',
# help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
# 'before they work properly.'),
# required=False,
# )),
] + extra_fields + list(super().settings_form_fields.items()) + moto_settings
)
if not self.settings.connect_client_id or self.settings.secret_key:
@@ -1946,3 +1956,15 @@ class StripeMobilePay(StripeRedirectMethod):
"type": "mobilepay",
},
}
class StripeWero(StripeRedirectMethod):
identifier = 'stripe_wero'
verbose_name = _('WERO via Stripe')
public_name = 'WERO'
method = 'wero'
confirmation_method = 'automatic'
explanation = _(
'This payment method is available to European online banking users, whose banking institutions support WERO '
'either through their native banking apps or through the WERO wallet app. Please have you app ready.'
)

View File

@@ -49,14 +49,14 @@ def register_payment_provider(sender, **kwargs):
StripeMultibanco, StripePayByBank, StripePayPal, StripePromptPay,
StripePrzelewy24, StripeRevolutPay, StripeSEPADirectDebit,
StripeSettingsHolder, StripeSofort, StripeSwish, StripeTwint,
StripeWeChatPay,
StripeWeChatPay, StripeWero,
)
return [
StripeSettingsHolder, StripeCC, StripeGiropay, StripeIdeal, StripeAlipay, StripeBancontact,
StripeSofort, StripeEPS, StripeMultibanco, StripePayByBank, StripePrzelewy24, StripePromptPay, StripeRevolutPay,
StripeWeChatPay, StripeSEPADirectDebit, StripeAffirm, StripeKlarna, StripePayPal, StripeSwish,
StripeTwint, StripeMobilePay
StripeTwint, StripeMobilePay, StripeWero
]

View File

@@ -339,13 +339,17 @@ class UserSettings2FATest(SoupTest):
def test_gen_emergency(self):
self.client.get('/control/settings/2fa/')
assert not StaticDevice.objects.filter(user=self.user, name='emergency').exists()
self.client.post('/control/settings/2fa/regenemergency')
d = StaticDevice.objects.get(user=self.user, name='emergency')
assert d.token_set.count() == 10
old_tokens = set(t.token for t in d.token_set.all())
self.client.post('/control/settings/2fa/regenemergency')
new_tokens = set(t.token for t in d.token_set.all())
d = StaticDevice.objects.get(user=self.user, name='emergency')
assert d.token_set.count() == 10
new_tokens = set(t.token for t in d.token_set.all())
assert old_tokens != new_tokens
def test_delete_u2f(self):