Development of an internal social media platform with personalised dashboards for students
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

password_validation.py 7.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import functools
  2. import gzip
  3. import re
  4. from difflib import SequenceMatcher
  5. from pathlib import Path
  6. from django.conf import settings
  7. from django.core.exceptions import (
  8. FieldDoesNotExist, ImproperlyConfigured, ValidationError,
  9. )
  10. from django.utils.functional import lazy
  11. from django.utils.html import format_html, format_html_join
  12. from django.utils.module_loading import import_string
  13. from django.utils.translation import gettext as _, ngettext
  14. @functools.lru_cache(maxsize=None)
  15. def get_default_password_validators():
  16. return get_password_validators(settings.AUTH_PASSWORD_VALIDATORS)
  17. def get_password_validators(validator_config):
  18. validators = []
  19. for validator in validator_config:
  20. try:
  21. klass = import_string(validator['NAME'])
  22. except ImportError:
  23. msg = "The module in NAME could not be imported: %s. Check your AUTH_PASSWORD_VALIDATORS setting."
  24. raise ImproperlyConfigured(msg % validator['NAME'])
  25. validators.append(klass(**validator.get('OPTIONS', {})))
  26. return validators
  27. def validate_password(password, user=None, password_validators=None):
  28. """
  29. Validate whether the password meets all validator requirements.
  30. If the password is valid, return ``None``.
  31. If the password is invalid, raise ValidationError with all error messages.
  32. """
  33. errors = []
  34. if password_validators is None:
  35. password_validators = get_default_password_validators()
  36. for validator in password_validators:
  37. try:
  38. validator.validate(password, user)
  39. except ValidationError as error:
  40. errors.append(error)
  41. if errors:
  42. raise ValidationError(errors)
  43. def password_changed(password, user=None, password_validators=None):
  44. """
  45. Inform all validators that have implemented a password_changed() method
  46. that the password has been changed.
  47. """
  48. if password_validators is None:
  49. password_validators = get_default_password_validators()
  50. for validator in password_validators:
  51. password_changed = getattr(validator, 'password_changed', lambda *a: None)
  52. password_changed(password, user)
  53. def password_validators_help_texts(password_validators=None):
  54. """
  55. Return a list of all help texts of all configured validators.
  56. """
  57. help_texts = []
  58. if password_validators is None:
  59. password_validators = get_default_password_validators()
  60. for validator in password_validators:
  61. help_texts.append(validator.get_help_text())
  62. return help_texts
  63. def _password_validators_help_text_html(password_validators=None):
  64. """
  65. Return an HTML string with all help texts of all configured validators
  66. in an <ul>.
  67. """
  68. help_texts = password_validators_help_texts(password_validators)
  69. help_items = format_html_join('', '<li>{}</li>', ((help_text,) for help_text in help_texts))
  70. return format_html('<ul>{}</ul>', help_items) if help_items else ''
  71. password_validators_help_text_html = lazy(_password_validators_help_text_html, str)
  72. class MinimumLengthValidator:
  73. """
  74. Validate whether the password is of a minimum length.
  75. """
  76. def __init__(self, min_length=8):
  77. self.min_length = min_length
  78. def validate(self, password, user=None):
  79. if len(password) < self.min_length:
  80. raise ValidationError(
  81. ngettext(
  82. "This password is too short. It must contain at least %(min_length)d character.",
  83. "This password is too short. It must contain at least %(min_length)d characters.",
  84. self.min_length
  85. ),
  86. code='password_too_short',
  87. params={'min_length': self.min_length},
  88. )
  89. def get_help_text(self):
  90. return ngettext(
  91. "Your password must contain at least %(min_length)d character.",
  92. "Your password must contain at least %(min_length)d characters.",
  93. self.min_length
  94. ) % {'min_length': self.min_length}
  95. class UserAttributeSimilarityValidator:
  96. """
  97. Validate whether the password is sufficiently different from the user's
  98. attributes.
  99. If no specific attributes are provided, look at a sensible list of
  100. defaults. Attributes that don't exist are ignored. Comparison is made to
  101. not only the full attribute value, but also its components, so that, for
  102. example, a password is validated against either part of an email address,
  103. as well as the full address.
  104. """
  105. DEFAULT_USER_ATTRIBUTES = ('username', 'first_name', 'last_name', 'email')
  106. def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
  107. self.user_attributes = user_attributes
  108. self.max_similarity = max_similarity
  109. def validate(self, password, user=None):
  110. if not user:
  111. return
  112. for attribute_name in self.user_attributes:
  113. value = getattr(user, attribute_name, None)
  114. if not value or not isinstance(value, str):
  115. continue
  116. value_parts = re.split(r'\W+', value) + [value]
  117. for value_part in value_parts:
  118. if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() >= self.max_similarity:
  119. try:
  120. verbose_name = str(user._meta.get_field(attribute_name).verbose_name)
  121. except FieldDoesNotExist:
  122. verbose_name = attribute_name
  123. raise ValidationError(
  124. _("The password is too similar to the %(verbose_name)s."),
  125. code='password_too_similar',
  126. params={'verbose_name': verbose_name},
  127. )
  128. def get_help_text(self):
  129. return _("Your password can't be too similar to your other personal information.")
  130. class CommonPasswordValidator:
  131. """
  132. Validate whether the password is a common password.
  133. The password is rejected if it occurs in a provided list, which may be gzipped.
  134. The list Django ships with contains 20000 common passwords, created by
  135. Royce Williams: https://gist.github.com/roycewilliams/281ce539915a947a23db17137d91aeb7
  136. """
  137. DEFAULT_PASSWORD_LIST_PATH = Path(__file__).resolve().parent / 'common-passwords.txt.gz'
  138. def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
  139. try:
  140. with gzip.open(str(password_list_path)) as f:
  141. common_passwords_lines = f.read().decode().splitlines()
  142. except IOError:
  143. with open(str(password_list_path)) as f:
  144. common_passwords_lines = f.readlines()
  145. self.passwords = {p.strip() for p in common_passwords_lines}
  146. def validate(self, password, user=None):
  147. if password.lower().strip() in self.passwords:
  148. raise ValidationError(
  149. _("This password is too common."),
  150. code='password_too_common',
  151. )
  152. def get_help_text(self):
  153. return _("Your password can't be a commonly used password.")
  154. class NumericPasswordValidator:
  155. """
  156. Validate whether the password is alphanumeric.
  157. """
  158. def validate(self, password, user=None):
  159. if password.isdigit():
  160. raise ValidationError(
  161. _("This password is entirely numeric."),
  162. code='password_entirely_numeric',
  163. )
  164. def get_help_text(self):
  165. return _("Your password can't be entirely numeric.")