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

3 thoughts on “Don’t repeat password validator in Django

  1. Hi Gonzalo,

    This article was so useful, thank you! I’m still new to writing custom password validators, and hit a problem with your code above when performing a sign-up (I’m using django all-auth). Basically, if I leave out the validator in my settings.py file, everything works fine, and new users can sign-up.

    settings.py
    {
    ‘NAME’: ‘app.validators.DontRepeatValidator’,
    ‘OPTIONS’: {‘history’: 3}
    },

    However, when I add it in settings.py, it works fine with users who have already been signed-up. No issue there, as the passwords are saved in CustomUserPasswordHistory. But when I try performing a sign-up as a new user, I’m hit with the following error message:

    TypeError: Field ‘id’ expected a number but got

    I think it has something to do with this line:
    all_history_user_passwords = CustomUserPasswordHistory.objects.filter(username_id=user).order_by(‘id’)

    I think it doesn’t have an existing user object to check against, as the user doesn’t exist till sign-up is completed How can the validator be changed so that it doesn’t check on new user sign-up?

    Many thanks!

    1. It looks like you said. As you’re using allauth you still don’t have a user. I think it’s easy to bypass this situation. You only need to check if there’s a user before.

  2. Hi, Gonzalo!

    Thank you for the excellent tutorial, it is very helpfull!

    Is it possible to handle storing new passwords to the CustomUserPasswordHistory without creating CustomUser, using just the default User model?

    Thanks!

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.