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.3, created at 2024-10-16 22:16 +0000
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +0000
1import logging
2import typing as t
4import requests
6from viur.core import conf, current
7from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
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
13class CaptchaBone(BaseBone):
14 r"""
15 The CaptchaBone is used to ensure that a user is not a bot.
17 The Captcha bone uses the Google reCAPTCHA API to perform the Captcha
18 validation and supports v2 and v3.
20 .. seealso::
22 Option :attr:`core.config.Security.captcha_default_credentials`
23 for global security settings.
25 Option :attr:`core.config.Security.captcha_enforce_always`
26 for developing.
27 """
29 type = "captcha"
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.
42 `publicKey` and `privateKey` can be omitted, if they are set globally
43 in :attr:`core.config.Security.captcha_default_credentials`.
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.")
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
71 def unserialize(self, skel: "SkeletonInstance", name) -> t.Literal[True]:
72 """
73 Stores the publicKey in the SkeletonInstance
75 :param skel: The target :class:`SkeletonInstance`.
76 :param name: The name of the CaptchaBone in the :class:`SkeletonInstance`.
78 :returns: boolean, that is true, as the Captcha bone is always unserialized successfully.
79 """
80 skel.accessedValues[name] = self.publicKey
81 return True
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.
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!")]
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=}")
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 )]
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 )]
130 return None # okay