diff --git a/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py b/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py new file mode 100644 index 00000000..e1339031 --- /dev/null +++ b/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.19 on 2025-02-11 23:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("coderedcms", "0043_remove_coderedpage_struct_org_actions_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_public_key", + field=models.CharField( + blank=True, + help_text="Create this key in the Google reCAPTCHA or Google Cloud dashboard.", + max_length=255, + verbose_name="reCAPTCHA Site Key (Public)", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_secret_key", + field=models.CharField( + blank=True, + help_text="Create this key in the Google reCAPTCHA or Google Cloud dashboard.", + max_length=255, + verbose_name="reCAPTCHA Secret Key (Private)", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_threshold", + field=models.DecimalField( + decimal_places=1, + default=0.5, + help_text="reCAPTCHA v3 returns a score (0.0 is very likely a bot, 1.0 is very likely a good interaction). Reject submissions below this score (recommended 0.5).", + max_digits=2, + verbose_name="reCAPTCHA Threshold", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="spam_service", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("honeypot", "Basic - honeypot technique"), + ("recaptcha3", "reCAPTCHA v3 - Invisible (requires API key)"), + ( + "recaptcha2", + "reCAPTCHA v2 - I am not a robot (requires API key)", + ), + ], + default="honeypot", + help_text="Choose a technique or 3rd party service to help block spam submissions.", + max_length=10, + verbose_name="Spam Protection", + ), + ), + ] diff --git a/coderedcms/models/page_models.py b/coderedcms/models/page_models.py index 435c20e6..31e83dd3 100755 --- a/coderedcms/models/page_models.py +++ b/coderedcms/models/page_models.py @@ -92,6 +92,7 @@ from coderedcms.forms import CoderedSubmissionsListView from coderedcms.models.snippet_models import ClassifierTerm from coderedcms.models.wagtailsettings_models import LayoutSettings +from coderedcms.recaptcha import verify_response from coderedcms.settings import crx_settings from coderedcms.widgets import ClassifierSelectWidget @@ -1373,6 +1374,15 @@ class Meta: FieldPanel("spam_protection"), ] + @property + def get_form_id(self): + """ + Returns a suitable HTML element ID. + """ + if self.form_id: + return self.form_id.strip() + return f"crx-form-{self.pk}" + @property def form_live(self): """ @@ -1613,12 +1623,31 @@ def get_form(self, request, *args, **kwargs): return form_class(request.POST, request.FILES, *args, **form_params) return form_class(*args, **form_params) - def contains_spam(self, request): - """ - Checks to see if the spam honeypot was filled out. - """ - if request.POST.get("cr-decoy-comments", None): - return True + def contains_spam(self, request) -> bool: + """ + Checks if the site's spam_service identifies spam. + """ + if not self.spam_protection: + return False + ls = LayoutSettings.for_site(self.get_site()) + if ls.spam_service == ls.SpamService.HONEYPOT: + if request.POST.get("cr-decoy-comments", None): + return True + elif ls.spam_service == ls.SpamService.RECAPTCHA_V3: + rr = verify_response( + request.POST.get("g-recaptcha-response", ""), + ls.recaptcha_secret_key, + utils.get_ip(request), + ) + # Score ranges from 0 (likely spam) to 1 (likely good). + return rr.score < ls.recaptcha_threshold + elif ls.spam_service == ls.SpamService.RECAPTCHA_V2: + rr = verify_response( + request.POST.get("g-recaptcha-response", ""), + ls.recaptcha_secret_key, + utils.get_ip(request), + ) + return not rr.success return False def process_spam_request(self, form, request): @@ -1664,7 +1693,7 @@ def process_form_get(self, form, request): def serve(self, request, *args, **kwargs): form = self.get_form(request, page=self, user=request.user) if request.method == "POST": - if self.spam_protection and self.contains_spam(request): + if self.contains_spam(request): return self.process_spam_request(form, request) return self.process_form_post(form, request) return self.process_form_get(form, request) diff --git a/coderedcms/models/wagtailsettings_models.py b/coderedcms/models/wagtailsettings_models.py index 74a7b69d..d45d181d 100755 --- a/coderedcms/models/wagtailsettings_models.py +++ b/coderedcms/models/wagtailsettings_models.py @@ -4,6 +4,7 @@ Global project or developer settings should be defined in coderedcms.settings.py . """ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey @@ -44,6 +45,18 @@ class LayoutSettings(ClusterableModel, BaseSiteSetting): class Meta: verbose_name = _("CRX Settings") + class SpamService(models.TextChoices): + NONE = ("", _("None")) + HONEYPOT = ("honeypot", _("Basic - honeypot technique")) + RECAPTCHA_V3 = ( + "recaptcha3", + _("reCAPTCHA v3 - Invisible (requires API key)"), + ) + RECAPTCHA_V2 = ( + "recaptcha2", + _("reCAPTCHA v2 - I am not a robot (requires API key)"), + ) + logo = models.ForeignKey( get_image_model_string(), null=True, @@ -132,6 +145,43 @@ class Meta: external_new_tab = models.BooleanField( default=False, verbose_name=_("Open all external links in new tab") ) + spam_service = models.CharField( + blank=True, + max_length=10, + choices=SpamService.choices, + default=SpamService.HONEYPOT, + verbose_name=_("Spam Protection"), + help_text=_( + "Choose a technique or 3rd party service to help block spam submissions." + ), + ) + recaptcha_threshold = models.DecimalField( + default=0.5, + max_digits=2, + decimal_places=1, + verbose_name=_("reCAPTCHA Threshold"), + help_text=_( + "reCAPTCHA v3 returns a score (0.0 is very likely a bot, " + "1.0 is very likely a good interaction). " + "Reject submissions below this score (recommended 0.5)." + ), + ) + recaptcha_public_key = models.CharField( + blank=True, + max_length=255, + verbose_name=_("reCAPTCHA Site Key (Public)"), + help_text=_( + "Create this key in the Google reCAPTCHA or Google Cloud dashboard." + ), + ) + recaptcha_secret_key = models.CharField( + blank=True, + max_length=255, + verbose_name=_("reCAPTCHA Secret Key (Private)"), + help_text=_( + "Create this key in the Google reCAPTCHA or Google Cloud dashboard." + ), + ) google_maps_api_key = models.CharField( blank=True, max_length=255, @@ -189,6 +239,15 @@ class Meta: ], heading=_("General"), ), + MultiFieldPanel( + [ + FieldPanel("spam_service"), + FieldPanel("recaptcha_threshold"), + FieldPanel("recaptcha_public_key"), + FieldPanel("recaptcha_secret_key"), + ], + heading=_("Form Settings"), + ), MultiFieldPanel( [ FieldPanel("google_maps_api_key"), @@ -229,6 +288,17 @@ def __init__(self, *args, **kwargs): ) self.navbar_format = crx_settings.CRX_FRONTEND_NAVBAR_FORMAT_DEFAULT + def clean(self): + """ + Make sure reCAPTCHA keys are set if selected. + """ + if self.spam_service in [ + self.SpamService.RECAPTCHA_V3, + self.SpamService.RECAPTCHA_V2, + ] and not (self.recaptcha_public_key and self.recaptcha_secret_key): + raise ValidationError(_("API keys are required to use reCAPTCHA.")) + return super().clean() + class NavbarOrderable(Orderable, models.Model): navbar_chooser = ParentalKey( diff --git a/coderedcms/recaptcha.py b/coderedcms/recaptcha.py new file mode 100644 index 00000000..7656eeef --- /dev/null +++ b/coderedcms/recaptcha.py @@ -0,0 +1,53 @@ +import json +import logging +import typing +from urllib.parse import urlencode +from urllib.request import Request +from urllib.request import urlopen + + +logger = logging.getLogger("coderedcms") + + +class RecaptchaResponse(typing.NamedTuple): + success: bool + score: float + error_codes: typing.List[str] + original_data: typing.Dict[str, typing.Any] + + +def verify_response(recaptcha_response: str, secret_key: str, remoteip: str): + """ + Verifies a response from reCAPTCHA front-end. + + * recaptcha_response = The token from the front-end, typically + ``g-recaptcha-response`` in POST parameters. + + * secret_key = reCAPTCHA secret/private API key. + + * remoteip = The form submitter's IP address. + """ + params = { + "secret": secret_key, + "response": recaptcha_response, + "remoteip": remoteip, + } + request = Request( + url="https://www.google.com/recaptcha/api/siteverify", + method="POST", + data=bytes(urlencode(params), encoding="utf8"), + headers={ + "Content-type": "application/x-www-form-urlencoded", + }, + ) + response = urlopen(request) + data = json.loads(response.read().decode("utf8")) + response.close() + logger.info(f"reCAPTCHA response: {data}") + # Default to good (likely not spam) values if they are not present. + return RecaptchaResponse( + success=data.get("success", True), + score=data.get("score", 1.0), + error_codes=data.get("error-codes", []), + original_data=data, + ) diff --git a/coderedcms/templates/coderedcms/includes/form_button.html b/coderedcms/templates/coderedcms/includes/form_button.html new file mode 100644 index 00000000..8c2b0e62 --- /dev/null +++ b/coderedcms/templates/coderedcms/includes/form_button.html @@ -0,0 +1,50 @@ +{% load i18n %} +{% with settings.coderedcms.LayoutSettings as ls %} + +{% if page.spam_protection and ls.spam_service == ls.SpamService.HONEYPOT %} +
+{% elif page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} +