Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/password.py: 21%
68 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 12:27 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 12:27 +0000
1"""
2The PasswordBone class is a specialized version of the StringBone class designed to handle password
3data. It hashes the password data before saving it to the database and prevents it from being read
4directly. The class also includes various tests to determine the strength of the entered password.
5"""
6import hashlib
7import re
8import typing as t
9from viur.core import conf, utils
10from viur.core.bones.string import StringBone
11from viur.core.i18n import translate
12from .base import ReadFromClientError, ReadFromClientErrorSeverity
14# https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
15PBKDF2_DEFAULT_ITERATIONS = 600_000
18def encode_password(password: str | bytes, salt: str | bytes,
19 iterations: int = PBKDF2_DEFAULT_ITERATIONS, dklen: int = 42
20 ) -> dict[str, str | bytes]:
21 """Decodes a pashword and return the hash and meta information as hash"""
22 password = password[:conf.user.max_password_length]
23 if isinstance(password, str):
24 password = password.encode()
25 if isinstance(salt, str):
26 salt = salt.encode()
27 pwhash = hashlib.pbkdf2_hmac("sha256", password, salt, iterations, dklen)
28 return {
29 "pwhash": pwhash.hex().encode(),
30 "salt": salt,
31 "iterations": iterations,
32 "dklen": dklen,
33 }
36class PasswordBone(StringBone):
37 """
38 A specialized subclass of the StringBone class designed to handle password data.
40 The PasswordBone hashes the password before saving it to the database and prevents it from
41 being read directly. It also includes various tests to determine the strength of the entered
42 password.
43 """
44 type = "password"
45 """A string representing the bone type, which is "password" in this case."""
46 saltLength = 13
48 tests: t.Iterable[t.Iterable[t.Tuple[str, str, bool]]] = (
49 (r"^.*[A-Z].*$", translate("core.bones.password.no_capital_letters",
50 defaultText="The password entered has no capital letters."), False),
51 (r"^.*[a-z].*$", translate("core.bones.password.no_lowercase_letters",
52 defaultText="The password entered has no lowercase letters."), False),
53 (r"^.*\d.*$", translate("core.bones.password.no_digits",
54 defaultText="The password entered has no digits."), False),
55 (r"^.*\W.*$", translate("core.bones.password.no_special_characters",
56 defaultText="The password entered has no special characters."), False),
57 (r"^.{8,}$", translate("core.bones.password.too_short",
58 defaultText="The password is too short. It requires for at least 8 characters."), True),
59 )
60 """Provides tests based on regular expressions to test the password strength.
62 Note: The provided regular expressions have to produce exactly the same results in Python and JavaScript.
63 This requires that some feature either cannot be used, or must be rewritten to match on both engines.
64 """
66 def __init__(
67 self,
68 *,
69 descr: str = "Password",
70 test_threshold: int = 4,
71 tests: t.Iterable[t.Iterable[t.Tuple[str, str, bool]]] = tests,
72 raw: bool = False,
73 **kwargs
74 ):
75 """
76 Initializes a new PasswordBone.
78 :param test_threshold: The minimum number of tests the password must pass.
79 :param password_tests: Defines separate tests specified as tuples of regex, hint and required-flag.
80 :param raw: Don't encode password's hash when reading from client, just save the provided string.
81 """
82 super().__init__(descr=descr, **kwargs)
83 self.test_threshold = test_threshold
84 self.raw = raw
85 if tests is not None:
86 self.tests = tests
88 def isInvalid(self, value):
89 """
90 Determines if the entered password is invalid based on the length and strength requirements.
91 It checks if the password is empty, too short, or too weak according to the password tests
92 specified in the class.
94 :param str value: The password to be checked.
95 :return: True if the password is invalid, otherwise False.
96 :rtype: bool
97 """
98 if not value:
99 return False
101 # Run our password test suite
102 tests_errors = []
103 tests_passed = 0
104 required_test_failed = False
106 for test, hint, required in self.tests:
107 if re.match(test, value):
108 tests_passed += 1
109 else:
110 tests_errors.append(str(hint)) # we may need to convert a "translate" object
111 if required: # we have a required test that failed make sure we abort
112 required_test_failed = True
114 if tests_passed < self.test_threshold or required_test_failed:
115 return tests_errors
117 return False
119 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
120 """
121 Processes the password field from the client data, validates it, and stores it in the
122 skeleton instance after hashing. This method performs several checks, such as ensuring that
123 the password field is present in the data, that the password is not empty, and that it meets
124 the length and strength requirements. If any of these checks fail, a ReadFromClientError is
125 returned.
127 :param SkeletonInstance skel: The skeleton instance to store the password in.
128 :param str name: The name of the password field.
129 :param dict data: The data dictionary containing the password field value.
130 :return: None if the password is valid, otherwise a list of ReadFromClientErrors.
131 :rtype: Union[None, List[ReadFromClientError]]
132 """
133 if name not in data:
134 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
136 if not (value := data[name]):
137 # PasswordBone is special: As it cannot be read, don't set back to None if no value is given
138 # This means a password once set can only be changed - but not deleted.
139 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value entered")]
141 if err := self.isInvalid(value):
142 return [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
144 # As we don't escape passwords and allow most special characters we'll hash it early on so we don't open
145 # an XSS attack vector if a password is echoed back to the client (which should not happen)
146 skel[name] = value if self.raw else encode_password(value, utils.string.random(self.saltLength))
148 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
149 """
150 Processes and stores the password field from the client data into the skeleton instance after
151 hashing and validating it. This method carries out various checks, such as:
153 * Ensuring that the password field is present in the data.
154 * Verifying that the password is not empty.
155 * Confirming that the password meets the length and strength requirements.
157 If any of these checks fail, a ReadFromClientError is returned.
159 :param SkeletonInstance skel: The skeleton instance where the password will be stored as a
160 hashed value along with its salt.
161 :param str name: The name of the password field used to access the password value in the
162 data dictionary.
163 :param dict data: The data dictionary containing the password field value, typically
164 submitted by the client.
165 :return: None if the password is valid and successfully stored in the skeleton instance;
166 otherwise, a list of ReadFromClientErrors containing detailed information about the errors.
167 :rtype: Union[None, List[ReadFromClientError]]
168 """
169 self.serialize_compute(skel, name)
170 if not (value := skel.accessedValues.get(name)):
171 return False
173 if isinstance(value, dict): # It is a pre-hashed value (probably fromClient)
174 skel.dbEntity[name] = value
175 else: # This has been set by skel["password"] = "secret", we'll still have to hash it
176 skel.dbEntity[name] = encode_password(value, utils.string.random(self.saltLength))
178 # Ensure our indexed flag is up2date
179 indexed = self.indexed and parentIndexed
181 if indexed and name in skel.dbEntity.exclude_from_indexes:
182 skel.dbEntity.exclude_from_indexes.discard(name)
183 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
184 skel.dbEntity.exclude_from_indexes.add(name)
186 return True
188 def unserialize(self, skeletonValues, name):
189 """
190 This method does not unserialize password values from the datastore. It always returns False,
191 indicating that no password value will be unserialized.
193 :param dict skeletonValues: The dictionary containing the values from the datastore.
194 :param str name: The name of the password field.
195 :return: False, as no password value will be unserialized.
196 :rtype: bool
197 """
198 return False
200 def structure(self) -> dict:
201 return super().structure() | {
202 "tests": self.tests if self.test_threshold else (),
203 "test_threshold": self.test_threshold,
204 }