Source code for pwned_passwords_django.validators

"""
A Django password validator using the Pwned Passwords API to check for
compromised passwords.

"""

# SPDX-License-Identifier: BSD-3-Clause

import logging
import typing

from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.password_validation import CommonPasswordValidator
from django.core.exceptions import ValidationError
from django.utils.functional import Promise
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from django.views.decorators.debug import sensitive_variables

from . import api, exceptions

logger = logging.getLogger(__name__)

# A value that is either a string, or a lazy Promise object that will resolve to a
# string.
Message = typing.Union[str, Promise]

# A value that is either a Message, or a 2-tuple of Message providing singular and
# plural forms.
PluralMessage = typing.Union[Message, typing.Tuple[Message, Message]]


[docs] class PwnedPasswordsValidator: """ Password validator which checks the Pwned Passwords database. """ default_error_message = _("This password is too common.") def __init__( self, error_message: typing.Optional[PluralMessage] = None, help_message: typing.Optional[Message] = None, api_client: api.PwnedPasswords = api.default_client, ) -> None: self.fallback_validator = CommonPasswordValidator() self.help_message = help_message or self.fallback_validator.get_help_text() error_message = error_message or self.default_error_message self.api_client = api_client # If there is no plural, use the same message for both forms. if isinstance(error_message, (str, Promise)): singular, plural = error_message, error_message else: singular, plural = error_message self.error_message = {"singular": singular, "plural": plural}
[docs] @sensitive_variables() def validate(self, password: str, user: typing.Optional[AbstractBaseUser] = None): """ Check a proposed password against Pwned Passwords and reject the password if it has been compromised. This method is called by most high-level account-creation and account-editing operations in Django. :raises django.core.exceptions.ValidationError: when the proposed password is compromised. """ # pylint: disable=unused-argument try: amount = self.api_client.check_password(password) except exceptions.PwnedPasswordsError: # HIBP API failure. Instead of allowing a potentially compromised # password, check Django's list of common passwords generated from # the same database. logger.error( "Falling back to Django CommonPasswordValidator due " "to error contacting Pwned Passwords." ) self.fallback_validator.validate(password) else: if amount: raise ValidationError( ngettext( self.error_message["singular"], self.error_message["plural"], amount, ), params={"amount": amount}, code="password_compromised", )
def get_help_text(self) -> Message: """ Return help text for this validator. """ return self.help_message def deconstruct(self) -> typing.Tuple[str, tuple, dict]: """ Deconstruct this validator instance for serialization by the Django ORM migration framework. Note that only :attr:`error_message` and :attr:`help_message` are serialized and stored in migrations when this validator is attached to a model field; :attr:`api_client`, if present, will not be serialized or stored in migrations, and so in migrations will always be the default :data:`~pwned_passwords_django.api.client` instance. """ return ( "pwned_passwords_django.validators.PwnedPasswordsValidator", (), {"help_message": self.help_message, "error_message": self.error_message}, ) def __eq__(self, other: object) -> bool: """ Equality check for this validator; this method is required to allow this validator to be serializable by the Django ORM migration framework. Note that only :attr:`error_message` and :attr:`help_message` are considered here; two validator instances which differ only in their :attr:`api_client` will compare equal. """ if not isinstance(other, PwnedPasswordsValidator): return NotImplemented return ( self.error_message == other.error_message and self.help_message == other.help_message )