Note: I'm migrating from gonzalo123.com to here. When I finish I'll swap the DNS to here. The "official" blog will be always gonzalo123.com

      Don't repeat password validator in Django

      Django has a set of default password validators by default in django.contrib.auth.password_validator

      AUTH_PASSWORD_VALIDATORS = [
          {
              'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
          },
          {
              'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
          },
          {
              'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
          },
          {
              'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
          },
      ]
      

      Normally those validators aren’t enough (at least for me) but it’s very easy to create customs validators. There’re also several validators that we can use, for example those ones.

      I normally need to avoid users to repeat passwords (for example the last ten ones). To do that we need to create a custom validator. Whit this validator we also need to create a model to store the last passwords (the hash). The idea is to persits the hash of the password each time the user changes the password. As well as is always a good practice not to use the default User model, we’re going to create a CustomUser model

      class CustomUser(AbstractUser):
          def __init__(self, *args, **kwargs):
              super(CustomUser, self).__init__(*args, **kwargs)
              self.original_password = self.password
      
          def save(self, *args, **kwargs):
              super(CustomUser, self).save(*args, **kwargs)
              if self._password_has_been_changed():
                  CustomUserPasswordHistory.remember_password(self)
      
          def _password_has_been_changed(self):
              return self.original_password != self.password
      

      And now we can create our CustomUserPasswordHistory implementing the remember_password method.

      class CustomUserPasswordHistory(models.Model):
          username = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
          old_pass = models.CharField(max_length=128)
          pass_date = models.DateTimeField()
      
          @classmethod
          def remember_password(cls, user):
              cls(username=user, old_pass=user.password, pass_date=localtime()).save()
      

      Now the validator:

      class DontRepeatValidator:
          def __init__(self, history=10):
              self.history = history
      
          def validate(self, password, user=None):
              for last_pass in self._get_last_passwords(user):
                  if check_password(password=password, encoded=last_pass):
                      self._raise_validation_error()
      
          def get_help_text(self):
              return _("You cannot repeat passwords")
      
          def _raise_validation_error(self):
              raise ValidationError(
                  _("This password has been used before."),
                  code='password_has_been_used',
                  params={'history': self.history},
              )
      
          def _get_last_passwords(self, user):
              all_history_user_passwords = CustomUserPasswordHistory.objects.filter(username_id=user).order_by('id')
      
              to_index = all_history_user_passwords.count() - self.history
              to_index = to_index if to_index > 0 else None
              if to_index:
                  [u.delete() for u in all_history_user_passwords[0:to_index]]
      
              return [p.old_pass for p in all_history_user_passwords[to_index:]]
      

      We can see how it works with the unit tests:

      class UserCreationTestCase(TestCase):
          def setUp(self):
              self.user = User.objects.create(username='gonzalo')
      
          def test_persist_password_to_history(self):
              self.user.set_password('pass1')
              self.user.save()
      
              all_history_user_passwords = CustomUserPasswordHistory.objects.filter(username_id=self.user)
              self.assertEqual(1, all_history_user_passwords.count())
      
      
      class DontRepeatValidatorTestCase(TestCase):
          def setUp(self):
              self.user = User.objects.create(username='gonzalo')
              self.validator = DontRepeatValidator()
      
          def test_validator_with_new_pass(self):
              self.validator.validate('pass33', self.user)
              self.assertTrue(True)
      
          def test_validator_with_repeated_pass(self):
              for i in range(0, 11):
                  self.user.set_password(f'pass{i}')
                  self.user.save()
      
              with self.assertRaises(ValidationError):
                  self.validator.validate('pass3', self.user)
      
          def test_keep_only_10_passwords(self):
              for i in range(0, 11):
      
                  self.user.set_password(f'pass{i}')
                  self.user.save()
      
              self.validator.validate('xxxx', self.user)
      
              all_history_user_passwords = CustomUserPasswordHistory.objects.filter(username_id=self.user)
              self.assertEqual(10, all_history_user_passwords.count())
      

      Full source code in my github

      comments powered by Disqus