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',