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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1import logging
2import typing as t
3import warnings
5from viur.core import conf, current
6from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
8from google.cloud import recaptchaenterprise_v1
9from google.cloud.recaptchaenterprise_v1 import Assessment
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
15class CaptchaBone(BaseBone):
16 r"""
17 The CaptchaBone validates reCAPTCHA Enterprise tokens to protect forms from bots.
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.
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.
26 .. seealso::
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.
32 Option :attr:`core.config.Security.captcha_default_public_key`
33 for global security settings.
35 Option :attr:`core.config.Security.captcha_enforce_always`
36 to enforce validation even on development servers.
37 """
39 type = "captcha"
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.
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")
72 self.public_key = public_key
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
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
87 def unserialize(self, skel: "SkeletonInstance", name) -> t.Literal[True]:
88 """
89 Stores the public_key in the SkeletonInstance
91 :param skel: The target :class:`SkeletonInstance`.
92 :param name: The name of the CaptchaBone in the :class:`SkeletonInstance`.
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
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.
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 """
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)
116 client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
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
131 project_name = f"projects/{conf.instance.project_id}"
133 # Create the assessment request.
134 request = recaptchaenterprise_v1.CreateAssessmentRequest()
135 request.assessment = assessment
136 request.parent = project_name
138 response = client.create_assessment(request)
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 )]
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 )]
170 return None
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 }