diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 31330e7a..9ed287de 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,10 @@
Changelog
=========
+2.0.5
+-----
+#. Added settings and kwargs that allow for the validation of reCAPTCHA v3 score values.
+
2.0.4
-----
#. Fixed travis tests for django 2.2
diff --git a/README.rst b/README.rst
index a39e77e8..f318f521 100644
--- a/README.rst
+++ b/README.rst
@@ -179,6 +179,34 @@ The templates are located in:
For more information about overriding templates look at `Django's template override `_
+reCAPTCHA v3 Score
+~~~~~~~~~~~~~~~~~~
+
+As of version 3, reCAPTCHA also returns a score value. This can be used to determine the likelihood of the page interaction being a bot. See the Google `documentation `_ for more details.
+
+To set a project wide score limit use the ``RECAPTCHA_REQUIRED_SCORE`` setting.
+
+ For example:
+
+ .. code-block:: python
+
+ RECAPTCHA_REQUIRED_SCORE = 0.85
+
+For per field, runtime, specification the attribute can also be passed to the widget:
+
+ .. code-block:: python
+
+ captcha = fields.ReCaptchaField(
+ widget=ReCaptchaV3(
+ attrs={
+ 'required_score':0.85,
+ ...
+ }
+ )
+ )
+
+In the event the score does not meet the requirements, the field validation will fail as expected and an error message will be logged.
+
Local Development and Functional Testing
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/captcha/client.py b/captcha/client.py
index ae11d8b0..f8bfcb9b 100644
--- a/captcha/client.py
+++ b/captcha/client.py
@@ -13,9 +13,10 @@
class RecaptchaResponse(object):
- def __init__(self, is_valid, error_codes=None):
+ def __init__(self, is_valid, error_codes=None, extra_data=None):
self.is_valid = is_valid
self.error_codes = error_codes or []
+ self.extra_data = extra_data or {}
def recaptcha_request(params):
@@ -66,6 +67,7 @@ def submit(recaptcha_response, private_key, remoteip):
data = json.loads(response.read().decode("utf-8"))
response.close()
return RecaptchaResponse(
- is_valid=data["success"],
- error_codes=data.get("error-codes")
+ is_valid=data.pop("success"),
+ error_codes=data.pop("error-codes", None),
+ extra_data=data
)
diff --git a/captcha/fields.py b/captcha/fields.py
index 801140f0..c0fdc323 100644
--- a/captcha/fields.py
+++ b/captcha/fields.py
@@ -14,7 +14,7 @@
from captcha import client
from captcha._compat import HTTPError, urlencode
from captcha.constants import TEST_PRIVATE_KEY, TEST_PUBLIC_KEY
-from captcha.widgets import ReCaptchaV2Checkbox, ReCaptchaBase
+from captcha.widgets import ReCaptchaV2Checkbox, ReCaptchaBase, ReCaptchaV3
logger = logging.getLogger(__name__)
@@ -91,3 +91,28 @@ def validate(self, value):
self.error_messages["captcha_invalid"],
code="captcha_invalid"
)
+
+ required_score = self.widget.attrs.get("required_score")
+ if required_score:
+ # Our score values need to be floats, as that is the expected
+ # response from the Google endpoint. Rather than ensure that on
+ # the widget, we do it on the field to better support user
+ # subclassing of the widgets.
+ required_score = float(required_score)
+
+ # If a score was expected but non was returned, default to a 0,
+ # which is the lowest score that it can return. This is to do our
+ # best to assure a failure here, we can not assume that a form
+ # that needed the threshold should be valid if we didn't get a
+ # value back.
+ score = float(check_captcha.extra_data.get("score", 0))
+
+ if required_score > score:
+ logger.error(
+ "ReCAPTCHA validation failed due to its score of %s"
+ " being lower than the required amount." % score
+ )
+ raise ValidationError(
+ self.error_messages["captcha_invalid"],
+ code="captcha_invalid"
+ )
diff --git a/captcha/tests/test_fields.py b/captcha/tests/test_fields.py
index 7888778f..245e5be3 100644
--- a/captcha/tests/test_fields.py
+++ b/captcha/tests/test_fields.py
@@ -321,3 +321,74 @@ class VThreeDomainForm(forms.Form):
'?render=pubkey">',
html
)
+
+ @patch("captcha.fields.client.submit")
+ def test_client_success_response_v3(self, mocked_submit):
+ class VThreeDomainForm(forms.Form):
+ captcha = fields.ReCaptchaField(
+ widget=widgets.ReCaptchaV3(attrs={'required_score': 0.8})
+ )
+ mocked_submit.return_value = RecaptchaResponse(
+ is_valid=True,
+ extra_data={'score': 0.9}
+ )
+ form_params = {"captcha": "PASSED"}
+ form = VThreeDomainForm(form_params)
+ self.assertTrue(form.is_valid())
+
+ @patch("captcha.fields.client.submit")
+ def test_client_failure_response_v3(self, mocked_submit):
+ class VThreeDomainForm(forms.Form):
+ captcha = fields.ReCaptchaField(
+ widget=widgets.ReCaptchaV3(attrs={'required_score': 0.8})
+ )
+ mocked_submit.return_value = RecaptchaResponse(
+ is_valid=True,
+ extra_data={'score': 0.1}
+ )
+ form_params = {"captcha": "PASSED"}
+ form = VThreeDomainForm(form_params)
+ self.assertFalse(form.is_valid())
+
+ @patch("captcha.fields.client.submit")
+ def test_client_empty_score_threshold_v3(self, mocked_submit):
+ class VThreeDomainForm(forms.Form):
+ captcha = fields.ReCaptchaField(
+ widget=widgets.ReCaptchaV3()
+ )
+ mocked_submit.return_value = RecaptchaResponse(
+ is_valid=True,
+ extra_data={'score': 0.1}
+ )
+ form_params = {"captcha": "PASSED"}
+ form = VThreeDomainForm(form_params)
+ self.assertTrue(form.is_valid())
+
+ @patch("captcha.fields.client.submit")
+ @override_settings(RECAPTCHA_REQUIRED_SCORE=0.0)
+ def test_required_score_human_setting(self, mocked_submit):
+ class VThreeDomainForm(forms.Form):
+ captcha = fields.ReCaptchaField(
+ widget=widgets.ReCaptchaV3()
+ )
+ mocked_submit.return_value = RecaptchaResponse(
+ is_valid=True,
+ extra_data={'score': 0.85}
+ )
+ form_params = {"captcha": "PASSED"}
+ form = VThreeDomainForm(form_params)
+ self.assertTrue(form.is_valid())
+
+ @patch("captcha.fields.client.submit")
+ @override_settings(RECAPTCHA_REQUIRED_SCORE=0.85)
+ def test_required_score_bot_setting(self, mocked_submit):
+ class VThreeDomainForm(forms.Form):
+ captcha = fields.ReCaptchaField(
+ widget=widgets.ReCaptchaV3()
+ )
+ mocked_submit.return_value = RecaptchaResponse(
+ is_valid=True,
+ extra_data={'score': 0})
+ form_params = {"captcha": "PASSED"}
+ form = VThreeDomainForm(form_params)
+ self.assertFalse(form.is_valid())
diff --git a/captcha/widgets.py b/captcha/widgets.py
index e67c1027..d75f3314 100644
--- a/captcha/widgets.py
+++ b/captcha/widgets.py
@@ -72,6 +72,15 @@ def build_attrs(self, base_attrs, extra_attrs=None):
class ReCaptchaV3(ReCaptchaBase):
template_name = "captcha/widget_v3.html"
+ def __init__(self, api_params=None, *args, **kwargs):
+ super(ReCaptchaV3, self).__init__(
+ api_params=api_params, *args, **kwargs
+ )
+ if not self.attrs.get("required_score", None):
+ self.attrs["required_score"] = getattr(
+ settings, "RECAPTCHA_REQUIRED_SCORE", None
+ )
+
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super(ReCaptchaV3, self).build_attrs(
base_attrs, extra_attrs
diff --git a/setup.py b/setup.py
index ccf584cd..19ab5201 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,7 @@
setup(
name='django-recaptcha',
- version='2.0.4',
+ version='2.0.5',
description='Django recaptcha form field/widget app.',
long_description=long_desc,
author='Praekelt Consulting',