diff --git a/src/pretix/base/migrations/0289_user_verified_email.py b/src/pretix/base/migrations/0289_user_verified_email.py new file mode 100644 index 0000000000..26ca154157 --- /dev/null +++ b/src/pretix/base/migrations/0289_user_verified_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-09-04 12:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0288_invoice_transmission"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="verified_email", + field=models.EmailField(max_length=190, null=True), + ), + ] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 27e0ee6961..56eb21dbe9 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -44,6 +44,7 @@ from django.contrib.auth.models import ( ) from django.contrib.auth.tokens import default_token_generator from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db import IntegrityError, models, transaction from django.db.models import Q from django.utils.crypto import get_random_string, salted_hmac @@ -242,6 +243,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): email = models.EmailField(unique=True, db_index=True, null=True, blank=True, verbose_name=_('Email'), max_length=190) + verified_email = models.EmailField(null=True, blank=True, verbose_name=_('Verified Email'), max_length=190) fullname = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('Full name')) is_active = models.BooleanField(default=True, @@ -593,6 +595,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): self.session_token = generate_session_token() self.save(update_fields=['session_token']) + def generate_email_verification_token(self): + token = str(self.pk) + '-' + get_random_string(length=32) + cache.set("confirm_email_token:" + str(self.pk), token, 3 * 86400) + return token + class UserKnownLoginSource(models.Model): user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources") diff --git a/src/pretix/control/templates/pretixcontrol/user/settings.html b/src/pretix/control/templates/pretixcontrol/user/settings.html index 139bfcbd5e..31cbf5cd9f 100644 --- a/src/pretix/control/templates/pretixcontrol/user/settings.html +++ b/src/pretix/control/templates/pretixcontrol/user/settings.html @@ -3,6 +3,11 @@ {% load bootstrap3 %} {% block title %}{% trans "Account settings" %}{% endblock %} {% block content %} +{% if user.email != user.verified_email %} +
+ {% trans "Please confirm your email address by clicking on the confirmation link in the email we sent you." %} +
+{% endif %}

{% trans "Account settings" %}

{% csrf_token %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 4243ad2d54..dd0210c792 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -110,6 +110,7 @@ urlpatterns = [ name='user.settings.2fa.confirm.webauthn'), re_path(r'^settings/2fa/(?P[^/]+)/(?P[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(), name='user.settings.2fa.delete'), + re_path(r'^confirm/(?P[a-zA-Z0-9-]+)$', user.ConfirmEmailView.as_view(), name='user.email.confirm'), re_path(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'), re_path(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'), re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'), diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 8d08160641..dd7566bbef 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -37,6 +37,7 @@ import json import logging import time from collections import defaultdict +from hmac import compare_digest from urllib.parse import quote import webauthn @@ -59,6 +60,7 @@ from django_otp.plugins.otp_totp.models import TOTPDevice from django_scopes import scopes_disabled from webauthn.helpers import generate_challenge, generate_user_handle +from django.core.cache import cache from pretix.base.auth import get_auth_backends from pretix.base.forms.auth import ReauthForm from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm @@ -74,6 +76,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.u2f import websafe_encode +from pretix.multidomain.urlreverse import build_absolute_uri REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice) logger = logging.getLogger(__name__) @@ -253,9 +256,12 @@ class UserSettings(UpdateView): msgs.append(_('Your email address has been changed to {email}.').format(email=form.cleaned_data['email'])) if msgs: - self.request.user.send_security_notice(msgs, email=form.cleaned_data['email']) if self._old_email != form.cleaned_data['email']: self.request.user.send_security_notice(msgs, email=self._old_email) + token = self.request.user.generate_email_verification_token() + link = build_absolute_uri(False, 'control:user.email.confirm', kwargs={'token': token}) + msgs.append(_('Please click the following link to confirm your new email address: {link}').format(link=link)) + self.request.user.send_security_notice(msgs, email=form.cleaned_data['email']) sup = super().form_valid(form) self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data=data) @@ -834,3 +840,28 @@ class EditStaffSession(StaffMemberRequiredMixin, UpdateView): return get_object_or_404(StaffSession, pk=self.kwargs['id']) else: return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user) + + +class ConfirmEmailView(View): + + def get(self, request, token, *args, **kwargs): + try: + uid = int(token.split("-")[0]) + except ValueError: + uid = None + if uid and compare_digest(cache.get('confirm_email_token:' + str(uid)), token): + user = User.objects.get(pk=uid) + with transaction.atomic(): + if user.email != user.verified_email: + user.log_action("user.email.confirmed", data={ + "old_email": user.verified_email, + "new_email": user.email, + }) + user.verified_email = user.email + user.save() + messages.success(request, _('Your email has been confirmed.')) + else: + messages.success(request, _('Your email was already confirmed.')) + else: + messages.error(request, _('Invalid confirmation link. Please try again.')) + return redirect("control:user.settings")