Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/captcha.py: 18%

49 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-03 13:41 +0000

1import logging 

2import typing as t 

3 

4import requests 

5 

6from viur.core import conf, current 

7from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity 

8 

9if t.TYPE_CHECKING: 9 ↛ 10line 9 didn't jump to line 10 because the condition on line 9 was never true

10 from viur.core.skeleton import SkeletonInstance 

11 

12 

13class CaptchaBone(BaseBone): 

14 r""" 

15 The CaptchaBone is used to ensure that a user is not a bot. 

16 

17 The Captcha bone uses the Google reCAPTCHA API to perform the Captcha 

18 validation and supports v2 and v3. 

19 

20 .. seealso:: 

21 

22 Option :attr:`core.config.Security.captcha_default_credentials` 

23 for global security settings. 

24 

25 Option :attr:`core.config.Security.captcha_enforce_always` 

26 for developing. 

27 """ 

28 

29 type = "captcha" 

30 

31 def __init__( 

32 self, 

33 *, 

34 publicKey: str = None, 

35 privateKey: str = None, 

36 score_threshold: float = 0.5, 

37 **kwargs: t.Any 

38 ): 

39 """ 

40 Initializes a new CaptchaBone. 

41 

42 `publicKey` and `privateKey` can be omitted, if they are set globally 

43 in :attr:`core.config.Security.captcha_default_credentials`. 

44 

45 :param publicKey: The public key for the Captcha validation. 

46 :param privateKey: The private key for the Captcha validation. 

47 :score_threshold: If reCAPTCHA v3 is used, the score must be at least this threshold. 

48 For reCAPTCHA v2 this property will be ignored. 

49 """ 

50 super().__init__(**kwargs) 

51 self.defaultValue = self.publicKey = publicKey 

52 self.privateKey = privateKey 

53 if not (0 < score_threshold <= 1): 

54 raise ValueError("score_threshold must be between 0 and 1.") 

55 self.score_threshold = score_threshold 

56 if not self.defaultValue and not self.privateKey: 

57 # Merge these values from the side-wide configuration if set 

58 if conf.security.captcha_default_credentials: 

59 self.defaultValue = self.publicKey = conf.security.captcha_default_credentials["sitekey"] 

60 self.privateKey = conf.security.captcha_default_credentials["secret"] 

61 self.required = True 

62 if not self.privateKey: 

63 raise ValueError("privateKey must be set.") 

64 

65 def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool: 

66 """ 

67 Serializing the Captcha bone is not possible so it return False 

68 """ 

69 return False 

70 

71 def unserialize(self, skel: "SkeletonInstance", name) -> t.Literal[True]: 

72 """ 

73 Stores the publicKey in the SkeletonInstance 

74 

75 :param skel: The target :class:`SkeletonInstance`. 

76 :param name: The name of the CaptchaBone in the :class:`SkeletonInstance`. 

77 

78 :returns: boolean, that is true, as the Captcha bone is always unserialized successfully. 

79 """ 

80 skel.accessedValues[name] = self.publicKey 

81 return True 

82 

83 def fromClient(self, skel: "SkeletonInstance", name: str, data: dict) -> None | list[ReadFromClientError]: 

84 """ 

85 Load the reCAPTCHA token from the provided data and validate it with the help of the API. 

86 

87 reCAPTCHA provides the token via callback usually as "g-recaptcha-response", 

88 but to fit into the skeleton logic, we support both names. 

89 So the token can be provided as "g-recaptcha-response" or the name of the CaptchaBone in the Skeleton. 

90 While the latter one is the preferred name. 

91 """ 

92 if not conf.security.captcha_enforce_always and conf.instance.is_dev_server: 

93 logging.info("Skipping captcha validation on development server") 

94 return None 

95 if not conf.security.captcha_enforce_always and (user := current.user.get()) and "root" in user["access"]: 

96 logging.info("Skipping captcha validation for root user") 

97 return None # Don't bother trusted users with this (not supported by admin/vi anyway) 

98 if name not in data and "g-recaptcha-response" not in data: 

99 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "No Captcha given!")] 

100 

101 result = requests.post( 

102 url="https://www.google.com/recaptcha/api/siteverify", 

103 data={ 

104 "secret": self.privateKey, 

105 "remoteip": current.request.get().request.remote_addr, 

106 "response": data.get(name, data.get("g-recaptcha-response")), 

107 }, 

108 timeout=10, 

109 ) 

110 if not result.ok: 

111 logging.error(f"{result.status_code} {result.reason}: {result.text}") 

112 raise ValueError(f"Request to reCAPTCHA failed: {result.status_code} {result.reason}") 

113 data = result.json() 

114 logging.debug(f"Captcha verification {data=}") 

115 

116 if not data.get("success"): 

117 logging.error(data.get("error-codes")) 

118 return [ReadFromClientError( 

119 ReadFromClientErrorSeverity.Invalid, 

120 f'Invalid Captcha: {", ".join(data.get("error-codes", []))}' 

121 )] 

122 

123 if "score" in data and data["score"] < self.score_threshold: 

124 # it's reCAPTCHA v3; check the score 

125 return [ReadFromClientError( 

126 ReadFromClientErrorSeverity.Invalid, 

127 f'Invalid Captcha: {data["score"]} is lower than threshold {self.score_threshold}' 

128 )] 

129 

130 return None # okay