Skip to content

Commit 58bc7c9

Browse files
fix: improve pw validation without js (#9113)
* fix: backend validators for PasswordForm Only used for password reset * refactor: share more code * fix: reconcile with PersonPasswordForm This form class hierarchy reallllly needs to be refactored more deeply * fix: attach pw errors to specific fields * fix: return val from clean_password_confirmation * refactor: share PasswordForm code * test: update test_change_password * test: update test_create_existing_account * test: update test_reset_password
1 parent 99775c4 commit 58bc7c9

File tree

3 files changed

+99
-60
lines changed

3 files changed

+99
-60
lines changed

ietf/ietfauth/forms.py

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,56 @@ def clean_email(self):
3333
return email
3434

3535

36+
class PasswordStrengthField(forms.CharField):
37+
widget = PasswordStrengthInput(
38+
attrs={
39+
"class": "password_strength",
40+
"data-disable-strength-enforcement": "", # usually removed in init
41+
}
42+
)
43+
44+
def __init__(self, *args, **kwargs):
45+
super().__init__(*args, **kwargs)
46+
for pwval in password_validation.get_default_password_validators():
47+
if isinstance(pwval, password_validation.MinimumLengthValidator):
48+
self.widget.attrs["minlength"] = pwval.min_length
49+
elif isinstance(pwval, StrongPasswordValidator):
50+
self.widget.attrs.pop(
51+
"data-disable-strength-enforcement", None
52+
)
53+
54+
55+
3656
class PasswordForm(forms.Form):
37-
password = forms.CharField(widget=PasswordStrengthInput(attrs={'class':'password_strength'}))
57+
password = PasswordStrengthField()
3858
password_confirmation = forms.CharField(widget=PasswordConfirmationInput(
3959
confirm_with='password',
4060
attrs={'class':'password_confirmation'}),
4161
help_text="Enter the same password as above, for verification.",)
42-
62+
63+
def __init__(self, *args, user=None, **kwargs):
64+
# user is a kw-only argument to avoid interfering with the signature
65+
# when this class is mixed with ModelForm in PersonPasswordForm
66+
self.user = user
67+
super().__init__(*args, **kwargs)
4368

4469
def clean_password_confirmation(self):
45-
password = self.cleaned_data.get("password", "")
46-
password_confirmation = self.cleaned_data["password_confirmation"]
70+
# clean fields here rather than a clean() method so validation is
71+
# still enforced in PersonPasswordForm without having to override its
72+
# clean() method
73+
password = self.cleaned_data.get("password")
74+
password_confirmation = self.cleaned_data.get("password_confirmation")
4775
if password != password_confirmation:
48-
raise forms.ValidationError("The two password fields didn't match.")
76+
raise ValidationError(
77+
"The password confirmation is different than the new password"
78+
)
79+
try:
80+
password_validation.validate_password(password_confirmation, self.user)
81+
except ValidationError as err:
82+
self.add_error("password", err)
4983
return password_confirmation
5084

85+
5186
def ascii_cleaner(supposedly_ascii):
5287
outside_printable_ascii_pattern = r'[^\x20-\x7F]'
5388
if re.search(outside_printable_ascii_pattern, supposedly_ascii):
@@ -174,35 +209,13 @@ class Meta:
174209
exclude = ['by', 'time' ]
175210

176211

177-
class ChangePasswordForm(forms.Form):
212+
class ChangePasswordForm(PasswordForm):
178213
current_password = forms.CharField(widget=forms.PasswordInput)
214+
field_order = ["current_password", "password", "password_confirmation"]
179215

180-
new_password = forms.CharField(
181-
widget=PasswordStrengthInput(
182-
attrs={
183-
"class": "password_strength",
184-
"data-disable-strength-enforcement": "", # usually removed in init
185-
}
186-
),
187-
)
188-
new_password_confirmation = forms.CharField(
189-
widget=PasswordConfirmationInput(
190-
confirm_with="new_password", attrs={"class": "password_confirmation"}
191-
)
192-
)
193-
194-
def __init__(self, user, data=None):
195-
self.user = user
196-
super().__init__(data)
197-
# Check whether we have validators to enforce
198-
new_password_field = self.fields["new_password"]
199-
for pwval in password_validation.get_default_password_validators():
200-
if isinstance(pwval, password_validation.MinimumLengthValidator):
201-
new_password_field.widget.attrs["minlength"] = pwval.min_length
202-
elif isinstance(pwval, StrongPasswordValidator):
203-
new_password_field.widget.attrs.pop(
204-
"data-disable-strength-enforcement", None
205-
)
216+
def __init__(self, user, *args, **kwargs):
217+
# user arg is optional in superclass, but required for this form
218+
super().__init__(*args, user=user, **kwargs)
206219

207220
def clean_current_password(self):
208221
# n.b., password = None is handled by check_password and results in a failed check
@@ -211,15 +224,6 @@ def clean_current_password(self):
211224
raise ValidationError("Invalid password")
212225
return password
213226

214-
def clean(self):
215-
new_password = self.cleaned_data.get("new_password", "")
216-
conf_password = self.cleaned_data.get("new_password_confirmation", "")
217-
if new_password != conf_password:
218-
raise ValidationError(
219-
"The password confirmation is different than the new password"
220-
)
221-
password_validation.validate_password(conf_password, self.user)
222-
223227

224228
class ChangeUsernameForm(forms.Form):
225229
username = forms.ChoiceField(choices=[('-','--------')])

ietf/ietfauth/tests.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -168,18 +168,40 @@ def register_and_verify(self, email):
168168
self.assertEqual(r.status_code, 200)
169169

170170
# password mismatch
171-
r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'nosecret' })
171+
r = self.client.post(
172+
confirm_url, {
173+
"password": "secret-and-secure",
174+
"password_confirmation": "not-secret-or-secure",
175+
}
176+
)
177+
self.assertEqual(r.status_code, 200)
178+
self.assertEqual(User.objects.filter(username=email).count(), 0)
179+
180+
# weak password
181+
r = self.client.post(
182+
confirm_url, {
183+
"password": "password1234",
184+
"password_confirmation": "password1234",
185+
}
186+
)
172187
self.assertEqual(r.status_code, 200)
173188
self.assertEqual(User.objects.filter(username=email).count(), 0)
174189

175190
# confirm
176-
r = self.client.post(confirm_url, { 'name': 'User Name', 'ascii': 'User Name', 'password': 'secret', 'password_confirmation': 'secret' })
191+
r = self.client.post(
192+
confirm_url,
193+
{
194+
"name": "User Name",
195+
"ascii": "User Name",
196+
"password": "secret-and-secure",
197+
"password_confirmation": "secret-and-secure",
198+
},
199+
)
177200
self.assertEqual(r.status_code, 200)
178201
self.assertEqual(User.objects.filter(username=email).count(), 1)
179202
self.assertEqual(Person.objects.filter(user__username=email).count(), 1)
180203
self.assertEqual(Email.objects.filter(person__user__username=email).count(), 1)
181204

182-
183205
# This also tests new account creation.
184206
def test_create_existing_account(self):
185207
# create account once
@@ -393,6 +415,7 @@ def test_nomcom_dressing_on_profile(self):
393415
self.assertTrue(q('#volunteered'))
394416

395417
def test_reset_password(self):
418+
WEAK_PASSWORD="password1234"
396419
VALID_PASSWORD = "complex-and-long-valid-password"
397420
ANOTHER_VALID_PASSWORD = "very-complicated-and-lengthy-password"
398421
url = urlreverse("ietf.ietfauth.views.password_reset")
@@ -450,6 +473,18 @@ def test_reset_password(self):
450473
q = PyQuery(r.content)
451474
self.assertTrue(len(q("form .is-invalid")) > 0)
452475

476+
# weak password
477+
r = self.client.post(
478+
confirm_url,
479+
{
480+
"password": WEAK_PASSWORD,
481+
"password_confirmation": WEAK_PASSWORD,
482+
},
483+
)
484+
self.assertEqual(r.status_code, 200)
485+
q = PyQuery(r.content)
486+
self.assertTrue(len(q("form .is-invalid")) > 0)
487+
453488
# confirm
454489
r = self.client.post(
455490
confirm_url,
@@ -636,8 +671,8 @@ def test_change_password(self):
636671
chpw_url,
637672
{
638673
"current_password": "fiddlesticks",
639-
"new_password": ANOTHER_VALID_PASSWORD,
640-
"new_password_confirmation": ANOTHER_VALID_PASSWORD,
674+
"password": ANOTHER_VALID_PASSWORD,
675+
"password_confirmation": ANOTHER_VALID_PASSWORD,
641676
},
642677
)
643678
self.assertEqual(r.status_code, 200)
@@ -648,14 +683,14 @@ def test_change_password(self):
648683
chpw_url,
649684
{
650685
"current_password": VALID_PASSWORD,
651-
"new_password": ANOTHER_VALID_PASSWORD,
652-
"new_password_confirmation": ANOTHER_VALID_PASSWORD[::-1],
686+
"password": ANOTHER_VALID_PASSWORD,
687+
"password_confirmation": ANOTHER_VALID_PASSWORD[::-1],
653688
},
654689
)
655690
self.assertEqual(r.status_code, 200)
656691
self.assertFormError(
657692
r.context["form"],
658-
None,
693+
"password_confirmation",
659694
"The password confirmation is different than the new password",
660695
)
661696

@@ -664,14 +699,14 @@ def test_change_password(self):
664699
chpw_url,
665700
{
666701
"current_password": VALID_PASSWORD,
667-
"new_password": "sh0rtpw0rd",
668-
"new_password_confirmation": "sh0rtpw0rd",
702+
"password": "sh0rtpw0rd",
703+
"password_confirmation": "sh0rtpw0rd",
669704
}
670705
)
671706
self.assertEqual(r.status_code, 200)
672707
self.assertFormError(
673708
r.context["form"],
674-
None,
709+
"password",
675710
"This password is too short. It must contain at least "
676711
f"{settings.PASSWORD_POLICY_MIN_LENGTH} characters."
677712
)
@@ -681,14 +716,14 @@ def test_change_password(self):
681716
chpw_url,
682717
{
683718
"current_password": VALID_PASSWORD,
684-
"new_password": "passwordpassword",
685-
"new_password_confirmation": "passwordpassword",
719+
"password": "passwordpassword",
720+
"password_confirmation": "passwordpassword",
686721
}
687722
)
688723
self.assertEqual(r.status_code, 200)
689724
self.assertFormError(
690725
r.context["form"],
691-
None,
726+
"password",
692727
"This password does not meet complexity requirements "
693728
"and is easily guessable."
694729
)
@@ -698,8 +733,8 @@ def test_change_password(self):
698733
chpw_url,
699734
{
700735
"current_password": VALID_PASSWORD,
701-
"new_password": ANOTHER_VALID_PASSWORD,
702-
"new_password_confirmation": ANOTHER_VALID_PASSWORD,
736+
"password": ANOTHER_VALID_PASSWORD,
737+
"password_confirmation": ANOTHER_VALID_PASSWORD,
703738
},
704739
)
705740
self.assertRedirects(r, prof_url)

ietf/ietfauth/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ def confirm_password_reset(request, auth):
529529
)
530530
success = False
531531
if request.method == 'POST':
532-
form = PasswordForm(request.POST)
532+
form = PasswordForm(user=user, data=request.POST)
533533
if form.is_valid():
534534
password = form.cleaned_data["password"]
535535

@@ -538,7 +538,7 @@ def confirm_password_reset(request, auth):
538538

539539
success = True
540540
else:
541-
form = PasswordForm()
541+
form = PasswordForm(user=user)
542542

543543
hlibname, hashername = settings.PASSWORD_HASHERS[0].rsplit('.',1)
544544
hlib = importlib.import_module(hlibname)
@@ -669,7 +669,7 @@ def change_password(request):
669669
if request.method == 'POST':
670670
form = ChangePasswordForm(user, request.POST)
671671
if form.is_valid():
672-
new_password = form.cleaned_data["new_password"]
672+
new_password = form.cleaned_data["password"]
673673

674674
user.set_password(new_password)
675675
user.save()

0 commit comments

Comments
 (0)