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 )