@@ -115,6 +115,36 @@ def get_help_text(self):
115115 ) % {'min_length' : self .min_length }
116116
117117
118+ def exceeds_maximum_length_ratio (password , max_similarity , value ):
119+ """
120+ Test that value is within a reasonable range of password.
121+
122+ The following ratio calculations are based on testing SequenceMatcher like
123+ this:
124+
125+ for i in range(0,6):
126+ print(10**i, SequenceMatcher(a='A', b='A'*(10**i)).quick_ratio())
127+
128+ which yields:
129+
130+ 1 1.0
131+ 10 0.18181818181818182
132+ 100 0.019801980198019802
133+ 1000 0.001998001998001998
134+ 10000 0.00019998000199980003
135+ 100000 1.999980000199998e-05
136+
137+ This means a length_ratio of 10 should never yield a similarity higher than
138+ 0.2, for 100 this is down to 0.02 and for 1000 it is 0.002. This can be
139+ calculated via 2 / length_ratio. As a result we avoid the potentially
140+ expensive sequence matching.
141+ """
142+ pwd_len = len (password )
143+ length_bound_similarity = max_similarity / 2 * pwd_len
144+ value_len = len (value )
145+ return pwd_len >= 10 * value_len and value_len < length_bound_similarity
146+
147+
118148class UserAttributeSimilarityValidator :
119149 """
120150 Validate whether the password is sufficiently different from the user's
@@ -130,19 +160,25 @@ class UserAttributeSimilarityValidator:
130160
131161 def __init__ (self , user_attributes = DEFAULT_USER_ATTRIBUTES , max_similarity = 0.7 ):
132162 self .user_attributes = user_attributes
163+ if max_similarity < 0.1 :
164+ raise ValueError ('max_similarity must be at least 0.1' )
133165 self .max_similarity = max_similarity
134166
135167 def validate (self , password , user = None ):
136168 if not user :
137169 return
138170
171+ password = password .lower ()
139172 for attribute_name in self .user_attributes :
140173 value = getattr (user , attribute_name , None )
141174 if not value or not isinstance (value , str ):
142175 continue
143- value_parts = re .split (r'\W+' , value ) + [value ]
176+ value_lower = value .lower ()
177+ value_parts = re .split (r'\W+' , value_lower ) + [value_lower ]
144178 for value_part in value_parts :
145- if SequenceMatcher (a = password .lower (), b = value_part .lower ()).quick_ratio () >= self .max_similarity :
179+ if exceeds_maximum_length_ratio (password , self .max_similarity , value_part ):
180+ continue
181+ if SequenceMatcher (a = password , b = value_part ).quick_ratio () >= self .max_similarity :
146182 try :
147183 verbose_name = str (user ._meta .get_field (attribute_name ).verbose_name )
148184 except FieldDoesNotExist :
0 commit comments