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__(
55        self, password_list_path: Path | cached_property = DEFAULT_PASSWORD_LIST_PATH
56    ) -> None:
57        if password_list_path is CommonPasswordValidator.DEFAULT_PASSWORD_LIST_PATH:
58            password_list_path = self.DEFAULT_PASSWORD_LIST_PATH
59        try:
60            with gzip.open(password_list_path, "rt", encoding="utf-8") as f:
61                self.passwords = {x.strip() for x in f}
62        except OSError:
63            with open(password_list_path) as f:
64                self.passwords = {x.strip() for x in f}
65
66    def __call__(self, password: str) -> None:
67        if password.lower().strip() in self.passwords:
68            raise ValidationError(
69                "This password is too common.",
70                code="password_too_common",
71            )
72
73
74@deconstructible
75class NumericPasswordValidator:
76    """
77    Validate that the password is not entirely numeric.
78    """
79
80    def __call__(self, password: str) -> None:
81        if password.isdigit():
82            raise ValidationError(
83                "This password is entirely numeric.",
84                code="password_entirely_numeric",
85            )