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

63 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

1import logging 

2import typing as t 

3import warnings 

4 

5from viur.core import conf, current 

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

7 

8from google.cloud import recaptchaenterprise_v1 

9from google.cloud.recaptchaenterprise_v1 import Assessment 

10 

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

12 from viur.core.skeleton import SkeletonInstance 

13 

14 

15class CaptchaBone(BaseBone): 

16 r""" 

17 The CaptchaBone validates reCAPTCHA Enterprise tokens to protect forms from bots. 

18 

19 It uses the Google reCAPTCHA Enterprise API and supports both invisible (v3-style score-based) 

20 and visible (checkbox widget) challenges via the ``render_challenge`` parameter. 

21 

22 The token is submitted by the client as the bone's field value and verified server-side 

23 against the configured site key. A configurable score threshold determines whether 

24 invisible challenges pass. 

25 

26 .. seealso:: 

27 

28 `Google reCAPTCHA Enterprise setup 

29 <https://cloud.google.com/recaptcha/docs/set-up-non-google-cloud-environments-api-keys>` 

30 for creating a site key and enabling the API. 

31 

32 Option :attr:`core.config.Security.captcha_default_public_key` 

33 for global security settings. 

34 

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

36 to enforce validation even on development servers. 

37 """ 

38 

39 type = "captcha" 

40 

41 def __init__( 

42 self, 

43 *, 

44 public_key: str = None, 

45 score_threshold: float = 0.5, 

46 render_challenge: bool = False, 

47 recaptcha_action: str = "", 

48 **kwargs: t.Any 

49 ): 

50 """ 

51 Initializes a new CaptchaBone. 

52 

53 :param public_key: The reCAPTCHA Enterprise site key shown to the client. 

54 Can be omitted if set globally via :attr:`core.config.Security.captcha_default_public_key`. 

55 :param score_threshold: Minimum score (0–1) required for invisible challenges to pass. 

56 Ignored when ``render_challenge`` is ``True``. 

57 :param render_challenge: If ``True``, renders a visible checkbox widget instead of 

58 running an invisible background check. 

59 :param recaptcha_action: The action name passed to reCAPTCHA for analytics and scoring. 

60 Should match the action used on the client side. 

61 """ 

62 if "publicKey" in kwargs: 

63 warnings.warn("publicKey parameter is deprecated, please use public_key", 

64 DeprecationWarning, stacklevel=2) 

65 public_key = kwargs.pop("publicKey") 

66 super().__init__(**kwargs) 

67 if not public_key and conf.security.captcha_default_public_key: 

68 public_key = conf.security.captcha_default_public_key 

69 if not public_key: 

70 raise ValueError("CaptchaBone requires either a public_key or conf.security.captcha_default_public_key") 

71 

72 self.public_key = public_key 

73 

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

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

76 self.render_challenge = render_challenge 

77 self.recaptcha_action = recaptcha_action 

78 self.score_threshold = score_threshold 

79 self.required = True 

80 

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

82 """ 

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

84 """ 

85 return False 

86 

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

88 """ 

89 Stores the public_key in the SkeletonInstance 

90 

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

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

93 

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

95 """ 

96 skel.accessedValues[name] = self.public_key 

97 return True 

98 

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

100 """ 

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

102 

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

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

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

106 While the latter one is the preferred name. 

107 """ 

108 

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

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

111 return None 

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

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

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

115 

116 client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient() 

117 

118 # Set the attributes of the event to be tracked. 

119 event = recaptchaenterprise_v1.Event() 

120 event.site_key = self.public_key 

121 if name in data: 

122 event.token = data[name] 

123 else: 

124 return [ReadFromClientError( 

125 ReadFromClientErrorSeverity.NotSet, 

126 "Token not set" 

127 )] 

128 assessment = recaptchaenterprise_v1.Assessment() 

129 assessment.event = event 

130 

131 project_name = f"projects/{conf.instance.project_id}" 

132 

133 # Create the assessment request. 

134 request = recaptchaenterprise_v1.CreateAssessmentRequest() 

135 request.assessment = assessment 

136 request.parent = project_name 

137 

138 response = client.create_assessment(request) 

139 

140 if not response.token_properties.valid: 

141 logging.info( 

142 "The CreateAssessment call failed because the token was " 

143 + "invalid for the following reasons: " 

144 + str(response.token_properties.invalid_reason) 

145 ) 

146 return [ReadFromClientError( 

147 ReadFromClientErrorSeverity.Invalid, 

148 "Invalid Token" 

149 )] 

150 

151 # Check if the expected action was executed. 

152 if response.token_properties.action != self.recaptcha_action: 

153 logging.info( 

154 "The action attribute in your reCAPTCHA tag does not match the action you are expecting to score" 

155 ) 

156 return [ReadFromClientError( 

157 ReadFromClientErrorSeverity.Invalid, 

158 f"Invalid Action: {self.recaptcha_action}" 

159 )] 

160 else: 

161 # Retrieve the risk score and reasons. 

162 # For more information on interpreting the assessment, see: 

163 # https://cloud.google.com/recaptcha/docs/interpret-assessment 

164 if response.risk_analysis.score < self.score_threshold: 

165 return [ReadFromClientError( 

166 ReadFromClientErrorSeverity.Invalid, 

167 f"Invalid Captcha: {response.risk_analysis.score}" 

168 )] 

169 

170 return None 

171 

172 def structure(self) -> dict: 

173 return super().structure() | { 

174 "public_key": self.public_key, 

175 "render_challenge": self.render_challenge, 

176 "action": self.recaptcha_action 

177 }