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 )