Validation of user email addresses (#5434)

* Validation of user email addresses
* Improve email and password change forms
This commit is contained in:
luelista
2025-11-07 11:17:34 +01:00
committed by GitHub
parent a0dbf6c5db
commit 1cb2d443f9
20 changed files with 620 additions and 142 deletions

View File

@@ -214,21 +214,38 @@ class PasswordRecoverForm(forms.Form):
error_messages = {
'pw_mismatch': _("Please enter the same password twice"),
}
email = forms.EmailField(
max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
),
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
max_length=4096,
)
def __init__(self, user_id=None, *args, **kwargs):
self.user_id = user_id
super().__init__(*args, **kwargs)
initial = kwargs.pop('initial', {})
try:
self.user = User.objects.get(id=user_id)
initial['email'] = self.user.email
except User.DoesNotExist:
self.user = None
super().__init__(*args, initial=initial, **kwargs)
def clean(self):
password1 = self.cleaned_data.get('password', '')
@@ -243,11 +260,7 @@ class PasswordRecoverForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
try:
user = User.objects.get(id=self.user_id)
except User.DoesNotExist:
user = None
if validate_password(password1, user=user) is not None:
if validate_password(password1, user=self.user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
@@ -307,3 +320,10 @@ class ReauthForm(forms.Form):
self.error_messages['inactive'],
code='inactive',
)
class ConfirmationCodeForm(forms.Form):
code = forms.IntegerField(
label=_('Confirmation code'),
widget=forms.NumberInput(attrs={'class': 'confirmation-code-input', 'inputmode': 'numeric', 'type': 'text'}),
)

View File

@@ -39,37 +39,16 @@ from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.db.models import Q
from django.urls.base import reverse
from django.utils.translation import gettext_lazy as _
from pytz import common_timezones
from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
class UserSettingsForm(forms.ModelForm):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput())
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput())
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput())
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
@@ -93,11 +72,60 @@ class UserSettingsForm(forms.ModelForm):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['email'].required = True
if self.user.auth_backend != 'native':
del self.fields['old_pw']
del self.fields['new_pw']
del self.fields['new_pw_repeat']
self.fields['email'].disabled = True
self.fields['email'].disabled = True
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
'text': _("Change email address"),
'link': reverse('control:user.settings.email.change')
})
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
class UserPasswordChangeForm(forms.Form):
error_messages = {
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
email = forms.EmailField(max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
))
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'current-password'},
))
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
initial = kwargs.pop('initial', {})
initial['email'] = self.user.email
super().__init__(*args, initial=initial, **kwargs)
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
@@ -121,15 +149,6 @@ class UserSettingsForm(forms.ModelForm):
return old_pw
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
return email
def clean_new_pw(self):
password1 = self.cleaned_data.get('new_pw', '')
if password1 and validate_password(password1, user=self.user) is not None:
@@ -148,32 +167,24 @@ class UserSettingsForm(forms.ModelForm):
code='pw_mismatch'
)
def clean(self):
password1 = self.cleaned_data.get('new_pw')
email = self.cleaned_data.get('email')
old_pw = self.cleaned_data.get('old_pw')
if (password1 or email != self.user.email) and not old_pw:
class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
}
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')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError(
self.error_messages['pw_current'],
code='pw_current'
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
if password1 and password1 == old_pw:
raise forms.ValidationError(
self.error_messages['pw_equal'],
code='pw_equal'
)
if password1:
self.instance.set_password(password1)
return self.cleaned_data
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
return email

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.23 on 2025-09-04 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0293_cartposition_price_includes_rounding_correction_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_verified",
field=models.BooleanField(default=False),
),
]

View File

@@ -35,6 +35,7 @@
import binascii
import json
import operator
import secrets
from datetime import timedelta
from functools import reduce
@@ -44,6 +45,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.exceptions import BadRequest, PermissionDenied
from django.db import IntegrityError, models, transaction
from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac
@@ -239,9 +241,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190)
is_verified = models.BooleanField(default=False, verbose_name=_('Verified email address'))
fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name'))
is_active = models.BooleanField(default=True,
@@ -353,6 +357,77 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
except SendMailException:
pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None):
"""
Sends a confirmation code via email to the user. The code is only valid for the action specified by `reason`.
The email is either sent to the email address currently on file for the user, or to the one given in the optional `email` parameter.
A `state` value can be provided which is bound to this confirmation code, and returned on successfully checking the code.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code (currently, only `email_change` is allowed)
:param email: optional, the email address to send the confirmation code to
:param state: optional
"""
from pretix.base.services.mail import mail
with language(self.locale):
if reason == 'email_change':
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
old_email=self.email, new_email=email,
))
elif reason == 'email_verify':
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
email=self.email,
))
else:
raise Exception('Invalid confirmation code reason')
code = "%07d" % secrets.SystemRandom().randint(0, 9999999)
session['user_confirmation_code:' + reason] = {
'code': code,
'state': state,
'attempts': 0,
}
mail(
email or self.email,
_('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt',
{
'user': self,
'reason': msg,
'code': code,
},
event=None,
user=self,
locale=self.locale
)
def check_confirmation_code(self, session, reason, code):
"""
Checks a confirmation code entered by the user against the valid code stored in the session.
If the code is correct, an optional state bound to the code is returned.
If the code is incorrect, PermissionDenied is raised. If the code could not be validated, either because no
code for the given reason is stored, or the number of input attempts is exceeded, BadRequest is raised.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code
:param code: the code entered by the user
:return: optional state bound to this code using the state parameter of send_confirmation_code, None otherwise
"""
stored = session.get('user_confirmation_code:' + reason)
if not stored:
raise BadRequest
if stored['attempts'] > User.MAX_CONFIRMATION_CODE_ATTEMPTS:
raise BadRequest
if int(stored['code']) == int(code):
del session['user_confirmation_code:' + reason]
return stored['state']
else:
stored['attempts'] += 1
session['user_confirmation_code:' + reason] = stored
raise PermissionDenied
def send_password_reset(self):
from pretix.base.services.mail import mail