Browse Source

Implement reCAPTCHA v2 and v3 (#681)

Vince Salvino 1 month ago
parent
commit
08a8203592

+ 63 - 0
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",
+            ),
+        ),
+    ]

+ 36 - 7
coderedcms/models/page_models.py

@@ -92,6 +92,7 @@ from coderedcms.forms import CoderedFormBuilder
 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 CoderedFormMixin(models.Model):
         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 @@ class CoderedFormMixin(models.Model):
             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 @@ class CoderedFormMixin(models.Model):
     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)

+ 70 - 0
coderedcms/models/wagtailsettings_models.py

@@ -4,6 +4,7 @@ Settings are user-configurable on a per-site basis (multisite).
 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 LayoutSettings(ClusterableModel, BaseSiteSetting):
     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 LayoutSettings(ClusterableModel, BaseSiteSetting):
             ],
             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 @@ class LayoutSettings(ClusterableModel, BaseSiteSetting):
             )
             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(

+ 53 - 0
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,
+    )

+ 50 - 0
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 %}
+<div style="overflow:hidden;width:0;height:0;" aria-hidden="true">
+  <label for="cr-decoy-comments">{% trans 'Leave this blank if you are a human' %}</label>
+  <textarea rows="1" name="cr-decoy-comments" id="cr-decoy-comments"></textarea>
+</div>
+{% elif page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V2 %}
+<div class="mb-3">
+  <div class="g-recaptcha" data-sitekey="{{ ls.recaptcha_public_key }}"></div>
+</div>
+{% endif %}
+
+{% if page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V3 %}
+<input type="hidden" name="g-recaptcha-response">
+<button
+  type="button"
+  class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}"
+  onclick="recaptchaSubmit('{{ page.get_form_id }}')"
+  >
+  {{ button_text|default:page.button_text }}
+</button>
+<script>
+  function recaptchaSubmit(formId) {
+    var form = document.getElementById(formId);
+    if (form.reportValidity()) {
+      grecaptcha.ready(function() {
+        grecaptcha.execute(
+          '{{ ls.recaptcha_public_key }}',
+          {action: 'submit'}
+        ).then(function(token) {
+          // Set value for every token input on the page.
+          document.getElementsByName("g-recaptcha-response").forEach(
+            function(el) {el.value = token}
+          );
+          var form = document.getElementById(formId);
+          form.submit();
+        });
+      });
+    }
+  }
+</script>
+{% else %}
+<button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
+  {{ button_text|default:page.button_text }}
+</button>
+{% endif %}
+
+{% endwith %}

+ 0 - 5
coderedcms/templates/coderedcms/includes/form_honeypot.html

@@ -1,5 +0,0 @@
-{% load i18n %}
-<div style="overflow:hidden;width:0;height:0;" aria-hidden="true">
-  <label for="cr-decoy-comments">{% trans 'Leave this blank if you are a human' %}</label>
-  <textarea rows="1" name="cr-decoy-comments" id="cr-decoy-comments"></textarea>
-</div>

+ 10 - 0
coderedcms/templates/coderedcms/pages/base.html

@@ -187,6 +187,16 @@
     {% block struct_seo_extra %}{% endblock %}
   {% endblock %}
 
+  {% block spam_service_scripts %}
+  {% with settings.coderedcms.LayoutSettings as ls %}
+  {% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %}
+  <script src="https://www.google.com/recaptcha/api.js?render={{ ls.recaptcha_public_key }}"></script>
+  {% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %}
+  <script src="https://www.google.com/recaptcha/api.js"></script>
+  {% endif %}
+  {% endwith %}
+  {% endblock %}
+
   {% block body_tracking_scripts %}
   {% if not disable_analytics %}
   {% if settings.coderedcms.AnalyticsSettings.gtm_id %}

+ 2 - 11
coderedcms/templates/coderedcms/pages/form_page.html

@@ -4,22 +4,13 @@
 {{ block.super }}
 {% if page.form_live %}
 <div class="container my-5">
-  <form class="{{ page.form_css_class }}" id="{{ page.form_id }}" action="{% pageurl self %}" method="POST" {% if form|is_file_form %}enctype="multipart/form-data"{% endif %}>
+  <form class="{{ page.form_css_class }}" id="{{ page.get_form_id }}" action="{% pageurl self %}" method="POST" {% if form|is_file_form %}enctype="multipart/form-data"{% endif %}>
     {% csrf_token %}
     {% bootstrap_form form layout="horizontal" %}
-
-    {% block captcha %}
-    {% if page.spam_protection %}
-    {% include "coderedcms/includes/form_honeypot.html" %}
-    {% endif %}
-    {% endblock %}
-
     <div class="row">
       <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
       <div class="{{'horizontal_field_class'|bootstrap_settings}}">
-        <button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
-          {{ page.button_text }}
-        </button>
+        {% include "coderedcms/includes/form_button.html" %}
       </div>
     </div>
   </form>

+ 2 - 9
coderedcms/templates/coderedcms/pages/form_page.mini.html

@@ -2,21 +2,14 @@
 {% with page=self.page.specific %}
 {% if page.form_live %}
 {% get_pageform page request as form %}
-<form class="{{ page.form_css_class }}" id="{{ page.form_id }}" action="{% pageurl page %}" method="POST"
+<form class="{{ page.form_css_class }}" id="{{ page.get_form_id }}" action="{% pageurl page %}" method="POST"
   {% if form|is_file_form %}enctype="multipart/form-data" {% endif %}>
   {% csrf_token %}
   {% bootstrap_form form layout="horizontal" %}
-  {% block captcha %}
-  {% if page.spam_protection %}
-  {% include "coderedcms/includes/form_honeypot.html" %}
-  {% endif %}
-  {% endblock %}
   <div class="mt-5 row">
     <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
     <div class="{{'horizontal_field_class'|bootstrap_settings}}">
-      <button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
-        {{ page.button_text }}
-      </button>
+      {% include "coderedcms/includes/form_button.html" %}
     </div>
   </div>
 </form>

+ 5 - 9
coderedcms/templates/coderedcms/pages/stream_form_page.html

@@ -32,12 +32,6 @@
     {% endfor %}
     {% endblock %}
 
-    {% block captcha %}
-    {% if page.spam_protection %}
-    {% include "coderedcms/includes/form_honeypot.html" %}
-    {% endif %}
-    {% endblock %}
-
     {% block stream_form_actions %}
     <div class="form-group mt-5 row">
       <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
@@ -47,9 +41,11 @@
           Previous
         </a>
         {% endif %}
-        <button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
-          {% if steps|last == step %}{{ page.button_text }}{% else %}Next{% endif %}
-        </button>
+        {% if steps|last == step %}
+        {% include "coderedcms/includes/form_button.html" with button_text=page.button_text %}
+        {% else %}
+        {% include "coderedcms/includes/form_button.html" with button_text="Next" %}
+        {% endif %}
       </div>
     </div>
     {% endblock %}

+ 21 - 0
coderedcms/utils.py

@@ -1,5 +1,6 @@
 from django.core.exceptions import ValidationError
 from django.core.validators import URLValidator
+from django.http import HttpRequest
 from django.utils.html import mark_safe
 
 from coderedcms.settings import crx_settings
@@ -45,3 +46,23 @@ def fix_ical_datetime_format(dt_str):
         dt_str = dt_str[:-3] + dt_str[-2:]
         return dt_str
     return dt_str
+
+
+def get_ip(request: HttpRequest) -> str:
+    """
+    Get the real IP address from a request.
+    """
+    for header in [
+        "X-Forwarded-For",
+        "X-Real-Ip",
+        "X-Client-Ip",
+        "Cf-Connecting-Ip",
+        "Remote-Addr",
+    ]:
+        ip = request.headers.get(header, "")
+        # Forwarded for headers can contain multiple IPs, the first being the
+        # real IP and subsequent being proxies along the way.
+        # We only need the first.
+        if ip:
+            return ip.split(",")[0]
+    return ""

+ 4 - 3
docs/features/index.rst

@@ -6,9 +6,10 @@ Features
 
     contentblocks/index
     layoutblocks/index
-    import_export
-    mailchimp
     page_types/index
+    snippets/index
     related_pages
     searching
-    snippets/index
+    spam-protection
+    import_export
+    mailchimp

+ 56 - 0
docs/features/spam-protection.rst

@@ -0,0 +1,56 @@
+Spam Protection
+===============
+
+CRX provides features to help block spam form submissions from your site.
+
+These can be toggled in **CRX Settings > Forms**
+
+
+Basic (honeypot)
+----------------
+
+The default spam protection technique is a simple honeypot. This adds a hidden field to all forms, which some spammers might mistake for a real field. If the hidden field is filled out, the submission is rejected and a generic error message is shown to the user.
+
+While this technique is not the most effective, it can help stop some brute force spammers, and requires no additional setup or 3rd party services.
+
+
+reCAPTCHA v3 (invisible)
+------------------------
+
+Google's reCAPTCHA v3 is invisible, meaning the visitor does not see anything and does not have to solve any challenges. This works by generating a score of how likely the submission is to be spam.
+
+By default, CRX will show a generic error message for scores lower than 0.5. This can be adjusted in **CRX Settings > Forms**. If your visitors are complaining that they are getting errors when submitting forms, you may want to lower this number. If you are still receiving a lot of spam submissions, you may want to raise it.
+
+**reCAPTCHA v3 requires API keys from Google.** When creating the API keys, you must select recAPTCHA v3, then enter those keys into **CRX Settings > Forms**.
+
+`Create reCAPTCHA API keys <https://www.google.com/recaptcha/admin/create>`_
+
+
+reCAPTCHA v2 ("I am not a robot")
+---------------------------------
+
+Google's reCAPTCHA v2 shows the famous "I am not a robot" checkbox on the form. This requires the visitor to click the box. In some cases, Google might require the visitor to solve a challenge, such as selecting images or solving a puzzle.
+
+**reCAPTCHA v2 requires API keys from Google.** When creating the API keys, you must select recAPTCHA v2 "I am not a robot", then enter those keys into **CRX Settings > Forms**.
+
+`Create reCAPTCHA API keys <https://www.google.com/recaptcha/admin/create>`_
+
+
+Customizing the spam error message
+----------------------------------
+
+The spam error message can be customized on a per-page basis by overriding the ``get_spam_message`` function as so:
+
+.. code-block:: python
+
+   class FormPage(CoderedFormPage):
+       ...
+
+       def get_spam_message(self) -> str:
+           return "Error submitting form. Please try again."
+
+|
+
+.. versionadded:: 5.0
+
+   reCAPTCHA v2 and v3 support was added in CRX 5.0.