Coverage for gws-app/gws/lib/password/__init__.py: 96%
55 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Password hashing, checking and generation.
3Passwords are hashed with PBKDF2 (100000 iterations) and a random salt. The encoded hash
4has the form ``$algorithm$salt$hash``, where the hash is base64 (URL-safe) encoded.
5``check`` reads the algorithm and salt back from the encoded value.
7``generate`` creates random passwords with a configurable length and number of lowercase,
8uppercase, digit and punctuation characters. ``generate_with_groups`` does the same for
9arbitrary character groups (``SymbolGroup``).
11Example::
13 import gws.lib.password
15 encoded = gws.lib.password.encode('secret')
16 gws.lib.password.check('secret', encoded) # True
18 pw = gws.lib.password.generate(min_len=12, max_len=16, min_digit=2)
19"""
21import hashlib
22import hmac
23import random
24import string
26import gws.lib.text
29def compare(a: str, b: str) -> bool:
30 """Compare two strings in constant time, to prevent timing attacks.
32 Args:
33 a: First string.
34 b: Second string.
36 Returns:
37 ``True`` if the strings are equal, ``False`` otherwise.
38 """
40 return hmac.compare_digest(a.encode('utf8'), b.encode('utf8'))
43def encode(password: str, algo: str = 'sha512') -> str:
44 """Encode a password into a salted PBKDF2 hash.
46 Args:
47 password: Plain text password.
48 algo: Hash algorithm name, as in ``hashlib``.
50 Returns:
51 The encoded hash in the format ``$algorithm$salt$hash``.
52 """
54 salt = _random_string(8)
55 h = _pbkdf2(password, salt, algo)
56 return '$'.join(['', algo, salt, gws.lib.text.to_base64(h, url_safe=True)])
59def check(password: str, encoded: str) -> bool:
60 """Check if a password matches an encoded hash.
62 Args:
63 password: Plain text password.
64 encoded: Encoded hash, as returned by ``encode``.
66 Returns:
67 ``True`` if the password matches, ``False`` if it does not or if the encoded hash is invalid.
68 """
70 try:
71 _, algo, salt, hs = str(encoded).split('$')
72 h1 = gws.lib.text.from_base64(hs, url_safe=True)
73 h2 = _pbkdf2(password, salt, algo)
74 except (TypeError, ValueError, gws.lib.text.Error):
75 return False
77 return hmac.compare_digest(h1, h2)
80class SymbolGroup:
81 """A group of characters with the minimum and maximum number of occurrences in a generated password."""
83 def __init__(self, s, min_len, max_len):
84 """Create a symbol group.
86 Args:
87 s: Characters of the group.
88 min_len: Minimum number of characters from this group.
89 max_len: Maximum number of characters from this group.
90 """
91 self.chars = s
92 self.max = max_len
93 self.min = min_len
94 self.count = 0
97def generate(
98 min_len: int = 16,
99 max_len: int = 16,
100 min_lower: int = 0,
101 max_lower: int = 255,
102 min_upper: int = 0,
103 max_upper: int = 255,
104 min_digit: int = 0,
105 max_digit: int = 255,
106 min_punct: int = 0,
107 max_punct: int = 255,
108) -> str:
109 """Generate a random password.
111 Args:
112 min_len: Minimum password length.
113 max_len: Maximum password length.
114 min_lower: Minimum number of lowercase letters.
115 max_lower: Maximum number of lowercase letters.
116 min_upper: Minimum number of uppercase letters.
117 max_upper: Maximum number of uppercase letters.
118 min_digit: Minimum number of digits.
119 max_digit: Maximum number of digits.
120 min_punct: Minimum number of punctuation characters.
121 max_punct: Maximum number of punctuation characters.
123 Returns:
124 The password.
126 Raises:
127 ``ValueError``: If the constraints cannot be satisfied.
128 """
130 groups = [
131 SymbolGroup(string.ascii_lowercase, min_lower, max_lower),
132 SymbolGroup(string.ascii_uppercase, min_upper, max_upper),
133 SymbolGroup(string.digits, min_digit, max_digit),
134 SymbolGroup(string.punctuation, min_punct, max_punct),
135 ]
136 return generate_with_groups(groups, min_len, max_len)
139def generate_with_groups(
140 groups: list[SymbolGroup],
141 min_len: int = 16,
142 max_len: int = 16,
143) -> str:
144 """Generate a random password from a list of symbol groups.
146 The ``count`` attribute of each group is updated.
148 Args:
149 groups: Symbol groups.
150 min_len: Minimum password length.
151 max_len: Maximum password length.
153 Returns:
154 The password.
156 Raises:
157 ``ValueError``: If the constraints cannot be satisfied.
158 """
160 r = random.SystemRandom()
161 p = []
163 for g in groups:
164 p.extend(r.choices(g.chars, k=g.min))
165 g.count = g.min
167 if len(p) > max_len:
168 raise ValueError('invalid parameters')
170 size = r.randint(max(min_len, len(p)), max_len)
172 while len(p) < size:
173 sel = ''.join(g.chars for g in groups if g.count < g.max)
174 if not sel:
175 raise ValueError('invalid parameters')
176 c = r.choice(sel)
177 for g in groups:
178 if c in g.chars:
179 g.count += 1
180 break
181 p.append(c)
183 r.shuffle(p)
185 return ''.join(p)
188##
191def _pbkdf2(password, salt, algo):
192 return hashlib.pbkdf2_hmac(algo, password.encode('utf8'), salt.encode('utf8'), 100000)
195def _random_string(length):
196 a = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
197 r = random.SystemRandom()
198 return ''.join(r.choice(a) for _ in range(length))