Plain is headed towards 1.0! Subscribe for development updates →

 1from __future__ import annotations
 2
 3import gzip
 4from functools import cached_property
 5from pathlib import Path
 6
 7from plain.exceptions import (
 8    ValidationError,
 9)
10from plain.utils.deconstruct import deconstructible
11from plain.utils.text import pluralize
12
13
14@deconstructible
15class MinimumLengthValidator:
16    """
17    Validate that the password is of a minimum length.
18    """
19
20    def __init__(self, min_length: int = 8) -> None:
21        self.min_length = min_length
22
23    def __call__(self, password: str) -> None:
24        if len(password) < self.min_length:
25            raise ValidationError(
26                pluralize(
27                    "This password is too short. It must contain at least "
28                    "%(min_length)d character.",
29                    "This password is too short. It must contain at least "
30                    "%(min_length)d characters.",
31                    self.min_length,
32                ),
33                code="password_too_short",
34                params={"min_length": self.min_length},
35            )
36
37
38@deconstructible
39class CommonPasswordValidator:
40    """
41    Validate that the password is not a common password.
42
43    The password is rejected if it occurs in a provided list of passwords,
44    which may be gzipped. The list Plain ships with contains 20000 common
45    passwords (lowercased and deduplicated), created by Royce Williams:
46    https://gist.github.com/roycewilliams/226886fd01572964e1431ac8afc999ce
47    The password list must be lowercased to match the comparison in validate().
48    """
49
50    @cached_property
51    def DEFAULT_PASSWORD_LIST_PATH(self) -> Path:
52        return Path(__file__).resolve().parent / "common-passwords.txt.gz"
53
54    def __init__(self, password_list_path: Path | None = None) -> None:
55        resolved_path: Path = (
56            password_list_path
57            if password_list_path
58            else self.DEFAULT_PASSWORD_LIST_PATH
59        )
60        try:
61            with gzip.open(resolved_path, "rt", encoding="utf-8") as f:
62                self.passwords = {x.strip() for x in f}
63        except OSError:
64            with open(resolved_path) as f:
65                self.passwords = {x.strip() for x in f}
66
67    def __call__(self, password: str) -> None:
68        if password.lower().strip() in self.passwords:
69            raise ValidationError(
70                "This password is too common.",
71                code="password_too_common",
72            )
73
74
75@deconstructible
76class NumericPasswordValidator:
77    """
78    Validate that the password is not entirely numeric.
79    """
80
81    def __call__(self, password: str) -> None:
82        if password.isdigit():
83            raise ValidationError(
84                "This password is entirely numeric.",
85                code="password_entirely_numeric",
86            )