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

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 

13 

14# https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 

15PBKDF2_DEFAULT_ITERATIONS = 600_000 

16 

17 

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 } 

34 

35 

36class PasswordBone(StringBone): 

37 """ 

38 A specialized subclass of the StringBone class designed to handle password data. 

39 

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 

47 

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. 

61 

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 """ 

65 

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. 

77 

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 

87 

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. 

93 

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 

100 

101 # Run our password test suite 

102 tests_errors = [] 

103 tests_passed = 0 

104 required_test_failed = False 

105 

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 

113 

114 if tests_passed < self.test_threshold or required_test_failed: 

115 return tests_errors 

116 

117 return False 

118 

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. 

126 

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")] 

135 

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")] 

140 

141 if err := self.isInvalid(value): 

142 return [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)] 

143 

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)) 

147 

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: 

152 

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. 

156 

157 If any of these checks fail, a ReadFromClientError is returned. 

158 

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 

172 

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)) 

177 

178 # Ensure our indexed flag is up2date 

179 indexed = self.indexed and parentIndexed 

180 

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) 

185 

186 return True 

187 

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. 

192 

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 

199 

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 }