Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/password.py: 22%
66 statements
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +0000
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +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 **kwargs
73 ):
74 """
75 Initializes a new PasswordBone.
77 :param test_threshold: The minimum number of tests the password must pass.
78 :param password_tests: Defines separate tests specified as tuples of regex, hint and required-flag.
79 """
80 super().__init__(descr=descr, **kwargs)
81 self.test_threshold = test_threshold
82 if tests is not None:
83 self.tests = tests
85 def isInvalid(self, value):
86 """
87 Determines if the entered password is invalid based on the length and strength requirements.
88 It checks if the password is empty, too short, or too weak according to the password tests
89 specified in the class.
91 :param str value: The password to be checked.
92 :return: True if the password is invalid, otherwise False.
93 :rtype: bool
94 """
95 if not value:
96 return False
98 # Run our password test suite
99 tests_errors = []
100 tests_passed = 0
101 required_test_failed = False
103 for test, hint, required in self.tests:
104 if re.match(test, value):
105 tests_passed += 1
106 else:
107 tests_errors.append(str(hint)) # we may need to convert a "translate" object
108 if required: # we have a required test that failed make sure we abort
109 required_test_failed = True
111 if tests_passed < self.test_threshold or required_test_failed:
112 return tests_errors
114 return False
116 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
117 """
118 Processes the password field from the client data, validates it, and stores it in the
119 skeleton instance after hashing. This method performs several checks, such as ensuring that
120 the password field is present in the data, that the password is not empty, and that it meets
121 the length and strength requirements. If any of these checks fail, a ReadFromClientError is
122 returned.
124 :param SkeletonInstance skel: The skeleton instance to store the password in.
125 :param str name: The name of the password field.
126 :param dict data: The data dictionary containing the password field value.
127 :return: None if the password is valid, otherwise a list of ReadFromClientErrors.
128 :rtype: Union[None, List[ReadFromClientError]]
129 """
130 if name not in data:
131 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
133 if not (value := data[name]):
134 # PasswordBone is special: As it cannot be read, don't set back to None if no value is given
135 # This means a password once set can only be changed - but not deleted.
136 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value entered")]
138 if err := self.isInvalid(value):
139 return [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
141 # As we don't escape passwords and allow most special characters we'll hash it early on so we don't open
142 # an XSS attack vector if a password is echoed back to the client (which should not happen)
143 skel[name] = encode_password(value, utils.string.random(self.saltLength))
145 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
146 """
147 Processes and stores the password field from the client data into the skeleton instance after
148 hashing and validating it. This method carries out various checks, such as:
150 * Ensuring that the password field is present in the data.
151 * Verifying that the password is not empty.
152 * Confirming that the password meets the length and strength requirements.
154 If any of these checks fail, a ReadFromClientError is returned.
156 :param SkeletonInstance skel: The skeleton instance where the password will be stored as a
157 hashed value along with its salt.
158 :param str name: The name of the password field used to access the password value in the
159 data dictionary.
160 :param dict data: The data dictionary containing the password field value, typically
161 submitted by the client.
162 :return: None if the password is valid and successfully stored in the skeleton instance;
163 otherwise, a list of ReadFromClientErrors containing detailed information about the errors.
164 :rtype: Union[None, List[ReadFromClientError]]
165 """
166 if not (value := skel.accessedValues.get(name)):
167 return False
169 if isinstance(value, dict): # It is a pre-hashed value (probably fromClient)
170 skel.dbEntity[name] = value
171 else: # This has been set by skel["password"] = "secret", we'll still have to hash it
172 skel.dbEntity[name] = encode_password(value, utils.string.random(self.saltLength))
174 # Ensure our indexed flag is up2date
175 indexed = self.indexed and parentIndexed
177 if indexed and name in skel.dbEntity.exclude_from_indexes:
178 skel.dbEntity.exclude_from_indexes.discard(name)
179 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
180 skel.dbEntity.exclude_from_indexes.add(name)
182 return True
184 def unserialize(self, skeletonValues, name):
185 """
186 This method does not unserialize password values from the datastore. It always returns False,
187 indicating that no password value will be unserialized.
189 :param dict skeletonValues: The dictionary containing the values from the datastore.
190 :param str name: The name of the password field.
191 :return: False, as no password value will be unserialized.
192 :rtype: bool
193 """
194 return False
196 def structure(self) -> dict:
197 return super().structure() | {
198 "tests": self.tests if self.test_threshold else (),
199 "test_threshold": self.test_threshold,
200 }