Compare commits

..

3 Commits

Author SHA1 Message Date
Raphael Michel
f51fbd7df3 Review notes 2026-06-02 12:20:36 +02:00
Raphael Michel
2ad2b8515a Make ip block work in dev 2026-06-02 12:20:35 +02:00
Raphael Michel
48933056aa User email verifications: Add rate limiting (Z#23232834)
Also, refactor rate limiting into a central utility
2026-06-02 12:20:35 +02:00
19 changed files with 247 additions and 301 deletions

View File

@@ -53,7 +53,7 @@ dependencies = [
"django-oauth-toolkit==2.3.*",
"django-otp==1.7.*",
"django-phonenumber-field==8.4.*",
"django-redis==7.0.*",
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.7.*",
"djangorestframework==3.17.*",

View File

@@ -33,8 +33,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import hashlib
import ipaddress
import logging
from django import forms
@@ -42,13 +40,12 @@ from django.conf import settings
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
from pretix.helpers.http import get_client_ip
from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
logger = logging.getLogger(__name__)
@@ -85,45 +82,26 @@ class LoginForm(forms.Form):
else:
move_to_end(self.fields, 'keep_logged_in')
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.get(self.ratelimit_key)
if cnt and int(cnt) > 10:
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300)
if rate_limit("login", **rate_limit_kwargs, increase=False):
# Check rate limit without counting up, we increase below only on failed logins
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="invalid")
# Count towards rate limit (result is ignored, we are checking above)
rate_limit("login", **rate_limit_kwargs)
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
)
else:
rate_limit_reset("login", include_ip_from_request=self.request)
self.confirm_login_allowed(self.user_cache)
return self.cleaned_data

View File

@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
@@ -46,6 +45,7 @@ from pytz import common_timezones
from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
from pretix.helpers.ratelimit import rate_limit
class UserSettingsForm(forms.ModelForm):
@@ -128,16 +128,11 @@ class UserPasswordChangeForm(forms.Form):
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.user.password):
raise forms.ValidationError(
@@ -175,19 +170,35 @@ class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
}
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if rate_limit("emailchange_attempt", include_ip_from_request=self.request, max_num=5, expire_time=300):
# Rate limit lookup for conflicting email addresses to make enumeration harder
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
if rate_limit("emailchange", self.user.pk, max_num=1, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return email

View File

@@ -461,31 +461,3 @@ class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
**super().create_option(name, value, label, selected, index, subindex, attrs),
"plugin_missing": plugin and plugin not in self.event.get_plugins(),
}
class ModelChoiceIteratorWithNone(forms.models.ModelChoiceIterator):
# see django.forms.models.ModelChoiceIterator for original implementation
def __iter__(self):
if self.field.empty_label is not None:
yield ("", self.field.empty_label)
if self.field.none_label is not None:
yield ("_none", self.field.none_label)
queryset = self.queryset
# Can't use iterator() when queryset uses prefetch_related()
if not queryset._prefetch_related_lookups:
queryset = queryset.iterator()
for obj in queryset:
yield self.choice(obj)
class ModelChoiceFieldWithNone(forms.ModelChoiceField):
iterator = ModelChoiceIteratorWithNone
def __init__(self, *args, **kwargs):
self.none_label = kwargs.pop("none_label", None)
super().__init__(*args, **kwargs)
def to_python(self, value):
if value == "_none":
return value
return super().to_python(value)

View File

@@ -29,30 +29,17 @@ class Select2Mixin:
super().__init__(*args, **kwargs)
def options(self, name, value, attrs=None):
if not value or not value[0]:
return
has_none = "_none" in value
if has_none:
value = [v for v in value if v != "_none"]
yield self.create_option(
None,
"_none",
self.choices.field.none_label,
True,
0,
subindex=None,
attrs=attrs
)
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i + (1 if has_none else 0),
subindex=None,
attrs=attrs
)
if value and value[0]:
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i,
subindex=None,
attrs=attrs
)
return
def optgroups(self, name, value, attrs=None):

View File

@@ -9,24 +9,23 @@
<h3 class="panel-title">{% trans "Go offline" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can take your event offline. Nobody except your team will be able to see or access it any more.
{% endblocktrans %}
</p>
</div>
<form class="col-sm-12 col-lg-6 text-right"
action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
</div>
<div class="col-sm-12 col-md-3">
<form action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
<button type="submit" class="btn btn-primary btn-lg">
<span class="fa fa-power-off"></span>
{% trans "Go offline" %}
</button>
</form>
<button type="submit" class="btn btn-primary btn-lg btn-block">
<span class="fa fa-power-off"></span>
{% trans "Go offline" %}
</button>
</form>
</div>
</div>
</div>
@@ -35,24 +34,22 @@
<h3 class="panel-title">{% trans "Cancel event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
If you need to call off your event you want to cancel and refund all tickets, you can do so through
this option.
{% endblocktrans %}
</p>
</div>
<div class="col-sm-12 col-lg-6 text-right">
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg pull-right {% if "event:cancel" not in request.eventpermset %}disabled{% endif %}">
<span class="fa fa-ban"></span>
{% if "event:cancel" in request.eventpermset %}
<div class="col-sm-12 col-md-3 text-center">
{% if "event:cancel" in request.eventpermset %}
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-block btn-lg">
<span class="fa fa-ban"></span>
{% trans "Cancel event" %}
{% else %}
{% trans "No permission" %}
{% endif %}
</a>
</a>
{% else %}
{% trans "No permission" %}
{% endif %}
</div>
</div>
</div>
@@ -62,16 +59,15 @@
<h3 class="panel-title">{% trans "Delete personal data" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can remove personal data such as names and email addresses from your event and only retain the
financial information such as the number and type of tickets sold.
{% endblocktrans %}
</p>
</div>
<div class="col-sm-12 col-lg-6 text-right">
<a href="{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg">
<div class="col-sm-12 col-md-3">
<a href="
{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg btn-block">
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
</a>
@@ -84,17 +80,15 @@
<h3 class="panel-title">{% trans "Delete event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can delete your event completely only as long as it does not contain any undeletable data, such as
orders not performed in test mode.
{% endblocktrans %}
</p>
</div>
<div class="col-sm-12 col-lg-6 text-right">
<div class="col-sm-12 col-md-3">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
class="btn btn-danger btn-block btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
<span class="fa fa-trash"></span>
{% trans "Delete event" %}
</a>

View File

@@ -65,6 +65,7 @@ from pretix.base.forms.auth import (
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import handle_login_source, session_login
logger = logging.getLogger(__name__)
@@ -318,19 +319,12 @@ class Forgot(TemplateView):
if self.form.is_valid():
email = self.form.cleaned_data['email']
has_redis = settings.HAS_REDIS
try:
user = User.objects.get(is_active=True, auth_backend='native', email__iexact=email)
if has_redis:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if rc.exists('pretix_pwreset_%s' % (user.id)):
user.log_action('pretix.control.auth.user.forgot_password.denied.repeated')
raise RepeatedResetDenied()
else:
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
if rate_limit("pwreset", user.pk, max_num=1, expire_time=3600 * 24):
user.log_action('pretix.control.auth.user.forgot_password.denied.repeated')
raise RepeatedResetDenied()
except User.DoesNotExist:
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
@@ -343,6 +337,7 @@ class Forgot(TemplateView):
user.log_action('pretix.control.auth.user.forgot_password.mail_sent')
finally:
has_redis = settings.HAS_REDIS
if has_redis:
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions. '
'Please note that we will send at most one email every 24 hours.'))
@@ -411,11 +406,7 @@ class Recover(TemplateView):
messages.success(request, _('You can now login using your new password.'))
user.log_action('pretix.control.auth.user.forgot_password.recovered')
has_redis = settings.HAS_REDIS
if has_redis:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
rc.delete('pretix_pwreset_%s' % user.id)
rate_limit_reset("pwreset", user.pk)
return redirect('control:auth.login')
else:
return self.get(request, *args, **kwargs)

View File

@@ -44,7 +44,7 @@ from pretix.control.permissions import (
from pretix.helpers.models import modelcopy
from ...helpers.compat import CompatDeleteView
from . import CreateView, UpdateView
from . import CreateView, PaginationMixin, UpdateView
class DiscountDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -183,7 +183,7 @@ class DiscountCreate(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form)
class DiscountList(ListView):
class DiscountList(PaginationMixin, ListView):
model = Discount
context_object_name = 'discounts'
template_name = 'pretixcontrol/items/discounts.html'

View File

@@ -335,7 +335,7 @@ class CategoryCreate(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form)
class CategoryList(ListView):
class CategoryList(PaginationMixin, ListView):
model = ItemCategory
context_object_name = 'categories'
template_name = 'pretixcontrol/items/categories.html'

View File

@@ -145,21 +145,11 @@ def event_list(request):
if 'can_copy' in request.GET:
qs = EventWizardCopyForm.copy_from_queryset(request.user, request.session)
else:
permission = request.GET.get('permission')
if permission:
qs = request.user.get_events_with_permission(permission, request)
else:
qs = request.user.get_events_with_any_permission(request)
name_slug_q = Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query)
organizer = request.GET.get('organizer')
if organizer:
qs = qs.filter(organizer__slug=organizer)
else:
name_slug_q |= Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
qs = request.user.get_events_with_any_permission(request)
qs = qs.filter(
name_slug_q
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
).annotate(
min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'),
@@ -172,19 +162,10 @@ def event_list(request):
total = qs.count()
pagesize = 20
offset = (page - 1) * pagesize
results = []
if page == 1 and 'include_none' in request.GET and not query:
results.append({
'id': "_none",
'text': _("No event"),
'name': _("No event"),
'type': "event",
})
results += [
serialize_event(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
]
doc = {
'results': results,
'results': [
serialize_event(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}

View File

@@ -80,6 +80,7 @@ from pretix.control.permissions import (
)
from pretix.control.views.auth import get_u2f_appid, get_webauthn_rp_id
from pretix.helpers.http import redirect_to_url
from pretix.helpers.ratelimit import rate_limit
from pretix.helpers.security import session_reauth
from pretix.helpers.u2f import websafe_encode
@@ -879,6 +880,7 @@ class UserEmailChangeView(RecentAuthenticationRequiredMixin, FormView):
return {
**super().get_form_kwargs(),
"request": self.request,
"user": self.request.user,
}
@@ -908,6 +910,10 @@ class UserEmailVerifyView(View):
messages.success(self.request, _('Your email address was already verified.'))
return redirect(reverse('control:user.settings', kwargs={}))
if rate_limit("emailverify", self.request.user.pk, max_num=2, expire_time=300):
messages.error(self.request, _("For security reasons, please wait 5 minutes before you try again."))
return redirect(reverse('control:user.settings', kwargs={}))
self.request.user.send_confirmation_code(
session=self.request.session,
reason='email_verify',

View File

@@ -0,0 +1,116 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import hashlib
import ipaddress
import logging
from django.conf import settings
from django.http import HttpRequest
from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__)
def _get_key(key, parameters):
return f'pretix:ratelimit:{key}:' + hashlib.sha256(','.join(str(p) for p in parameters).encode()).hexdigest()
def _get_ip(request):
client_ip = get_client_ip(request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private and not settings.DEBUG:
# This is the private IP of the server, web server not set up correctly
return None
return str(client_ip)
def rate_limit(key: str, *parameters, include_ip_from_request: HttpRequest=None, max_num: int, expire_time: int, increase: bool = True):
"""
This is a shared utility to implement simple rate limiting in operations like
password resets.
:param key: The key referring to the feature like "pwreset"
:param parameters: Any number of things to be hashed as the bucket key
:param include_ip_from_request: Add IP address from request to the bucket key. If IP address cannot be determined,
rate limit is not applied.
:param max_num: The maximum number of actions to performed within expire_time of the first action
:param expire_time: The length of the time window in seconds
:param increase: Whether to count the call as an event counted towards the rate, or just check
:return:
"""
if not settings.HAS_REDIS:
# No rate limiting
return False
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if include_ip_from_request:
ip = _get_ip(include_ip_from_request)
if not ip:
# IP not discovered, can't rate limit
return False
parameters = (*parameters, ip)
redis_key = _get_key(key, parameters)
if increase:
p = rc.pipeline()
p.set(redis_key, 0, nx=True, ex=expire_time) # Start a rate limit window if none is running
p.incr(redis_key)
new_counter = p.execute()[1]
else:
new_counter = int(rc.get(redis_key) or 0)
if new_counter > max_num:
return True
return False
def rate_limit_reset(key: str, *parameters, include_ip_from_request: HttpRequest=None):
"""
Reset a rate limit bucket.
"""
if not settings.HAS_REDIS:
# No rate limiting
return
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if include_ip_from_request:
ip = _get_ip(include_ip_from_request)
if not ip:
# IP not discovered, can't rate limit
return False
parameters = (*parameters, ip)
redis_key = _get_key(key, parameters)
rc.delete(redis_key)

View File

@@ -20,8 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import functools
import hashlib
import ipaddress
import random
from django import forms
@@ -32,7 +30,6 @@ from django.contrib.auth.password_validation import (
)
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core import signing
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
from phonenumber_field.formfields import PhoneNumberField
@@ -44,6 +41,7 @@ from pretix.base.forms.questions import (
from pretix.base.i18n import get_language_without_region
from pretix.base.models import Customer
from pretix.helpers.http import get_client_ip
from pretix.helpers.ratelimit import rate_limit
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -205,23 +203,6 @@ class RegistrationForm(forms.Form):
min_value=0,
)
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_customer_registration_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
email = self.cleaned_data.get('email')
@@ -255,17 +236,11 @@ class RegistrationForm(forms.Form):
code='incomplete'
)
else:
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 600)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if rate_limit("customer_signup", include_ip_from_request=self.request, max_num=10, expire_time=600):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return self.cleaned_data
def create(self):
@@ -370,13 +345,8 @@ class ResetPasswordForm(forms.Form):
def clean(self):
d = super().clean()
if d.get('email') and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwreset_customer_%s' % self.customer.pk)
rc.expire('pretix_pwreset_customer_%s' % self.customer.pk, 600)
if cnt > 2:
if d.get('email'):
if rate_limit("customer_pwreset", self.customer.pk, max_num=2, expire_time=600):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
@@ -445,13 +415,8 @@ class ChangePasswordForm(forms.Form):
def clean_password_current(self):
old_pw = self.cleaned_data.get('password_current')
if old_pw and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_customer_%s' % self.customer.pk)
rc.expire('pretix_pwchange_customer_%s' % self.customer.pk, 300)
if cnt > 10:
if old_pw:
if rate_limit("customer_pwchange", self.customer.pk, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
@@ -521,17 +486,11 @@ class ChangeInfoForm(forms.ModelForm):
old_pw = self.cleaned_data.get('password_current')
if old_pw:
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_customer_%s' % self.instance.pk)
rc.expire('pretix_pwchange_customer_%s' % self.instance.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if rate_limit("customer_pwchange", self.instance.pk, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.instance.password):
raise forms.ValidationError(

View File

@@ -32,7 +32,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.conf import settings
from django.contrib import messages
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@@ -42,6 +41,7 @@ from django.views.generic import TemplateView
from pretix.base.email import get_email_context
from pretix.base.services.mail import INVALID_ADDRESS, mail
from pretix.helpers.http import redirect_to_url
from pretix.helpers.ratelimit import rate_limit
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.user import ResendLinkForm
from pretix.presale.views import EventViewMixin
@@ -61,17 +61,12 @@ class ResendLinkView(EventViewMixin, TemplateView):
user = self.link_form.cleaned_data.get('email')
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if rc.exists('pretix_resend_{}_{}'.format(request.event.pk, user)):
messages.error(request, _('If the email address you entered is valid and associated with a ticket, we have '
'already sent you an email with a link to your ticket in the past {number} hours. '
'If the email did not arrive, please check your spam folder and also double check '
'that you used the correct email address.').format(number=24))
return redirect_to_url(eventreverse(self.request.event, 'presale:event.resend_link'))
else:
rc.setex('pretix_resend_{}_{}'.format(request.event.pk, user), 3600 * 24, '1')
if rate_limit("order_resend", self.request.event.pk, user, max_num=1, expire_time=3600 * 24):
messages.error(request, _('If the email address you entered is valid and associated with a ticket, we have '
'already sent you an email with a link to your ticket in the past {number} hours. '
'If the email did not arrive, please check your spam folder and also double check '
'that you used the correct email address.').format(number=24))
return redirect_to_url(eventreverse(self.request.event, 'presale:event.resend_link'))
orders = self.request.event.orders.filter(email__iexact=user)

View File

@@ -58,11 +58,10 @@ from django.utils.translation import gettext_lazy as _ # NOQA
_config = configparser.RawConfigParser()
if 'PRETIX_CONFIG_FILE' in os.environ:
config_files = [os.environ['PRETIX_CONFIG_FILE']]
_config.read_file(open(os.environ.get('PRETIX_CONFIG_FILE'), encoding='utf-8'))
else:
config_files = ['/etc/pretix/pretix.cfg', os.path.expanduser('~/.pretix.cfg'), 'pretix.cfg']
_config.read(config_files, encoding='utf-8')
_config.read(['/etc/pretix/pretix.cfg', os.path.expanduser('~/.pretix.cfg'), 'pretix.cfg'],
encoding='utf-8')
config = EnvOrParserConfig(_config)
CONFIG_FILE = config
@@ -705,7 +704,7 @@ if config.has_option('sentry', 'dsn') and not any(c in sys.argv for c in ('shell
from sentry_sdk.integrations.logging import (
LoggingIntegration, ignore_logger,
)
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST
from .sentry import PretixSentryIntegration, setup_custom_filters
@@ -896,14 +895,3 @@ VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
VITE_DEV_MODE = DEBUG
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
if DEBUG:
# Reload if settings file changes
config_files_to_watch = [Path(x).absolute() for x in config_files]
from django.dispatch import receiver
from django.utils.autoreload import BaseReloader, autoreload_started
@receiver(autoreload_started, dispatch_uid="pretix_watch_config_file")
def watch_config_file(sender: BaseReloader, *args, **kwargs):
sender.extra_files.update(config_files_to_watch)

View File

@@ -639,13 +639,11 @@ var form_handlers = function (el) {
).append(" ").append($("<div>").text(res.organizer).html())
);
}
if (res.date_range) {
$ret.append(
$("<span>").addClass("event-daterange").append(
$("<span>").addClass("fa fa-calendar fa-fw")
).append(" ").append(res.date_range)
);
}
$ret.append(
$("<span>").addClass("event-daterange").append(
$("<span>").addClass("fa fa-calendar fa-fw")
).append(" ").append(res.date_range)
);
return $ret;
},
}).on("select2:select", function () {

View File

@@ -120,6 +120,7 @@ def fakeredis_client(monkeypatch):
redis = get_redis_connection("default", True)
redis.flushall()
monkeypatch.setattr('django_redis.get_redis_connection', get_redis_connection, raising=False)
monkeypatch.setattr('pretix.base.metrics.redis', redis, raising=False)
yield redis

View File

@@ -504,33 +504,8 @@ class Login2FAFormTest(TestCase):
assert "recovery code" in djmail.outbox[0].body
class FakeRedis(object):
def get_redis_connection(self, connection_string):
return self
def __init__(self):
self.storage = {}
def pipeline(self):
return self
def hincrbyfloat(self, rkey, key, amount):
return self
def commit(self):
return self
def exists(self, rkey):
return rkey in self.storage
def setex(self, rkey, value, expiration):
self.storage[rkey] = value
def execute(self):
pass
@pytest.mark.usefixtures("class_monkeypatch")
@pytest.mark.usefixtures("fakeredis_client")
class PasswordRecoveryFormTest(TestCase):
def setUp(self):
super().setUp()
@@ -560,11 +535,6 @@ class PasswordRecoveryFormTest(TestCase):
@override_settings(HAS_REDIS=True)
def test_email_reset_twice_redis(self):
fake_redis = FakeRedis()
m = self.monkeypatch
m.setattr('django_redis.get_redis_connection', fake_redis.get_redis_connection, raising=False)
m.setattr('pretix.base.metrics.redis', fake_redis, raising=False)
djmail.outbox = []
response = self.client.post('/control/forgot', {

View File

@@ -731,13 +731,11 @@ def event_series(organizer):
"""Create an event series with multiple subevents, items, and quotas."""
from pretix.base.models import ItemCategory
base_date = _future_dt(days=30, hour=19)
event = Event.objects.create(
organizer=organizer,
name='Concert Series',
slug='concert-series',
date_from=base_date,
date_from=_future_dt(days=30, hour=19),
has_subevents=True,
currency='EUR',
live=True,
@@ -762,8 +760,9 @@ def event_series(organizer):
)
subevents = []
base_date = _future_dt(days=30, hour=19)
for i in range(20):
for i in range(15):
se = SubEvent.objects.create(
event=event,
name=f'Concert Night {i + 1}',