Ticket #37202: password_validation.py

File password_validation.py, 9.4 KB (added by meng, 27 hours ago)
Line 
1import functools
2import gzip
3import re
4from difflib import SequenceMatcher
5from pathlib import Path
6
7from django.conf import settings
8from django.core.exceptions import (
9 FieldDoesNotExist,
10 ImproperlyConfigured,
11 ValidationError,
12)
13from django.utils.functional import cached_property, lazy
14from django.utils.html import format_html, format_html_join
15from django.utils.module_loading import import_string
16from django.utils.translation import gettext as _
17from django.utils.translation import ngettext
18
19
20@functools.cache
21def get_default_password_validators():
22 return get_password_validators(settings.AUTH_PASSWORD_VALIDATORS)
23
24
25def get_password_validators(validator_config):
26 validators = []
27 for validator in validator_config:
28 try:
29 klass = import_string(validator["NAME"])
30 except ImportError:
31 msg = (
32 "The module in NAME could not be imported: %s. Check your "
33 "AUTH_PASSWORD_VALIDATORS setting."
34 )
35 raise ImproperlyConfigured(msg % validator["NAME"])
36 validators.append(klass(**validator.get("OPTIONS", {})))
37
38 return validators
39
40
41def validate_password(password, user=None, password_validators=None):
42 """
43 Validate that the password meets all validator requirements.
44
45 If the password is valid, return ``None``.
46 If the password is invalid, raise ValidationError with all error messages.
47 """
48 errors = []
49 if password_validators is None:
50 password_validators = get_default_password_validators()
51 for validator in password_validators:
52 try:
53 validator.validate(password, user)
54 except ValidationError as error:
55 errors.append(error)
56 if errors:
57 raise ValidationError(errors)
58
59
60def password_changed(password, user=None, password_validators=None):
61 """
62 Inform all validators that have implemented a password_changed() method
63 that the password has been changed.
64 """
65 if password_validators is None:
66 password_validators = get_default_password_validators()
67 for validator in password_validators:
68 password_changed = getattr(validator, "password_changed", lambda *a: None)
69 password_changed(password, user)
70
71
72def password_validators_help_texts(password_validators=None):
73 """
74 Return a list of all help texts of all configured validators.
75 """
76 help_texts = []
77 if password_validators is None:
78 password_validators = get_default_password_validators()
79 for validator in password_validators:
80 help_texts.append(validator.get_help_text())
81 return help_texts
82
83
84def _password_validators_help_text_html(password_validators=None):
85 """
86 Return an HTML string with all help texts of all configured validators
87 in an <ul>.
88 """
89 help_texts = password_validators_help_texts(password_validators)
90 help_items = format_html_join(
91 "", "<li>{}</li>", ((help_text,) for help_text in help_texts)
92 )
93 return format_html("<ul>{}</ul>", help_items) if help_items else ""
94
95
96password_validators_help_text_html = lazy(_password_validators_help_text_html, str)
97
98
99class MinimumLengthValidator:
100 """
101 Validate that the password is of a minimum length.
102 """
103
104 def __init__(self, min_length=8):
105 self.min_length = min_length
106
107 def validate(self, password, user=None):
108 if len(password) < self.min_length:
109 raise ValidationError(
110 self.get_error_message(),
111 code="password_too_short",
112 params={"min_length": self.min_length},
113 )
114
115 def get_error_message(self):
116 return (
117 ngettext(
118 "This password is too short. It must contain at least %(min_length)d character.",
119 "This password is too short. It must contain at least %(min_length)d characters.",
120 self.min_length,
121 )
122 % {"min_length": self.min_length}
123 )
124
125 def get_help_text(self):
126 return ngettext(
127 "Your password must contain at least %(min_length)d character.",
128 "Your password must contain at least %(min_length)d characters.",
129 self.min_length,
130 ) % {"min_length": self.min_length}
131
132
133def exceeds_maximum_length_ratio(password, max_similarity, value):
134 """
135 Test that value is within a reasonable range of password.
136
137 The following ratio calculations are based on testing SequenceMatcher like
138 this:
139
140 for i in range(0,6):
141 print(10**i, SequenceMatcher(a='A', b='A'*(10**i)).quick_ratio())
142
143 which yields:
144
145 1 1.0
146 10 0.18181818181818182
147 100 0.019801980198019802
148 1000 0.001998001998001998
149 10000 0.00019998000199980003
150 100000 1.999980000199998e-05
151
152 This means a length_ratio of 10 should never yield a similarity higher than
153 0.2, for 100 this is down to 0.02 and for 1000 it is 0.002. This can be
154 calculated via 2 / length_ratio. As a result we avoid the potentially
155 expensive sequence matching.
156 """
157 pwd_len = len(password)
158 length_bound_similarity = max_similarity / 2 * pwd_len
159 value_len = len(value)
160 return pwd_len >= 10 * value_len and value_len < length_bound_similarity
161
162
163class UserAttributeSimilarityValidator:
164 """
165 Validate that the password is sufficiently different from the user's
166 attributes.
167
168 If no specific attributes are provided, look at a sensible list of
169 defaults. Attributes that don't exist are ignored. Comparison is made to
170 not only the full attribute value, but also its components, so that, for
171 example, a password is validated against either part of an email address,
172 as well as the full address.
173 """
174
175 DEFAULT_USER_ATTRIBUTES = ("username", "first_name", "last_name", "email")
176
177 def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
178 self.user_attributes = user_attributes
179 if max_similarity < 0.1:
180 raise ValueError("max_similarity must be at least 0.1")
181 self.max_similarity = max_similarity
182
183 def validate(self, password, user=None):
184 if not user:
185 return
186
187 password = password.lower()
188 for attribute_name in self.user_attributes:
189 value = getattr(user, attribute_name, None)
190 if not value or not isinstance(value, str):
191 continue
192 value_lower = value.lower()
193 value_parts = re.split(r"\W+", value_lower) + [value_lower]
194 for value_part in value_parts:
195 if exceeds_maximum_length_ratio(
196 password, self.max_similarity, value_part
197 ):
198 continue
199 if (
200 SequenceMatcher(a=password, b=value_part).quick_ratio()
201 >= self.max_similarity
202 ):
203 try:
204 verbose_name = str(
205 user._meta.get_field(attribute_name).verbose_name
206 )
207 except FieldDoesNotExist:
208 verbose_name = attribute_name
209 raise ValidationError(
210 self.get_error_message(),
211 code="password_too_similar",
212 params={"verbose_name": verbose_name},
213 )
214
215 def get_error_message(self):
216 return _("The password is too similar to the %(verbose_name)s.")
217
218 def get_help_text(self):
219 return _(
220 "Your password can’t be too similar to your other personal information."
221 )
222
223
224class CommonPasswordValidator:
225 """
226 Validate that the password is not a common password.
227
228 The password is rejected if it occurs in a provided list of passwords,
229 which may be gzipped. The list Django ships with contains 20000 common
230 passwords (unhexed, lowercased and deduplicated), created by Royce Williams:
231 https://gist.github.com/roycewilliams/226886fd01572964e1431ac8afc999ce
232 The password list must be lowercased to match the comparison in validate().
233 """
234
235 @cached_property
236 def DEFAULT_PASSWORD_LIST_PATH(self):
237 return Path(__file__).resolve().parent / "common-passwords.txt.gz"
238
239 def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
240 if password_list_path is CommonPasswordValidator.DEFAULT_PASSWORD_LIST_PATH:
241 password_list_path = self.DEFAULT_PASSWORD_LIST_PATH
242 try:
243 with gzip.open(password_list_path, "rt", encoding="utf-8") as f:
244 self.passwords = {x.strip() for x in f}
245 except OSError:
246 with open(password_list_path) as f:
247 self.passwords = {x.strip() for x in f}
248
249 def validate(self, password, user=None):
250 if password.lower().strip() in self.passwords:
251 raise ValidationError(
252 self.get_error_message(),
253 code="password_too_common",
254 )
255
256 def get_error_message(self):
257 return _("This password is too common.")
258
259 def get_help_text(self):
260 return _("Your password can’t be a commonly used password.")
261
262
263class NumericPasswordValidator:
264 """
265 Validate that the password is not entirely numeric.
266 """
267
268 def validate(self, password, user=None):
269 if password.isdigit():
270 raise ValidationError(
271 self.get_error_message(),
272 code="password_entirely_numeric",
273 )
274
275 def get_error_message(self):
276 return _("This password is entirely numeric.")
277
278 def get_help_text(self):
279 return _("Your password can’t be entirely numeric.")
Back to Top