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

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 **kwargs 

73 ): 

74 """ 

75 Initializes a new PasswordBone. 

76 

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 

84 

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. 

90 

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 

97 

98 # Run our password test suite 

99 tests_errors = [] 

100 tests_passed = 0 

101 required_test_failed = False 

102 

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 

110 

111 if tests_passed < self.test_threshold or required_test_failed: 

112 return tests_errors 

113 

114 return False 

115 

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. 

123 

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

132 

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

137 

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

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

140 

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

144 

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: 

149 

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. 

153 

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

155 

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 

168 

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

173 

174 # Ensure our indexed flag is up2date 

175 indexed = self.indexed and parentIndexed 

176 

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) 

181 

182 return True 

183 

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. 

188 

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 

195 

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 }