Skip to content

Commit 9e4660e

Browse files
feat: add ability to require strong passwords (ietf-tools#8828)
* feat: enforce pw strength at login * chore(deps): add zxcvbn to requirements.txt * feat: use zxcvbn for password strength check * feat: validate password strength for setting pw * feat: feedback on password change page * refactor: avoid field validator munging * feat: give more info about how to choose a pw * refactor: use password_validation module Only for changing password so; may need to update StrongPasswordAuthenticationForm to match * feat: password min_length validdation * refactor: use password_validation for login form * fix: UI feedback consistent with validation * chore: update chpw page to state length req * chore(dev): disable password validators in dev * fix: drop JS validation when password val disabled * style: ruff on ChangePasswordForm * chore: lint * chore(dev): preserve pw validator cfg for tests * test: fix test_change_password * test: fix test_change_username * test: fix test_reset_password * style: ruff refactored tests * chore: type lint * feat: require pw reset for very stale accounts * test: test stale account login * test: rejection of short/simple PWs * Revert "test: test stale account login" This reverts commit ae0d90b. * Revert "feat: require pw reset for very stale accounts" This reverts commit 1a98ef4. * test: disable pw validators for playwright legacy tests * feat: make pw enforcement at login optional
1 parent ecc4786 commit 9e4660e

11 files changed

Lines changed: 321 additions & 105 deletions

File tree

ietf/ietfauth/forms.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33

44

55
import re
6+
67
from unidecode import unidecode
78

89
from django import forms
10+
from django.contrib.auth.models import User
11+
from django.contrib.auth import password_validation
912
from django.core.exceptions import ValidationError
1013
from django.db import models
11-
from django.contrib.auth.models import User
1214

1315
from ietf.person.models import Person, Email
1416
from ietf.mailinglists.models import Allowlisted
1517
from ietf.utils.text import isascii
18+
from .password_validation import StrongPasswordValidator
1619

1720
from .validators import prevent_at_symbol, prevent_system_name, prevent_anonymous_name, is_allowed_address
1821
from .widgets import PasswordStrengthInput, PasswordConfirmationInput
@@ -170,33 +173,52 @@ class Meta:
170173
model = Allowlisted
171174
exclude = ['by', 'time' ]
172175

173-
174-
from django import forms
175-
176176

177177
class ChangePasswordForm(forms.Form):
178178
current_password = forms.CharField(widget=forms.PasswordInput)
179179

180-
new_password = forms.CharField(widget=PasswordStrengthInput(attrs={'class':'password_strength'}))
181-
new_password_confirmation = forms.CharField(widget=PasswordConfirmationInput(
182-
confirm_with='new_password',
183-
attrs={'class':'password_confirmation'}))
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+
)
184193

185194
def __init__(self, user, data=None):
186195
self.user = user
187-
super(ChangePasswordForm, self).__init__(data)
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+
)
188206

189207
def clean_current_password(self):
190-
password = self.cleaned_data.get('current_password', None)
208+
# n.b., password = None is handled by check_password and results in a failed check
209+
password = self.cleaned_data.get("current_password", None)
191210
if not self.user.check_password(password):
192-
raise ValidationError('Invalid password')
211+
raise ValidationError("Invalid password")
193212
return password
194-
213+
195214
def clean(self):
196-
new_password = self.cleaned_data.get('new_password', None)
197-
conf_password = self.cleaned_data.get('new_password_confirmation', None)
198-
if not new_password == conf_password:
199-
raise ValidationError("The password confirmation is different than the new password")
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)
200222

201223

202224
class ChangeUsernameForm(forms.Form):
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright The IETF Trust 2025, All Rights Reserved
2+
from django.core.exceptions import ValidationError
3+
from zxcvbn import zxcvbn
4+
5+
6+
class StrongPasswordValidator:
7+
message = "This password does not meet complexity requirements and is easily guessable."
8+
code = "weak"
9+
min_zxcvbn_score = 3
10+
11+
def __init__(self, message=None, code=None, min_zxcvbn_score=None):
12+
if message is not None:
13+
self.message = message
14+
if code is not None:
15+
self.code = code
16+
if min_zxcvbn_score is not None:
17+
self.min_zxcvbn_score = min_zxcvbn_score
18+
19+
def validate(self, password, user=None):
20+
"""Validate that a password is strong enough"""
21+
strength_report = zxcvbn(password[:72], max_length=72)
22+
if strength_report["score"] < self.min_zxcvbn_score:
23+
raise ValidationError(message=self.message, code=self.code)

0 commit comments

Comments
 (0)