mirror of
https://github.com/pretix/pretix.git
synced 2025-12-15 14:02:27 +00:00
Compare commits
33 Commits
recovery-c
...
validate-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3689d4a5a0 | ||
|
|
867ea39a0d | ||
|
|
b060968f62 | ||
|
|
9a47eb6385 | ||
|
|
be67e40f1c | ||
|
|
c99a9ebe9a | ||
|
|
b5cac90475 | ||
|
|
58d36706b2 | ||
|
|
c520f77bbb | ||
|
|
73ceeffc7f | ||
|
|
7b6c82c341 | ||
|
|
35ac277b3d | ||
|
|
eb731f305b | ||
|
|
bf9af08cab | ||
|
|
95639dc6e1 | ||
|
|
966d6bb8e9 | ||
|
|
2c20bf972f | ||
|
|
0deba91e4b | ||
|
|
b9b937ea0d | ||
|
|
410215f575 | ||
|
|
1fa84b772b | ||
|
|
cda950befc | ||
|
|
783d51b75f | ||
|
|
7333f82e45 | ||
|
|
b2a4ba96f8 | ||
|
|
81f5af8414 | ||
|
|
ce1406b158 | ||
|
|
b5ad68f48d | ||
|
|
5512fa8245 | ||
|
|
83f891ce24 | ||
|
|
04e4e33885 | ||
|
|
2a98907e88 | ||
|
|
22e7962a29 |
@@ -214,21 +214,38 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
error_messages = {
|
error_messages = {
|
||||||
'pw_mismatch': _("Please enter the same password twice"),
|
'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(
|
password = forms.CharField(
|
||||||
label=_('Password'),
|
label=_('Password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput(attrs={
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}),
|
||||||
max_length=4096,
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
label=_('Repeat password'),
|
label=_('Repeat password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput(attrs={
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}),
|
||||||
max_length=4096,
|
max_length=4096,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user_id=None, *args, **kwargs):
|
def __init__(self, user_id=None, *args, **kwargs):
|
||||||
self.user_id = user_id
|
initial = kwargs.pop('initial', {})
|
||||||
super().__init__(*args, **kwargs)
|
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):
|
def clean(self):
|
||||||
password1 = self.cleaned_data.get('password', '')
|
password1 = self.cleaned_data.get('password', '')
|
||||||
@@ -243,11 +260,7 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
|
|
||||||
def clean_password(self):
|
def clean_password(self):
|
||||||
password1 = self.cleaned_data.get('password', '')
|
password1 = self.cleaned_data.get('password', '')
|
||||||
try:
|
if validate_password(password1, user=self.user) is not None:
|
||||||
user = User.objects.get(id=self.user_id)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
user = None
|
|
||||||
if validate_password(password1, user=user) is not None:
|
|
||||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||||
return password1
|
return password1
|
||||||
|
|
||||||
@@ -307,3 +320,10 @@ class ReauthForm(forms.Form):
|
|||||||
self.error_messages['inactive'],
|
self.error_messages['inactive'],
|
||||||
code='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'}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -39,37 +39,16 @@ from django.contrib.auth.password_validation import (
|
|||||||
password_validators_help_texts, validate_password,
|
password_validators_help_texts, validate_password,
|
||||||
)
|
)
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.urls.base import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from pytz import common_timezones
|
from pytz import common_timezones
|
||||||
|
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
from pretix.control.forms import SingleLanguageWidget
|
from pretix.control.forms import SingleLanguageWidget
|
||||||
|
from pretix.helpers.format import format_map
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsForm(forms.ModelForm):
|
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(
|
timezone = forms.ChoiceField(
|
||||||
choices=((a, a) for a in common_timezones),
|
choices=((a, a) for a in common_timezones),
|
||||||
label=_("Default timezone"),
|
label=_("Default timezone"),
|
||||||
@@ -93,11 +72,60 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
self.user = kwargs.pop('user')
|
self.user = kwargs.pop('user')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['email'].required = True
|
self.fields['email'].required = True
|
||||||
if self.user.auth_backend != 'native':
|
self.fields['email'].disabled = True
|
||||||
del self.fields['old_pw']
|
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
|
||||||
del self.fields['new_pw']
|
'text': _("Change email address"),
|
||||||
del self.fields['new_pw_repeat']
|
'link': reverse('control:user.settings.email.change')
|
||||||
self.fields['email'].disabled = True
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def clean_old_pw(self):
|
||||||
old_pw = self.cleaned_data.get('old_pw')
|
old_pw = self.cleaned_data.get('old_pw')
|
||||||
@@ -121,15 +149,6 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
|
|
||||||
return old_pw
|
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):
|
def clean_new_pw(self):
|
||||||
password1 = self.cleaned_data.get('new_pw', '')
|
password1 = self.cleaned_data.get('new_pw', '')
|
||||||
if password1 and validate_password(password1, user=self.user) is not None:
|
if password1 and validate_password(password1, user=self.user) is not None:
|
||||||
@@ -148,32 +167,24 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
code='pw_mismatch'
|
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(
|
raise forms.ValidationError(
|
||||||
self.error_messages['pw_current'],
|
self.error_messages['duplicate_identifier'],
|
||||||
code='pw_current'
|
code='duplicate_identifier',
|
||||||
)
|
)
|
||||||
|
return email
|
||||||
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)')),
|
|
||||||
))
|
|
||||||
|
|||||||
18
src/pretix/base/migrations/0294_user_is_verified.py
Normal file
18
src/pretix/base/migrations/0294_user_is_verified.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
import binascii
|
import binascii
|
||||||
import json
|
import json
|
||||||
import operator
|
import operator
|
||||||
|
import secrets
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import reduce
|
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.auth.tokens import default_token_generator
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import BadRequest, PermissionDenied
|
||||||
from django.db import IntegrityError, models, transaction
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
@@ -239,9 +241,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
USERNAME_FIELD = 'email'
|
USERNAME_FIELD = 'email'
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
|
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
|
||||||
|
|
||||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||||
verbose_name=_('Email'), max_length=190)
|
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,
|
fullname = models.CharField(max_length=255, blank=True, null=True,
|
||||||
verbose_name=_('Full name'))
|
verbose_name=_('Full name'))
|
||||||
is_active = models.BooleanField(default=True,
|
is_active = models.BooleanField(default=True,
|
||||||
@@ -353,6 +357,77 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
except SendMailException:
|
except SendMailException:
|
||||||
pass # Already logged
|
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):
|
def send_password_reset(self):
|
||||||
from pretix.base.services.mail import mail
|
from pretix.base.services.mail import mail
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class UserEditForm(forms.ModelForm):
|
|||||||
'email',
|
'email',
|
||||||
'require_2fa',
|
'require_2fa',
|
||||||
'is_active',
|
'is_active',
|
||||||
|
'is_verified',
|
||||||
'is_staff',
|
'is_staff',
|
||||||
'needs_password_change',
|
'needs_password_change',
|
||||||
'last_login'
|
'last_login'
|
||||||
|
|||||||
@@ -667,6 +667,14 @@ class UserSettingsChangedLogEntryType(LogEntryType):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@log_entry_types.new_from_dict({
|
||||||
|
'pretix.user.email.changed': _('Your email address has been changed from {old_email} to {email}.'),
|
||||||
|
'pretix.user.email.confirmed': _('Your email address {email} has been confirmed.'),
|
||||||
|
})
|
||||||
|
class UserEmailChangedLogEntryType(LogEntryType):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserImpersonatedLogEntryType(LogEntryType):
|
class UserImpersonatedLogEntryType(LogEntryType):
|
||||||
def display(self, logentry, data):
|
def display(self, logentry, data):
|
||||||
return self.plain.format(data['other_email'])
|
return self.plain.format(data['other_email'])
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class PermissionMiddleware:
|
|||||||
)
|
)
|
||||||
|
|
||||||
EXCEPTIONS_FORCED_PW_CHANGE = (
|
EXCEPTIONS_FORCED_PW_CHANGE = (
|
||||||
"user.settings",
|
"user.settings.password.change",
|
||||||
"auth.logout"
|
"auth.logout"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ class PermissionMiddleware:
|
|||||||
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||||
except SessionPasswordChangeRequired:
|
except SessionPasswordChangeRequired:
|
||||||
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
||||||
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
return redirect_to_url(reverse('control:user.settings.password.change') + '?next=' + quote(request.get_full_path()))
|
||||||
except Session2FASetupRequired:
|
except Session2FASetupRequired:
|
||||||
if url_name not in self.EXCEPTIONS_2FA:
|
if url_name not in self.EXCEPTIONS_2FA:
|
||||||
return redirect_to_url(reverse('control:user.settings.2fa'))
|
return redirect_to_url(reverse('control:user.settings.2fa'))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<h3>{% trans "Set new password" %}</h3>
|
<h3>{% trans "Set new password" %}</h3>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% bootstrap_form_errors form type='all' layout='inline' %}
|
{% bootstrap_form_errors form type='all' layout='inline' %}
|
||||||
|
{% bootstrap_field form.email %}
|
||||||
{% bootstrap_field form.password %}
|
{% bootstrap_field form.password %}
|
||||||
{% bootstrap_field form.password_repeat %}
|
{% bootstrap_field form.password_repeat %}
|
||||||
<div class="form-group buttons">
|
<div class="form-group buttons">
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
|
||||||
|
|
||||||
|
{{ reason }}
|
||||||
|
|
||||||
|
{{ code }}
|
||||||
|
|
||||||
|
Please do never give this code to another person. Our support team will never ask for this code.
|
||||||
|
|
||||||
|
If this code was not requested by you, please contact us immediately.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your pretix team
|
||||||
|
{% endblocktrans %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "pretixcontrol/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Change login email address" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<form action="" method="post" class="form centered-form">
|
||||||
|
<h1>
|
||||||
|
{% trans "Change login email address" %}
|
||||||
|
</h1>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form_errors form %}
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
|
||||||
|
</p>
|
||||||
|
{% bootstrap_field form.old_email %}
|
||||||
|
{% bootstrap_field form.new_email %}
|
||||||
|
<p>
|
||||||
|
{% trans "We will send a confirmation code to your new email address, which you need to enter in the next step to confirm the email address is correct." %}
|
||||||
|
</p>
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<a href="{% url "control:user.settings" %}" class="btn btn-default btn-cancel">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary btn-save btn-lg">
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "pretixcontrol/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Change password" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<form action="" method="post" class="form centered-form">
|
||||||
|
<h1>
|
||||||
|
{% trans "Change password" %}
|
||||||
|
</h1>
|
||||||
|
<br>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form_errors form %}
|
||||||
|
{% bootstrap_field form.email %}
|
||||||
|
{% bootstrap_field form.old_pw %}
|
||||||
|
{% bootstrap_field form.new_pw %}
|
||||||
|
{% bootstrap_field form.new_pw_repeat %}
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<a href="{% url "control:user.settings" %}" class="btn btn-default btn-cancel">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="btn btn-primary btn-save btn-lg">
|
||||||
|
{% trans "Change password" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "pretixcontrol/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Enter confirmation code" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<form action="" method="post" class="form centered-form">
|
||||||
|
<h1>
|
||||||
|
{% trans "Enter confirmation code" %}
|
||||||
|
</h1>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form_errors form type='all' layout='inline' %}
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{% bootstrap_field form.code %}
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<a href="{{ cancel_url }}" class="btn btn-default btn-cancel">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="btn btn-primary btn-save btn-lg">
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,8 +3,26 @@
|
|||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% block title %}{% trans "Account settings" %}{% endblock %}
|
{% block title %}{% trans "Account settings" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if not user.is_verified %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Your email address is not confirmed yet. To secure your account, please confirm your email address using
|
||||||
|
a confirmation code we will send to your email address.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<form action="{% url "control:user.settings.email.send_verification_code" %}" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{% trans "Send confirmation email" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<h1>{% trans "Account settings" %}</h1>
|
<h1>{% trans "Account settings" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal" data-testid="usersettingsform">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% bootstrap_form_errors form %}
|
{% bootstrap_form_errors form %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -13,7 +31,7 @@
|
|||||||
{% bootstrap_field form.locale layout='horizontal' %}
|
{% bootstrap_field form.locale layout='horizontal' %}
|
||||||
{% bootstrap_field form.timezone layout='horizontal' %}
|
{% bootstrap_field form.timezone layout='horizontal' %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Notifications" %}</label>
|
<label class="col-md-3 control-label">{% trans "Notifications" %}</label>
|
||||||
<div class="col-md-9 static-form-row">
|
<div class="col-md-9 static-form-row">
|
||||||
{% if request.user.notifications_send and request.user.notification_settings.exists %}
|
{% if request.user.notifications_send and request.user.notification_settings.exists %}
|
||||||
<span class="label label-success">
|
<span class="label label-success">
|
||||||
@@ -41,8 +59,18 @@
|
|||||||
{% bootstrap_field form.new_pw layout='horizontal' %}
|
{% bootstrap_field form.new_pw layout='horizontal' %}
|
||||||
{% bootstrap_field form.new_pw_repeat layout='horizontal' %}
|
{% bootstrap_field form.new_pw_repeat layout='horizontal' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if user.auth_backend == 'native' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">{% trans "Password" %}</label>
|
||||||
|
<div class="col-md-9 static-form-row">
|
||||||
|
<a href="{% url "control:user.settings.password.change" %}">
|
||||||
|
<span class="fa fa-edit"></span> {% trans "Change password" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Two-factor authentication" %}</label>
|
<label class="col-md-3 control-label">{% trans "Two-factor authentication" %}</label>
|
||||||
<div class="col-md-9 static-form-row">
|
<div class="col-md-9 static-form-row">
|
||||||
{% if user.require_2fa %}
|
{% if user.require_2fa %}
|
||||||
<span class="label label-success">{% trans "Enabled" %}</span>
|
<span class="label label-success">{% trans "Enabled" %}</span>
|
||||||
@@ -58,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="col-md-3 control-label" for="">{% trans "Authorized applications" %}</label>
|
<label class="col-md-3 control-label">{% trans "Authorized applications" %}</label>
|
||||||
<div class="col-md-9 static-form-row">
|
<div class="col-md-9 static-form-row">
|
||||||
<a href="{% url "control:user.settings.oauth.list" %}">
|
<a href="{% url "control:user.settings.oauth.list" %}">
|
||||||
<span class="fa fa-plug"></span>
|
<span class="fa fa-plug"></span>
|
||||||
@@ -67,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="col-md-3 control-label" for="">{% trans "Account history" %}</label>
|
<label class="col-md-3 control-label">{% trans "Account history" %}</label>
|
||||||
<div class="col-md-9 static-form-row">
|
<div class="col-md-9 static-form-row">
|
||||||
<a href="{% url "control:user.settings.history" %}">
|
<a href="{% url "control:user.settings.history" %}">
|
||||||
<span class="fa fa-history"></span>
|
<span class="fa fa-history"></span>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
{% if form.new_pw %}
|
{% if form.new_pw %}
|
||||||
{% bootstrap_field form.new_pw layout='control' %}
|
{% bootstrap_field form.new_pw layout='control' %}
|
||||||
{% bootstrap_field form.new_pw_repeat layout='control' %}
|
{% bootstrap_field form.new_pw_repeat layout='control' %}
|
||||||
|
{% bootstrap_field form.is_verified layout='control' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% bootstrap_field form.last_login layout='control' %}
|
{% bootstrap_field form.last_login layout='control' %}
|
||||||
{% bootstrap_field form.require_2fa layout='control' %}
|
{% bootstrap_field form.require_2fa layout='control' %}
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ urlpatterns = [
|
|||||||
name='user.settings.2fa.confirm.webauthn'),
|
name='user.settings.2fa.confirm.webauthn'),
|
||||||
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
|
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
|
||||||
name='user.settings.2fa.delete'),
|
name='user.settings.2fa.delete'),
|
||||||
|
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
|
||||||
|
re_path(r'^settings/email/change$', user.UserEmailChangeView.as_view(), name='user.settings.email.change'),
|
||||||
|
re_path(r'^settings/email/verify', user.UserEmailVerifyView.as_view(), name='user.settings.email.send_verification_code'),
|
||||||
|
re_path(r'^settings/password/change$', user.UserPasswordChangeView.as_view(), name='user.settings.password.change'),
|
||||||
re_path(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'),
|
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/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
|
||||||
re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
|
re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
|
||||||
|
|||||||
@@ -44,11 +44,13 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import BadRequest, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.functional import cached_property
|
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.http import url_has_allowed_host_and_scheme
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -60,8 +62,11 @@ from django_scopes import scopes_disabled
|
|||||||
from webauthn.helpers import generate_challenge, generate_user_handle
|
from webauthn.helpers import generate_challenge, generate_user_handle
|
||||||
|
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
from pretix.base.forms.auth import ReauthForm
|
from pretix.base.forms.auth import ConfirmationCodeForm, ReauthForm
|
||||||
from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm
|
from pretix.base.forms.user import (
|
||||||
|
User2FADeviceAddForm, UserEmailChangeForm, UserPasswordChangeForm,
|
||||||
|
UserSettingsForm,
|
||||||
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice,
|
Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice,
|
||||||
)
|
)
|
||||||
@@ -237,25 +242,7 @@ class UserSettings(UpdateView):
|
|||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
for k in form.changed_data:
|
for k in form.changed_data:
|
||||||
if k not in ('old_pw', 'new_pw_repeat'):
|
data[k] = form.cleaned_data[k]
|
||||||
if 'new_pw' == k:
|
|
||||||
data['new_pw'] = True
|
|
||||||
else:
|
|
||||||
data[k] = form.cleaned_data[k]
|
|
||||||
|
|
||||||
msgs = []
|
|
||||||
|
|
||||||
if 'new_pw' in form.changed_data:
|
|
||||||
self.request.user.needs_password_change = False
|
|
||||||
msgs.append(_('Your password has been changed.'))
|
|
||||||
|
|
||||||
if 'email' in form.changed_data:
|
|
||||||
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)
|
|
||||||
|
|
||||||
sup = super().form_valid(form)
|
sup = super().form_valid(form)
|
||||||
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data=data)
|
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data=data)
|
||||||
@@ -834,3 +821,159 @@ class EditStaffSession(StaffMemberRequiredMixin, UpdateView):
|
|||||||
return get_object_or_404(StaffSession, pk=self.kwargs['id'])
|
return get_object_or_404(StaffSession, pk=self.kwargs['id'])
|
||||||
else:
|
else:
|
||||||
return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user)
|
return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordChangeView(FormView):
|
||||||
|
max_time = 300
|
||||||
|
|
||||||
|
form_class = UserPasswordChangeForm
|
||||||
|
template_name = 'pretixcontrol/user/change_password.html'
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
if self.request.user.auth_backend != 'native':
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return {
|
||||||
|
**super().get_form_kwargs(),
|
||||||
|
"user": self.request.user,
|
||||||
|
}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
with transaction.atomic():
|
||||||
|
self.request.user.set_password(form.cleaned_data['new_pw'])
|
||||||
|
self.request.user.needs_password_change = False
|
||||||
|
self.request.user.save()
|
||||||
|
msgs = []
|
||||||
|
msgs.append(_('Your password has been changed.'))
|
||||||
|
self.request.user.send_security_notice(msgs)
|
||||||
|
|
||||||
|
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data={'new_pw': True})
|
||||||
|
|
||||||
|
update_session_auth_hash(self.request, self.request.user)
|
||||||
|
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
if "next" in self.request.GET and url_has_allowed_host_and_scheme(self.request.GET.get("next"), allowed_hosts=None):
|
||||||
|
return self.request.GET.get("next")
|
||||||
|
return reverse('control:user.settings')
|
||||||
|
|
||||||
|
|
||||||
|
class UserEmailChangeView(RecentAuthenticationRequiredMixin, FormView):
|
||||||
|
max_time = 300
|
||||||
|
|
||||||
|
form_class = UserEmailChangeForm
|
||||||
|
template_name = 'pretixcontrol/user/change_email.html'
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
if self.request.user.auth_backend != 'native':
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return {
|
||||||
|
**super().get_form_kwargs(),
|
||||||
|
"user": self.request.user,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
return {
|
||||||
|
"old_email": self.request.user.email
|
||||||
|
}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.request.user.send_confirmation_code(
|
||||||
|
session=self.request.session,
|
||||||
|
reason='email_change',
|
||||||
|
email=form.cleaned_data['new_email'],
|
||||||
|
state=form.cleaned_data['new_email'],
|
||||||
|
)
|
||||||
|
self.request.session['email_confirmation_destination'] = form.cleaned_data['new_email']
|
||||||
|
return redirect(reverse('control:user.settings.email.confirm', kwargs={}) + '?reason=email_change')
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class UserEmailVerifyView(View):
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
if self.request.user.is_verified:
|
||||||
|
messages.success(self.request, _('Your email address was already verified.'))
|
||||||
|
return redirect(reverse('control:user.settings', kwargs={}))
|
||||||
|
|
||||||
|
self.request.user.send_confirmation_code(
|
||||||
|
session=self.request.session,
|
||||||
|
reason='email_verify',
|
||||||
|
email=self.request.user.email,
|
||||||
|
state=self.request.user.email,
|
||||||
|
)
|
||||||
|
self.request.session['email_confirmation_destination'] = self.request.user.email
|
||||||
|
return redirect(reverse('control:user.settings.email.confirm', kwargs={}) + '?reason=email_verify')
|
||||||
|
|
||||||
|
|
||||||
|
class UserEmailConfirmView(FormView):
|
||||||
|
form_class = ConfirmationCodeForm
|
||||||
|
template_name = 'pretixcontrol/user/confirmation_code_dialog.html'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return {
|
||||||
|
**super().get_context_data(**kwargs),
|
||||||
|
"cancel_url": reverse('control:user.settings', kwargs={}),
|
||||||
|
"message": format_html(
|
||||||
|
_("Please enter the confirmation code we sent to your email address <strong>{email}</strong>."),
|
||||||
|
email=self.request.session.get('email_confirmation_destination', ''),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@transaction.atomic()
|
||||||
|
def form_valid(self, form):
|
||||||
|
reason = self.request.GET['reason']
|
||||||
|
if reason not in ('email_change', 'email_verify'):
|
||||||
|
raise PermissionDenied
|
||||||
|
try:
|
||||||
|
new_email = self.request.user.check_confirmation_code(
|
||||||
|
session=self.request.session,
|
||||||
|
reason=reason,
|
||||||
|
code=form.cleaned_data['code'],
|
||||||
|
)
|
||||||
|
except PermissionDenied:
|
||||||
|
return self.form_invalid(form)
|
||||||
|
except BadRequest:
|
||||||
|
messages.error(self.request, _(
|
||||||
|
'We were unable to verify your confirmation code. Please try again.'
|
||||||
|
))
|
||||||
|
return redirect(reverse('control:user.settings', kwargs={}))
|
||||||
|
|
||||||
|
log_data = {
|
||||||
|
'email': new_email,
|
||||||
|
'email_verified': True,
|
||||||
|
}
|
||||||
|
if reason == 'email_change':
|
||||||
|
msgs = []
|
||||||
|
msgs.append(_('Your email address has been changed to {email}.').format(email=new_email))
|
||||||
|
log_data['old_email'] = old_email = self.request.user.email
|
||||||
|
self.request.user.send_security_notice(msgs, email=old_email)
|
||||||
|
self.request.user.send_security_notice(msgs, email=new_email)
|
||||||
|
log_action = 'pretix.user.email.changed'
|
||||||
|
else:
|
||||||
|
log_action = 'pretix.user.email.confirmed'
|
||||||
|
|
||||||
|
self.request.user.email = new_email
|
||||||
|
self.request.user.is_verified = True
|
||||||
|
self.request.user.save()
|
||||||
|
self.request.user.log_action(log_action, user=self.request.user, data=log_data)
|
||||||
|
update_session_auth_hash(self.request, self.request.user)
|
||||||
|
|
||||||
|
if reason == 'email_change':
|
||||||
|
messages.success(self.request, _('Your email address has been changed successfully.'))
|
||||||
|
else:
|
||||||
|
messages.success(self.request, _('Your email address has been confirmed successfully.'))
|
||||||
|
return redirect(reverse('control:user.settings', kwargs={}))
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, _('The entered confirmation code is not correct. Please try again.'))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|||||||
@@ -263,3 +263,11 @@ svg.svg-icon {
|
|||||||
@include table-row-variant('warning', var(--pretix-brand-warning-lighten-40), var(--pretix-brand-warning-lighten-35));
|
@include table-row-variant('warning', var(--pretix-brand-warning-lighten-40), var(--pretix-brand-warning-lighten-35));
|
||||||
@include table-row-variant('danger', var(--pretix-brand-danger-lighten-30), var(--pretix-brand-danger-lighten-25));
|
@include table-row-variant('danger', var(--pretix-brand-danger-lighten-30), var(--pretix-brand-danger-lighten-25));
|
||||||
|
|
||||||
|
.confirmation-code-input {
|
||||||
|
font-size: 200%;
|
||||||
|
font-family: monospace;
|
||||||
|
font-stretch: expanded;
|
||||||
|
text-align: center;
|
||||||
|
height: 50px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -938,3 +938,25 @@ details {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (min-width: $screen-lg-min) {
|
||||||
|
.centered-form {
|
||||||
|
margin: 80px auto;
|
||||||
|
max-width: 800px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 20px 40px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 2px 2px 2px #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form.centered-form .submit-group {
|
||||||
|
margin: 25px -40px 0 !important;
|
||||||
|
padding-right: 40px;
|
||||||
|
padding-left: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-form p {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1134,7 +1134,7 @@ class PasswordChangeRequiredTest(TestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||||
|
|
||||||
def test_redirect_to_settings(self):
|
def test_redirect_to_password_change(self):
|
||||||
self.user.needs_password_change = True
|
self.user.needs_password_change = True
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.client.login(email='dummy@dummy.dummy', password='dummy')
|
self.client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
@@ -1143,9 +1143,9 @@ class PasswordChangeRequiredTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
assert self.user.needs_password_change is True
|
assert self.user.needs_password_change is True
|
||||||
self.assertIn('/control/settings?next=/control/events/', response['Location'])
|
self.assertIn('/control/settings/password/change?next=/control/events/', response['Location'])
|
||||||
|
|
||||||
def test_redirect_to_2fa_to_settings(self):
|
def test_redirect_to_2fa_to_password_change(self):
|
||||||
self.user.require_2fa = True
|
self.user.require_2fa = True
|
||||||
self.user.needs_password_change = True
|
self.user.needs_password_change = True
|
||||||
self.user.save()
|
self.user.save()
|
||||||
@@ -1168,4 +1168,4 @@ class PasswordChangeRequiredTest(TestCase):
|
|||||||
response = self.client.get('/control/events/')
|
response = self.client.get('/control/events/')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn('/control/settings?next=/control/events/', response['Location'])
|
self.assertIn('/control/settings/password/change?next=/control/events/', response['Location'])
|
||||||
|
|||||||
@@ -19,22 +19,11 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# 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/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import re
|
||||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
|
||||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
|
||||||
#
|
|
||||||
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
|
||||||
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
|
||||||
#
|
|
||||||
# This file contains Apache-licensed contributions copyrighted by: Jason Estibeiro
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
|
||||||
# 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 time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.core import mail as djmail
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_otp.oath import TOTP
|
from django_otp.oath import TOTP
|
||||||
from django_otp.plugins.otp_static.models import StaticDevice
|
from django_otp.plugins.otp_static.models import StaticDevice
|
||||||
@@ -56,7 +45,7 @@ class UserSettingsTest(SoupTest):
|
|||||||
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
|
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
|
||||||
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
|
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
|
||||||
doc = self.get_doc('/control/settings')
|
doc = self.get_doc('/control/settings')
|
||||||
self.form_data = extract_form_fields(doc.select('.container-fluid form')[0])
|
self.form_data = extract_form_fields(doc.select('form[data-testid="usersettingsform"]')[0])
|
||||||
|
|
||||||
def save(self, data):
|
def save(self, data):
|
||||||
form_data = self.form_data.copy()
|
form_data = self.form_data.copy()
|
||||||
@@ -71,33 +60,107 @@ class UserSettingsTest(SoupTest):
|
|||||||
self.user = User.objects.get(pk=self.user.pk)
|
self.user = User.objects.get(pk=self.user.pk)
|
||||||
assert self.user.fullname == 'Peter Miller'
|
assert self.user.fullname == 'Peter Miller'
|
||||||
|
|
||||||
def test_change_email_require_password(self):
|
def test_set_locale_and_timezone(self):
|
||||||
doc = self.save({
|
doc = self.save({
|
||||||
'email': 'foo@example.com',
|
'locale': 'fr',
|
||||||
|
'timezone': 'Europe/Paris',
|
||||||
})
|
})
|
||||||
assert doc.select(".alert-danger")
|
assert doc.select(".alert-success")
|
||||||
self.user = User.objects.get(pk=self.user.pk)
|
self.user = User.objects.get(pk=self.user.pk)
|
||||||
assert self.user.email == 'dummy@dummy.dummy'
|
assert self.user.locale == 'fr'
|
||||||
|
assert self.user.timezone == 'Europe/Paris'
|
||||||
|
|
||||||
|
|
||||||
|
class UserEmailChangeTest(SoupTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
|
||||||
|
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
|
||||||
|
session = self.client.session
|
||||||
|
session['pretix_auth_login_time'] = int(time.time())
|
||||||
|
session.save()
|
||||||
|
doc = self.get_doc('/control/settings/email/change')
|
||||||
|
self.form_data = extract_form_fields(doc.select('.container-fluid form')[0])
|
||||||
|
|
||||||
|
def test_require_reauth(self):
|
||||||
|
session = self.client.session
|
||||||
|
session['pretix_auth_login_time'] = int(time.time()) - 3600 * 2
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = self.client.get('/control/settings/email/change')
|
||||||
|
self.assertIn('/control/reauth', response['Location'])
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
response = self.client.post('/control/reauth/?next=/control/settings/email/change', {
|
||||||
|
'password': 'barfoofoo'
|
||||||
|
})
|
||||||
|
self.assertIn('/control/settings/email/change', response['Location'])
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def submit_step_1(self, data):
|
||||||
|
form_data = self.form_data.copy()
|
||||||
|
form_data.update(data)
|
||||||
|
return self.post_doc('/control/settings/email/change', form_data)
|
||||||
|
|
||||||
|
def submit_step_2(self, data):
|
||||||
|
form_data = self.form_data.copy()
|
||||||
|
form_data.update(data)
|
||||||
|
return self.post_doc('/control/settings/email/confirm?reason=email_change', form_data)
|
||||||
|
|
||||||
def test_change_email_success(self):
|
def test_change_email_success(self):
|
||||||
doc = self.save({
|
djmail.outbox = []
|
||||||
'email': 'foo@example.com',
|
doc = self.submit_step_1({
|
||||||
'old_pw': 'barfoofoo'
|
'new_email': 'foo@example.com',
|
||||||
|
})
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
assert djmail.outbox[0].to == ['foo@example.com']
|
||||||
|
code = re.search("[0-9]{7}", djmail.outbox[0].body).group(0)
|
||||||
|
doc = self.submit_step_2({
|
||||||
|
'code': code,
|
||||||
})
|
})
|
||||||
assert doc.select(".alert-success")
|
assert doc.select(".alert-success")
|
||||||
self.user = User.objects.get(pk=self.user.pk)
|
self.user = User.objects.get(pk=self.user.pk)
|
||||||
assert self.user.email == 'foo@example.com'
|
assert self.user.email == 'foo@example.com'
|
||||||
|
|
||||||
def test_change_email_no_duplicates(self):
|
def test_change_email_wrong_code(self):
|
||||||
User.objects.create_user('foo@example.com', 'foo')
|
djmail.outbox = []
|
||||||
doc = self.save({
|
doc = self.submit_step_1({
|
||||||
'email': 'foo@example.com',
|
'new_email': 'foo@example.com',
|
||||||
'old_pw': 'barfoofoo'
|
})
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
assert djmail.outbox[0].to == ['foo@example.com']
|
||||||
|
code = re.search("[0-9]{7}", djmail.outbox[0].body).group(0)
|
||||||
|
wrong_code = '0000000' if code == '1234567' else '1234567'
|
||||||
|
doc = self.submit_step_2({
|
||||||
|
'code': wrong_code,
|
||||||
})
|
})
|
||||||
assert doc.select(".alert-danger")
|
assert doc.select(".alert-danger")
|
||||||
self.user = User.objects.get(pk=self.user.pk)
|
self.user = User.objects.get(pk=self.user.pk)
|
||||||
assert self.user.email == 'dummy@dummy.dummy'
|
assert self.user.email == 'dummy@dummy.dummy'
|
||||||
|
|
||||||
|
def test_change_email_no_duplicates(self):
|
||||||
|
User.objects.create_user('foo@example.com', 'foo')
|
||||||
|
doc = self.submit_step_1({
|
||||||
|
'new_email': 'foo@example.com',
|
||||||
|
})
|
||||||
|
assert doc.select(".alert-danger")
|
||||||
|
self.user = User.objects.get(pk=self.user.pk)
|
||||||
|
assert self.user.email == 'dummy@dummy.dummy'
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordChangeTest(SoupTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
|
||||||
|
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
|
||||||
|
doc = self.get_doc('/control/settings/password/change')
|
||||||
|
self.form_data = extract_form_fields(doc.select('.container-fluid form')[0])
|
||||||
|
|
||||||
|
def save(self, data):
|
||||||
|
form_data = self.form_data.copy()
|
||||||
|
form_data.update(data)
|
||||||
|
return self.post_doc('/control/settings/password/change', form_data)
|
||||||
|
|
||||||
def test_change_password_require_password(self):
|
def test_change_password_require_password(self):
|
||||||
doc = self.save({
|
doc = self.save({
|
||||||
'new_pw': 'foo',
|
'new_pw': 'foo',
|
||||||
@@ -193,18 +256,6 @@ class UserSettingsTest(SoupTest):
|
|||||||
})
|
})
|
||||||
assert doc.select(".alert-danger")
|
assert doc.select(".alert-danger")
|
||||||
|
|
||||||
def test_needs_password_change(self):
|
|
||||||
self.user.needs_password_change = True
|
|
||||||
self.user.save()
|
|
||||||
doc = self.save({
|
|
||||||
'email': 'foo@example.com',
|
|
||||||
'old_pw': 'barfoofoo'
|
|
||||||
})
|
|
||||||
assert doc.select(".alert-success")
|
|
||||||
assert doc.select(".alert-warning")
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
assert self.user.needs_password_change is True
|
|
||||||
|
|
||||||
def test_needs_password_change_changed(self):
|
def test_needs_password_change_changed(self):
|
||||||
self.user.needs_password_change = True
|
self.user.needs_password_change = True
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|||||||
Reference in New Issue
Block a user