-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: Validates score of [reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3?hl=en) challenge * feat: Add more verbose error messages * feat: Add config option `captcha_enforce_always` * change: Support bone name and "g-recaptcha-response" in `fromClient` * refactor: Use `requests` * docs: Update docstrings and describe behavior --------- Co-authored-by: Jan Max Meyer <[email protected]>
- Loading branch information
1 parent
aaa8d27
commit e33b423
Showing
2 changed files
with
106 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,82 +1,130 @@ | ||
import json | ||
import urllib.parse | ||
import urllib.request | ||
import logging | ||
import typing as t | ||
|
||
import requests | ||
|
||
from viur.core import conf, current | ||
from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity | ||
|
||
if t.TYPE_CHECKING: | ||
from viur.core.skeleton import SkeletonInstance | ||
|
||
|
||
class CaptchaBone(BaseBone): | ||
r""" | ||
The CaptchaBone is used to ensure that a user is not a bot. | ||
The Captcha bone uses the Google reCAPTCHA API to perform the Captcha | ||
validation and is derived from the BaseBone. | ||
validation and supports v2 and v3. | ||
.. seealso:: | ||
Option :attr:`core.config.Security.captcha_default_credentials` | ||
for global security settings. | ||
:param publicKey: The public key for the Captcha validation. | ||
:param privateKey: The private key for the Captcha validation. | ||
:param \**kwargs: Additional arguments to pass to the base class constructor. | ||
Option :attr:`core.config.Security.captcha_enforce_always` | ||
for developing. | ||
""" | ||
|
||
type = "captcha" | ||
|
||
def __init__(self, *, publicKey=None, privateKey=None, **kwargs): | ||
def __init__( | ||
self, | ||
*, | ||
publicKey: str = None, | ||
privateKey: str = None, | ||
score_threshold: float = 0.5, | ||
**kwargs: t.Any | ||
): | ||
""" | ||
Initializes a new CaptchaBone. | ||
`publicKey` and `privateKey` can be omitted, if they are set globally | ||
in :attr:`core.config.Security.captcha_default_credentials`. | ||
:param publicKey: The public key for the Captcha validation. | ||
:param privateKey: The private key for the Captcha validation. | ||
:score_threshold: If reCAPTCHA v3 is used, the score must be at least this threshold. | ||
For reCAPTCHA v2 this property will be ignored. | ||
""" | ||
super().__init__(**kwargs) | ||
self.defaultValue = self.publicKey = publicKey | ||
self.privateKey = privateKey | ||
if not (0 < score_threshold <= 1): | ||
raise ValueError("score_threshold must be between 0 and 1.") | ||
self.score_threshold = score_threshold | ||
if not self.defaultValue and not self.privateKey: | ||
# Merge these values from the side-wide configuration if set | ||
if conf.security.captcha_default_credentials: | ||
self.defaultValue = self.publicKey = conf.security.captcha_default_credentials["sitekey"] | ||
self.privateKey = conf.security.captcha_default_credentials["secret"] | ||
self.required = True | ||
if not self.privateKey: | ||
raise ValueError("privateKey must be set.") | ||
|
||
def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool: | ||
def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool: | ||
""" | ||
Serializing the Captcha bone is not possible so it return False | ||
""" | ||
return False | ||
|
||
def unserialize(self, skel, name) -> bool: | ||
def unserialize(self, skel: "SkeletonInstance", name) -> t.Literal[True]: | ||
""" | ||
Unserialize the Captcha bone. | ||
Stores the publicKey in the SkeletonInstance | ||
:param skel: The SkeletonInstance containing the Captcha bone. | ||
:param name: The name of the Captcha bone. | ||
:param skel: The target :class:`SkeletonInstance`. | ||
:param name: The name of the CaptchaBone in the :class:`SkeletonInstance`. | ||
:returns: boolean, that is true, as the Captcha bone is always unserialized successfully. | ||
""" | ||
skel.accessedValues[name] = self.publicKey | ||
return True | ||
|
||
def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]: | ||
def fromClient(self, skel: "SkeletonInstance", name: str, data: dict) -> None | list[ReadFromClientError]: | ||
""" | ||
Reads a value from the client. | ||
If this value is valid for this bone, | ||
store this value and return None. | ||
Otherwise our previous value is | ||
left unchanged and an error-message | ||
is returned. | ||
:param name: Our name in the skeleton | ||
:param data: *User-supplied* request-data | ||
:returns: None or a list of errors | ||
Load the reCAPTCHA token from the provided data and validate it with the help of the API. | ||
reCAPTCHA provides the token via callback usually as "g-recaptcha-response", | ||
but to fit into the skeleton logic, we support both names. | ||
So the token can be provided as "g-recaptcha-response" or the name of the CaptchaBone in the Skeleton. | ||
While the latter one is the preferred name. | ||
""" | ||
if conf.instance.is_dev_server: # We dont enforce captchas on dev server | ||
if not conf.security.captcha_enforce_always and conf.instance.is_dev_server: | ||
logging.info("Skipping captcha validation on development server") | ||
return None | ||
if (user := current.user.get()) and "root" in user["access"]: | ||
return None # Don't bother trusted users with this (not supported by admin/vi anyways) | ||
if not "g-recaptcha-response" in data: | ||
if not conf.security.captcha_enforce_always and (user := current.user.get()) and "root" in user["access"]: | ||
logging.info("Skipping captcha validation for root user") | ||
return None # Don't bother trusted users with this (not supported by admin/vi anyway) | ||
if name not in data and "g-recaptcha-response" not in data: | ||
return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "No Captcha given!")] | ||
data = { | ||
"secret": self.privateKey, | ||
"remoteip": current.request.get().request.remote_addr, | ||
"response": data["g-recaptcha-response"] | ||
} | ||
req = urllib.request.Request(url="https://www.google.com/recaptcha/api/siteverify", | ||
data=urllib.parse.urlencode(data).encode(), | ||
method="POST") | ||
response = urllib.request.urlopen(req) | ||
if json.loads(response.read()).get("success"): | ||
return None | ||
return [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid Captcha")] | ||
|
||
result = requests.post( | ||
url="https://www.google.com/recaptcha/api/siteverify", | ||
data={ | ||
"secret": self.privateKey, | ||
"remoteip": current.request.get().request.remote_addr, | ||
"response": data.get(name, data.get("g-recaptcha-response")), | ||
}, | ||
timeout=10, | ||
) | ||
if not result.ok: | ||
logging.error(f"{result.status_code} {result.reason}: {result.text}") | ||
raise ValueError(f"Request to reCAPTCHA failed: {result.status_code} {result.reason}") | ||
data = result.json() | ||
logging.debug(f"Captcha verification {data=}") | ||
|
||
if not data.get("success"): | ||
logging.error(data.get("error-codes")) | ||
return [ReadFromClientError( | ||
ReadFromClientErrorSeverity.Invalid, | ||
f'Invalid Captcha: {", ".join(data.get("error-codes", []))}' | ||
)] | ||
|
||
if "score" in data and data["score"] < self.score_threshold: | ||
# it's reCAPTCHA v3; check the score | ||
return [ReadFromClientError( | ||
ReadFromClientErrorSeverity.Invalid, | ||
f'Invalid Captcha: {data["score"]} is lower than threshold {self.score_threshold}' | ||
)] | ||
|
||
return None # okay |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters