forms: specialize form for password reset by username (#52013)

This commit is contained in:
Benjamin Dauvergne 2021-03-18 16:04:02 +01:00
parent 5baca2ba03
commit 13cd493740
4 changed files with 94 additions and 43 deletions

View File

@ -133,8 +133,8 @@ default_settings = dict(
A2_PROFILE_DISPLAY_EMPTY_FIELDS=Setting(default=False, definition='Include empty fields in profile view'),
A2_HOMEPAGE_URL=Setting(default=None, definition='IdP has no homepage, redirect to this one.'),
A2_USER_CAN_RESET_PASSWORD=Setting(default=None, definition='Allow online reset of passwords'),
A2_RESET_PASSWORD_ID_LABEL=Setting(
default=None, definition='Alternate ID label for the password reset form'
A2_USER_CAN_RESET_PASSWORD_BY_USERNAME=Setting(
default=False, definition='Allow password reset request by username'
),
A2_EMAIL_IS_UNIQUE=Setting(default=False, definition='Email of users must be unique'),
A2_REGISTRATION_EMAIL_IS_UNIQUE=Setting(

View File

@ -20,13 +20,12 @@ from collections import OrderedDict
from django import forms
from django.contrib.auth import forms as auth_forms
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import Form
from django.utils.translation import ugettext_lazy as _
from .. import app_settings, hooks, models, utils
from .. import app_settings, hooks, models, utils, validators
from ..backends import get_user_queryset
from .fields import CheckPasswordField, NewPasswordField, PasswordField
from .fields import CheckPasswordField, NewPasswordField, PasswordField, ValidatedEmailField
from .utils import NextUrlFormMixin
logger = logging.getLogger(__name__)
@ -35,16 +34,43 @@ logger = logging.getLogger(__name__)
class PasswordResetForm(forms.Form):
next_url = forms.CharField(widget=forms.HiddenInput, required=False)
email = forms.CharField(label=_("Email"), max_length=254)
email = ValidatedEmailField(label=_("Email"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if app_settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME:
del self.fields['email']
self.fields['email_or_username'] = forms.CharField(label=_('Email or username'), max_length=254)
def clean_email(self):
email = self.cleaned_data.get('email')
if email:
self.users = get_user_queryset().filter(email__iexact=email)
return email
def clean_email_or_username(self):
email_or_username = self.cleaned_data.get('email_or_username')
if email_or_username:
self.users = get_user_queryset().filter(
models.Q(username__iexact=email_or_username) | models.Q(email__iexact=email_or_username)
)
try:
validators.email_validator(email_or_username)
except ValidationError:
pass
else:
self.cleaned_data['email'] = email_or_username
return email_or_username
def save(self):
"""
Generates a one-use only link for resetting password and sends to the
user.
"""
email = self.cleaned_data["email"].strip()
users = get_user_queryset().filter(Q(email__iexact=email) | Q(username__iexact=email))
active_users = users.filter(is_active=True)
email = self.cleaned_data.get('email')
email_or_username = self.cleaned_data.get('email_or_username')
active_users = self.users.filter(is_active=True)
for user in active_users:
# we don't set the password to a random string, as some users should not have
# a password
@ -52,14 +78,14 @@ class PasswordResetForm(forms.Form):
utils.send_password_reset_mail(
user, set_random_password=set_random_password, next_url=self.cleaned_data.get('next_url')
)
for user in users.filter(is_active=False):
for user in self.users.filter(is_active=False):
logger.info('password reset failed for user "%r": account is disabled', user)
utils.send_templated_mail(user, ['authentic2/password_reset_refused'])
if not users.exists():
if not self.users.exists() and email:
logger.info(u'password reset request for "%s", no user found', email)
ctx = {'registration_url': utils.make_url('registration_register', absolute=True)}
utils.send_templated_mail(email, ['authentic2/password_reset_no_account'], context=ctx)
hooks.call_hooks('event', name='password-reset', email=email, users=active_users)
hooks.call_hooks('event', name='password-reset', email=email or email_or_username, users=active_users)
class PasswordResetMixin(Form):

View File

@ -653,13 +653,6 @@ class PasswordResetView(FormView):
'registration/password_reset_form.html',
]
def get_form(self, **kwargs):
"""Return an instance of the form to be used in this view."""
form = super(PasswordResetView, self).get_form(**kwargs)
if app_settings.A2_RESET_PASSWORD_ID_LABEL:
form.fields['email'].label = app_settings.A2_RESET_PASSWORD_ID_LABEL
return form
def get_form_kwargs(self, **kwargs):
kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs)
initial = kwargs.setdefault('initial', {})
@ -674,7 +667,7 @@ class PasswordResetView(FormView):
return ctx
def form_valid(self, form):
email = form.cleaned_data['email']
email = form.cleaned_data.get('email') or form.cleaned_data.get('email_or_username')
# if an email has already been sent, warn once before allowing resend
token = models.Token.objects.filter(

View File

@ -42,7 +42,7 @@ def test_send_password_reset_email(app, simple_user, mailoutbox):
utils.assert_event('user.password.reset', user=simple_user, session=app.session)
def test_view_with_email(app, simple_user, mailoutbox, settings):
def test_reset_by_email(app, simple_user, mailoutbox, settings):
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('email', simple_user.email)
@ -69,19 +69,20 @@ def test_view_with_email(app, simple_user, mailoutbox, settings):
app.get(url, status=404)
def test_view_with_username(app, simple_user, mailoutbox, settings):
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('email', simple_user.username)
assert len(mailoutbox) == 0
settings.DEFAULT_FROM_EMAIL = 'show only addr <noreply@example.net>'
resp = resp.form.submit()
utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
assert resp['Location'].endswith('/instructions/')
resp = resp.follow()
assert '"noreply@example.net"' in resp.text
assert 'show only addr' not in resp.text
def test_can_reset_by_username(app, db, simple_user, settings, mailoutbox):
resp = app.get('/accounts/password/reset/')
assert 'email_or_username' not in resp.form.fields
settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME = True
resp = app.get('/accounts/password/reset/')
assert 'email_or_username' in resp.form.fields
resp.form.set('email_or_username', simple_user.username)
resp = resp.form.submit().follow()
assert 'an email has been sent to %s' % simple_user.username in resp
assert len(mailoutbox) == 1
assert mailoutbox[0].to == [simple_user.email]
url = utils.get_link_from_mail(mailoutbox[0])
relative_url = url.split('testserver')[1]
resp = app.get(relative_url, status=200)
@ -91,20 +92,44 @@ def test_view_with_username(app, simple_user, mailoutbox, settings):
# verify user is logged
assert str(app.session['_auth_user_id']) == str(simple_user.pk)
with override_settings(A2_USER_CAN_RESET_PASSWORD=False):
url = reverse('password_reset')
app.get(url, status=404)
def test_can_reset_by_username_with_email(app, db, simple_user, settings, mailoutbox):
settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME = True
resp = app.get('/accounts/password/reset/')
resp.form.set('email_or_username', simple_user.email)
resp = resp.form.submit().follow()
assert 'an email has been sent to %s' % simple_user.username in resp
assert len(mailoutbox) == 1
def test_user_filter(app, simple_user, mailoutbox, settings):
settings.A2_USER_FILTER = {'username': 'xxx'} # will not match simple_user
def test_reset_by_email_no_account(app, db, mailoutbox):
resp = app.get('/accounts/password/reset/')
resp.form.set('email', 'john.doe@example.com')
resp = resp.form.submit().follow()
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('email', simple_user.email)
assert 'an email has been sent to john.doe@example.com' in resp
assert len(mailoutbox) == 1
assert 'no account was found' in mailoutbox[0].body
def test_can_reset_by_username_no_account(app, db, settings, mailoutbox):
settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME = True
resp = app.get('/accounts/password/reset/')
resp.form.set('email_or_username', 'john.doe')
resp = resp.form.submit().follow()
assert 'an email has been sent to john.doe' in resp
assert len(mailoutbox) == 0
resp = resp.form.submit()
assert 'no account was found associated with this address' in mailoutbox[0].body
def test_can_reset_by_username_no_account_email(app, db, settings, mailoutbox):
settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME = True
resp = app.get('/accounts/password/reset/')
resp.form.set('email_or_username', 'john.doe@example.com')
resp = resp.form.submit().follow()
assert 'an email has been sent to john.doe' in resp
assert len(mailoutbox) == 1
def test_user_exclude(app, simple_user, mailoutbox, settings):
@ -150,3 +175,10 @@ def test_send_password_reset_email_disabled_account(app, simple_user, mailoutbox
mail = mailoutbox[0]
assert mail.subject == 'Your account on testserver is disabled'
assert 'your account has been disabled on this server' in mail.body
def test_email_validation(app, db):
resp = app.get('/accounts/password/reset/')
resp.form.set('email', 'coin@')
resp = resp.form.submit()
assert 'Enter a valid email address.' in resp