Plain is headed towards 1.0! Subscribe for development updates →

plain.password

Password authentication for Plain.

  1import gzip
  2from pathlib import Path
  3
  4from plain.exceptions import (
  5    ValidationError,
  6)
  7from plain.utils.deconstruct import deconstructible
  8from plain.utils.functional import cached_property
  9from plain.utils.text import pluralize
 10
 11
 12@deconstructible
 13class MinimumLengthValidator:
 14    """
 15    Validate that the password is of a minimum length.
 16    """
 17
 18    def __init__(self, min_length=8):
 19        self.min_length = min_length
 20
 21    def __call__(self, password):
 22        if len(password) < self.min_length:
 23            raise ValidationError(
 24                pluralize(
 25                    "This password is too short. It must contain at least "
 26                    "%(min_length)d character.",
 27                    "This password is too short. It must contain at least "
 28                    "%(min_length)d characters.",
 29                    self.min_length,
 30                ),
 31                code="password_too_short",
 32                params={"min_length": self.min_length},
 33            )
 34
 35
 36# def exceeds_maximum_length_ratio(password, max_similarity, value):
 37#     """
 38#     Test that value is within a reasonable range of password.
 39
 40#     The following ratio calculations are based on testing SequenceMatcher like
 41#     this:
 42
 43#     for i in range(0,6):
 44#       print(10**i, SequenceMatcher(a='A', b='A'*(10**i)).quick_ratio())
 45
 46#     which yields:
 47
 48#     1 1.0
 49#     10 0.18181818181818182
 50#     100 0.019801980198019802
 51#     1000 0.001998001998001998
 52#     10000 0.00019998000199980003
 53#     100000 1.999980000199998e-05
 54
 55#     This means a length_ratio of 10 should never yield a similarity higher than
 56#     0.2, for 100 this is down to 0.02 and for 1000 it is 0.002. This can be
 57#     calculated via 2 / length_ratio. As a result we avoid the potentially
 58#     expensive sequence matching.
 59#     """
 60#     pwd_len = len(password)
 61#     length_bound_similarity = max_similarity / 2 * pwd_len
 62#     value_len = len(value)
 63#     return pwd_len >= 10 * value_len and value_len < length_bound_similarity
 64
 65
 66# @deconstructible
 67# class UserAttributeSimilarityValidator:
 68#     """
 69#     Validate that the password is sufficiently different from the user's
 70#     attributes.
 71
 72#     If no specific attributes are provided, look at a sensible list of
 73#     defaults. Attributes that don't exist are ignored. Comparison is made to
 74#     not only the full attribute value, but also its components, so that, for
 75#     example, a password is validated against either part of an email address,
 76#     as well as the full address.
 77#     """
 78
 79#     DEFAULT_USER_ATTRIBUTES = ("username", "email")
 80
 81#     def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
 82#         self.user_attributes = user_attributes
 83#         if max_similarity < 0.1:
 84#             raise ValueError("max_similarity must be at least 0.1")
 85#         self.max_similarity = max_similarity
 86
 87#     def validate(self, password, user=None):
 88#         if not user:
 89#             return
 90
 91#         password = password.lower()
 92#         for attribute_name in self.user_attributes:
 93#             value = getattr(user, attribute_name, None)
 94#             if not value or not isinstance(value, str):
 95#                 continue
 96#             value_lower = value.lower()
 97#             value_parts = re.split(r"\W+", value_lower) + [value_lower]
 98#             for value_part in value_parts:
 99#                 if exceeds_maximum_length_ratio(
100#                     password, self.max_similarity, value_part
101#                 ):
102#                     continue
103#                 if (
104#                     SequenceMatcher(a=password, b=value_part).quick_ratio()
105#                     >= self.max_similarity
106#                 ):
107#                     try:
108#                         verbose_name = str(
109#                             user._meta.get_field(attribute_name).verbose_name
110#                         )
111#                     except FieldDoesNotExist:
112#                         verbose_name = attribute_name
113#                     raise ValidationError(
114#                         "The password is too similar to the %(verbose_name)s.",
115#                         code="password_too_similar",
116#                         params={"verbose_name": verbose_name},
117#                     )
118
119#     def get_help_text(self):
120#         return "Your password can’t be too similar to your other personal information."
121
122
123@deconstructible
124class CommonPasswordValidator:
125    """
126    Validate that the password is not a common password.
127
128    The password is rejected if it occurs in a provided list of passwords,
129    which may be gzipped. The list Plain ships with contains 20000 common
130    passwords (lowercased and deduplicated), created by Royce Williams:
131    https://gist.github.com/roycewilliams/226886fd01572964e1431ac8afc999ce
132    The password list must be lowercased to match the comparison in validate().
133    """
134
135    @cached_property
136    def DEFAULT_PASSWORD_LIST_PATH(self):
137        return Path(__file__).resolve().parent / "common-passwords.txt.gz"
138
139    def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
140        if password_list_path is CommonPasswordValidator.DEFAULT_PASSWORD_LIST_PATH:
141            password_list_path = self.DEFAULT_PASSWORD_LIST_PATH
142        try:
143            with gzip.open(password_list_path, "rt", encoding="utf-8") as f:
144                self.passwords = {x.strip() for x in f}
145        except OSError:
146            with open(password_list_path) as f:
147                self.passwords = {x.strip() for x in f}
148
149    def __call__(self, password):
150        if password.lower().strip() in self.passwords:
151            raise ValidationError(
152                "This password is too common.",
153                code="password_too_common",
154            )
155
156
157@deconstructible
158class NumericPasswordValidator:
159    """
160    Validate that the password is not entirely numeric.
161    """
162
163    def __call__(self, password):
164        if password.isdigit():
165            raise ValidationError(
166                "This password is entirely numeric.",
167                code="password_entirely_numeric",
168            )