Coverage for /var/srv/projects/api.amasfac.comuna18.com/tmp/venv/lib/python3.9/site-packages/django/contrib/auth/password_validation.py: 46%

119 statements  

« prev     ^ index     » next       coverage.py v6.4.4, created at 2023-07-17 14:22 -0600

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.lru_cache(maxsize=None) 

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 whether 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: 65 ↛ 67line 65 didn't jump to line 67, because the condition on line 65 was never false

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 whether 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 ngettext( 

111 "This password is too short. It must contain at least " 

112 "%(min_length)d character.", 

113 "This password is too short. It must contain at least " 

114 "%(min_length)d characters.", 

115 self.min_length, 

116 ), 

117 code="password_too_short", 

118 params={"min_length": self.min_length}, 

119 ) 

120 

121 def get_help_text(self): 

122 return ngettext( 

123 "Your password must contain at least %(min_length)d character.", 

124 "Your password must contain at least %(min_length)d characters.", 

125 self.min_length, 

126 ) % {"min_length": self.min_length} 

127 

128 

129def exceeds_maximum_length_ratio(password, max_similarity, value): 

130 """ 

131 Test that value is within a reasonable range of password. 

132 

133 The following ratio calculations are based on testing SequenceMatcher like 

134 this: 

135 

136 for i in range(0,6): 

137 print(10**i, SequenceMatcher(a='A', b='A'*(10**i)).quick_ratio()) 

138 

139 which yields: 

140 

141 1 1.0 

142 10 0.18181818181818182 

143 100 0.019801980198019802 

144 1000 0.001998001998001998 

145 10000 0.00019998000199980003 

146 100000 1.999980000199998e-05 

147 

148 This means a length_ratio of 10 should never yield a similarity higher than 

149 0.2, for 100 this is down to 0.02 and for 1000 it is 0.002. This can be 

150 calculated via 2 / length_ratio. As a result we avoid the potentially 

151 expensive sequence matching. 

152 """ 

153 pwd_len = len(password) 

154 length_bound_similarity = max_similarity / 2 * pwd_len 

155 value_len = len(value) 

156 return pwd_len >= 10 * value_len and value_len < length_bound_similarity 

157 

158 

159class UserAttributeSimilarityValidator: 

160 """ 

161 Validate whether the password is sufficiently different from the user's 

162 attributes. 

163 

164 If no specific attributes are provided, look at a sensible list of 

165 defaults. Attributes that don't exist are ignored. Comparison is made to 

166 not only the full attribute value, but also its components, so that, for 

167 example, a password is validated against either part of an email address, 

168 as well as the full address. 

169 """ 

170 

171 DEFAULT_USER_ATTRIBUTES = ("username", "first_name", "last_name", "email") 

172 

173 def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7): 

174 self.user_attributes = user_attributes 

175 if max_similarity < 0.1: 175 ↛ 176line 175 didn't jump to line 176, because the condition on line 175 was never true

176 raise ValueError("max_similarity must be at least 0.1") 

177 self.max_similarity = max_similarity 

178 

179 def validate(self, password, user=None): 

180 if not user: 

181 return 

182 

183 password = password.lower() 

184 for attribute_name in self.user_attributes: 

185 value = getattr(user, attribute_name, None) 

186 if not value or not isinstance(value, str): 

187 continue 

188 value_lower = value.lower() 

189 value_parts = re.split(r"\W+", value_lower) + [value_lower] 

190 for value_part in value_parts: 

191 if exceeds_maximum_length_ratio( 

192 password, self.max_similarity, value_part 

193 ): 

194 continue 

195 if ( 

196 SequenceMatcher(a=password, b=value_part).quick_ratio() 

197 >= self.max_similarity 

198 ): 

199 try: 

200 verbose_name = str( 

201 user._meta.get_field(attribute_name).verbose_name 

202 ) 

203 except FieldDoesNotExist: 

204 verbose_name = attribute_name 

205 raise ValidationError( 

206 _("The password is too similar to the %(verbose_name)s."), 

207 code="password_too_similar", 

208 params={"verbose_name": verbose_name}, 

209 ) 

210 

211 def get_help_text(self): 

212 return _( 

213 "Your password can’t be too similar to your other personal information." 

214 ) 

215 

216 

217class CommonPasswordValidator: 

218 """ 

219 Validate whether the password is a common password. 

220 

221 The password is rejected if it occurs in a provided list of passwords, 

222 which may be gzipped. The list Django ships with contains 20000 common 

223 passwords (lowercased and deduplicated), created by Royce Williams: 

224 https://gist.github.com/roycewilliams/281ce539915a947a23db17137d91aeb7 

225 The password list must be lowercased to match the comparison in validate(). 

226 """ 

227 

228 @cached_property 

229 def DEFAULT_PASSWORD_LIST_PATH(self): 

230 return Path(__file__).resolve().parent / "common-passwords.txt.gz" 

231 

232 def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH): 

233 if password_list_path is CommonPasswordValidator.DEFAULT_PASSWORD_LIST_PATH: 233 ↛ 235line 233 didn't jump to line 235, because the condition on line 233 was never false

234 password_list_path = self.DEFAULT_PASSWORD_LIST_PATH 

235 try: 

236 with gzip.open(password_list_path, "rt", encoding="utf-8") as f: 

237 self.passwords = {x.strip() for x in f} 

238 except OSError: 

239 with open(password_list_path) as f: 

240 self.passwords = {x.strip() for x in f} 

241 

242 def validate(self, password, user=None): 

243 if password.lower().strip() in self.passwords: 

244 raise ValidationError( 

245 _("This password is too common."), 

246 code="password_too_common", 

247 ) 

248 

249 def get_help_text(self): 

250 return _("Your password can’t be a commonly used password.") 

251 

252 

253class NumericPasswordValidator: 

254 """ 

255 Validate whether the password is alphanumeric. 

256 """ 

257 

258 def validate(self, password, user=None): 

259 if password.isdigit(): 

260 raise ValidationError( 

261 _("This password is entirely numeric."), 

262 code="password_entirely_numeric", 

263 ) 

264 

265 def get_help_text(self): 

266 return _("Your password can’t be entirely numeric.")