Customer accounts: Add security notices (#5705)

* Customer accounts: Add security notices

* Apply suggestions from code review
This commit is contained in:
Raphael Michel
2026-02-10 17:55:53 +01:00
committed by GitHub
parent 27fcdff17f
commit 47f409171d
6 changed files with 75 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned from pretix.base.banlist import banned
from pretix.base.i18n import language
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.base.models.giftcards import GiftCardTransaction from pretix.base.models.giftcards import GiftCardTransaction
@@ -164,6 +165,28 @@ class Customer(LoggedModel):
self.attendee_profiles.all().delete() self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete() self.invoice_addresses.all().delete()
def send_security_notice(self, message, email=None):
from pretix.base.services.mail import SendMailException, mail
from pretix.multidomain.urlreverse import build_absolute_uri
try:
with language(self.locale):
mail(
email or self.email,
self.organizer.settings.mail_subject_customer_security_notice,
self.organizer.settings.mail_text_customer_security_notice,
{
**self.get_email_context(),
'message': str(message),
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
},
customer=self,
organizer=self.organizer,
locale=self.locale
)
except SendMailException:
pass # Already logged
@scopes_disabled() @scopes_disabled()
def assign_identifier(self): def assign_identifier(self):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')

View File

@@ -2948,6 +2948,28 @@ If you did not request a new password, please ignore this email.
Best regards, Best regards,
Your {organizer} team""")) # noqa: W291
},
'mail_subject_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Changes to your account at {organizer}")),
},
'mail_text_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
the following change has been made to your account at {organizer}:
{message}
You can review and change your account settings here:
{url}
If this change was not performed by you, please contact us immediately.
Best regards,
Your {organizer} team""")) # noqa: W291 Your {organizer} team""")) # noqa: W291
}, },
'smtp_use_custom': { 'smtp_use_custom': {

View File

@@ -635,6 +635,16 @@ class MailSettingsForm(SettingsForm):
required=False, required=False,
widget=I18nMarkdownTextarea, widget=I18nMarkdownTextarea,
) )
mail_subject_customer_security_notice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_security_notice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
)
base_context = { base_context = {
'mail_text_customer_registration': ['customer', 'url'], 'mail_text_customer_registration': ['customer', 'url'],
@@ -643,6 +653,8 @@ class MailSettingsForm(SettingsForm):
'mail_subject_customer_email_change': ['customer', 'url'], 'mail_subject_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'], 'mail_text_customer_reset': ['customer', 'url'],
'mail_subject_customer_reset': ['customer', 'url'], 'mail_subject_customer_reset': ['customer', 'url'],
'mail_text_customer_security_notice': ['customer', 'url', 'message'],
'mail_subject_customer_security_notice': ['customer', 'url', 'message'],
} }
def _get_sample_context(self, base_parameters): def _get_sample_context(self, base_parameters):
@@ -656,6 +668,9 @@ class MailSettingsForm(SettingsForm):
'presale:organizer.customer.activate' 'presale:organizer.customer.activate'
) + '?token=' + get_random_string(30) ) + '?token=' + get_random_string(30)
if 'message' in base_parameters:
placeholders['message'] = _('Your password has been changed.')
if 'customer' in base_parameters: if 'customer' in base_parameters:
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe') placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme] name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]

View File

@@ -65,6 +65,9 @@
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %} {% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
{% blocktrans asvar title_security_notice %}Customer account security notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="security_notice" title=title_security_notice items="mail_subject_customer_security_notice,mail_text_customer_security_notice" %}
</div> </div>
</fieldset> </fieldset>
</div> </div>

View File

@@ -286,6 +286,7 @@ class SetPasswordView(FormView):
self.customer.is_verified = True self.customer.is_verified = True
self.customer.save() self.customer.save()
self.customer.log_action('pretix.customer.password.set', {}) self.customer.log_action('pretix.customer.password.set', {})
self.customer.send_security_notice(_("Your password has been changed."))
messages.success( messages.success(
self.request, self.request,
_('Your new password has been set! You can now use it to log in.'), _('Your new password has been set! You can now use it to log in.'),
@@ -541,6 +542,7 @@ class ChangePasswordView(CustomerAccountBaseMixin, FormView):
customer.set_password(form.cleaned_data['password']) customer.set_password(form.cleaned_data['password'])
customer.save() customer.save()
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
customer.send_security_notice(_("Your password has been changed."))
update_customer_session_auth_hash(self.request, customer) update_customer_session_auth_hash(self.request, customer)
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
@@ -631,11 +633,15 @@ class ConfirmChangeView(View):
try: try:
with transaction.atomic(): with transaction.atomic():
old_email = customer.email
customer.email = data['email'] customer.email = data['email']
customer.save() customer.save()
customer.log_action('pretix.customer.changed', { customer.log_action('pretix.customer.changed', {
'email': data['email'] 'email': data['email']
}) })
msg = _('Your email address has been changed from {old_email} to {email}.').format(old_email=old_email, email=customer.email)
customer.send_security_notice(msg, email=old_email)
customer.send_security_notice(msg, email=customer.email)
except IntegrityError: except IntegrityError:
messages.success(request, _('Your email address has not been updated since the address is already in use ' messages.success(request, _('Your email address has not been updated since the address is already in use '
'for another customer account.')) 'for another customer account.'))

View File

@@ -164,6 +164,7 @@ def test_org_resetpw(env, client):
customer.refresh_from_db() customer.refresh_from_db()
assert customer.check_password('PANioMR62') assert customer.check_password('PANioMR62')
assert customer.is_verified assert customer.is_verified
assert len(djmail.outbox) == 2
@pytest.mark.django_db @pytest.mark.django_db
@@ -623,6 +624,7 @@ def test_change_email(env, client):
customer.refresh_from_db() customer.refresh_from_db()
assert customer.email == 'john@example.org' assert customer.email == 'john@example.org'
assert len(djmail.outbox) == 1 assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == ['john@example.com']
token = dumps({ token = dumps({
'customer': customer.pk, 'customer': customer.pk,
@@ -632,6 +634,9 @@ def test_change_email(env, client):
assert r.status_code == 302 assert r.status_code == 302
customer.refresh_from_db() customer.refresh_from_db()
assert customer.email == 'john@example.com' assert customer.email == 'john@example.com'
assert len(djmail.outbox) == 3
assert djmail.outbox[1].to == ['john@example.org']
assert djmail.outbox[2].to == ['john@example.com']
@pytest.mark.django_db @pytest.mark.django_db
@@ -673,6 +678,7 @@ def test_change_pw(env, client, client2):
r = client.get('/bigevents/account/password') r = client.get('/bigevents/account/password')
assert r.status_code == 200 assert r.status_code == 200
assert len(djmail.outbox) == 1
# Client 2 got logged out # Client 2 got logged out
r = client2.post('/bigevents/account/password') r = client2.post('/bigevents/account/password')