From 5b773470663d98b3d52c90851988563e08394780 Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 27 Mar 2026 17:32:11 +0100 Subject: [PATCH] feat (auth): implement Cloudflare Turnstile captcha (#5052) --- backend/src/baserow/api/captcha/__init__.py | 0 backend/src/baserow/api/captcha/errors.py | 7 + backend/src/baserow/api/sessions.py | 10 ++ backend/src/baserow/api/user/serializers.py | 6 + backend/src/baserow/api/user/views.py | 12 ++ backend/src/baserow/config/settings/base.py | 11 ++ backend/src/baserow/config/settings/test.py | 1 + backend/src/baserow/core/apps.py | 12 ++ backend/src/baserow/core/captcha/__init__.py | 0 .../src/baserow/core/captcha/exceptions.py | 9 ++ backend/src/baserow/core/captcha/handler.py | 101 +++++++++++++ .../baserow/core/captcha/provider_types.py | 52 +++++++ .../src/baserow/core/captcha/registries.py | 55 +++++++ .../core/captcha/settings_data_type.py | 32 ++++ .../baserow/api/users/test_user_views.py | 134 +++++++++++++++++ .../tests/baserow/core/captcha/__init__.py | 0 .../core/captcha/test_captcha_handler.py | 129 ++++++++++++++++ .../core/captcha/test_cloudflare_turnstile.py | 142 ++++++++++++++++++ ...troduced_cloudflare_turnstile_captcha.json | 9 ++ docker-compose.yml | 4 + docs/installation/configuration.md | 9 ++ .../modules/core/captchaProviderTypes.js | 42 ++++++ .../core/components/auth/CaptchaWidget.vue | 57 +++++++ .../auth/CloudflareTurnstileWidget.vue | 103 +++++++++++++ .../core/components/auth/PasswordRegister.vue | 20 ++- web-frontend/modules/core/locales/en.json | 2 + web-frontend/modules/core/plugin.js | 7 + web-frontend/modules/core/services/auth.js | 7 +- web-frontend/modules/core/store/auth.js | 4 +- 29 files changed, 974 insertions(+), 3 deletions(-) create mode 100644 backend/src/baserow/api/captcha/__init__.py create mode 100644 backend/src/baserow/api/captcha/errors.py create mode 100644 backend/src/baserow/core/captcha/__init__.py create mode 100644 backend/src/baserow/core/captcha/exceptions.py create mode 100644 backend/src/baserow/core/captcha/handler.py create mode 100644 backend/src/baserow/core/captcha/provider_types.py create mode 100644 backend/src/baserow/core/captcha/registries.py create mode 100644 backend/src/baserow/core/captcha/settings_data_type.py create mode 100644 backend/tests/baserow/core/captcha/__init__.py create mode 100644 backend/tests/baserow/core/captcha/test_captcha_handler.py create mode 100644 backend/tests/baserow/core/captcha/test_cloudflare_turnstile.py create mode 100644 changelog/entries/unreleased/feature/introduced_cloudflare_turnstile_captcha.json create mode 100644 web-frontend/modules/core/captchaProviderTypes.js create mode 100644 web-frontend/modules/core/components/auth/CaptchaWidget.vue create mode 100644 web-frontend/modules/core/components/auth/CloudflareTurnstileWidget.vue diff --git a/backend/src/baserow/api/captcha/__init__.py b/backend/src/baserow/api/captcha/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/api/captcha/errors.py b/backend/src/baserow/api/captcha/errors.py new file mode 100644 index 0000000000..8a58bd0ae4 --- /dev/null +++ b/backend/src/baserow/api/captcha/errors.py @@ -0,0 +1,7 @@ +from rest_framework.status import HTTP_400_BAD_REQUEST + +ERROR_CAPTCHA_VERIFICATION_FAILED = ( + "ERROR_CAPTCHA_VERIFICATION_FAILED", + HTTP_400_BAD_REQUEST, + "Captcha verification failed. Please try again.", +) diff --git a/backend/src/baserow/api/sessions.py b/backend/src/baserow/api/sessions.py index 43fc4e8feb..a92d64ea9b 100644 --- a/backend/src/baserow/api/sessions.py +++ b/backend/src/baserow/api/sessions.py @@ -96,6 +96,16 @@ def _set_user_websocket_id(user, websocket_id): def get_user_remote_ip_address_from_request(request): + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + # X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2. + # The first one is the original client IP. + return x_forwarded_for.split(",")[0].strip() + + x_real_ip = request.META.get("HTTP_X_REAL_IP") + if x_real_ip: + return x_real_ip.strip() + return request.META.get("REMOTE_ADDR") diff --git a/backend/src/baserow/api/user/serializers.py b/backend/src/baserow/api/user/serializers.py index 70f562a669..cf1ae8a3d2 100755 --- a/backend/src/baserow/api/user/serializers.py +++ b/backend/src/baserow/api/user/serializers.py @@ -169,6 +169,12 @@ class RegisterSerializer(serializers.Serializer): "account. This only works if the `workspace_invitation_token` param is not " "provided.", ) + captcha_token = serializers.CharField( + required=False, + default="", + allow_blank=True, + help_text="The captcha response token, required when captcha is enabled.", + ) class AccountSerializer(serializers.Serializer): diff --git a/backend/src/baserow/api/user/views.py b/backend/src/baserow/api/user/views.py index e4f6a48a53..363ae0e283 100755 --- a/backend/src/baserow/api/user/views.py +++ b/backend/src/baserow/api/user/views.py @@ -25,6 +25,7 @@ UndoRedoResponseSerializer, get_undo_request_serializer, ) +from baserow.api.captcha.errors import ERROR_CAPTCHA_VERIFICATION_FAILED from baserow.api.decorators import map_exceptions, validate_body from baserow.api.errors import ( BAD_TOKEN_SIGNATURE, @@ -34,6 +35,7 @@ from baserow.api.schemas import get_error_schema from baserow.api.sessions import ( get_untrusted_client_session_id, + get_user_remote_ip_address_from_request, set_user_session_data_from_request, ) from baserow.api.user.registries import user_data_registry @@ -48,6 +50,8 @@ EmailVerificationRequired, ) from baserow.core.auth_provider.handler import PasswordProviderHandler +from baserow.core.captcha.exceptions import CaptchaVerificationFailed +from baserow.core.captcha.handler import CaptchaHandler from baserow.core.exceptions import ( BaseURLHostnameNotAllowed, LockConflict, @@ -286,6 +290,7 @@ class UserView(APIView): "ERROR_GROUP_INVITATION_DOES_NOT_EXIST", "ERROR_REQUEST_BODY_VALIDATION", "BAD_TOKEN_SIGNATURE", + "ERROR_CAPTCHA_VERIFICATION_FAILED", ] ), 404: get_error_schema(["ERROR_GROUP_INVITATION_DOES_NOT_EXIST"]), @@ -302,6 +307,7 @@ class UserView(APIView): WorkspaceInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH, DisabledSignupError: ERROR_DISABLED_SIGNUP, AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED, + CaptchaVerificationFailed: ERROR_CAPTCHA_VERIFICATION_FAILED, } ) @validate_body(RegisterSerializer) @@ -311,6 +317,12 @@ def post(self, request, data): if not PasswordProviderHandler.get().enabled: raise AuthProviderDisabled() + CaptchaHandler.validate_if_required( + "signup", + data.get("captcha_token"), + get_user_remote_ip_address_from_request(request), + ) + template = ( Template.objects.get(pk=data["template_id"]) if data["template_id"] diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index cf55b38195..7c49ba0218 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -1520,3 +1520,14 @@ def install_cachalot(): try_float(os.getenv("BASEROW_DEADLOCK_INITIAL_BACKOFF"), 0.2), 0.1, ) + +# Set to "all" to enable captcha everywhere, or comma-separated contexts like +# "signup,invitations" to enable only in specific places. +BASEROW_ENABLE_CAPTCHA = os.getenv("BASEROW_ENABLE_CAPTCHA", "") +BASEROW_CAPTCHA_PROVIDER = os.getenv("BASEROW_CAPTCHA_PROVIDER", "cloudflare_turnstile") +BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY = os.getenv( + "BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY", "" +) +BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY = os.getenv( + "BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY", "" +) diff --git a/backend/src/baserow/config/settings/test.py b/backend/src/baserow/config/settings/test.py index d6449937e8..661f447df4 100644 --- a/backend/src/baserow/config/settings/test.py +++ b/backend/src/baserow/config/settings/test.py @@ -150,6 +150,7 @@ def getenv_for_tests(key: str, default: str = "") -> str: install_cachalot() +BASEROW_ENABLE_CAPTCHA = "" try: from .local_test import * # noqa: F403, F401 diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py index 3eaca57c25..bfdf2e9cff 100755 --- a/backend/src/baserow/core/apps.py +++ b/backend/src/baserow/core/apps.py @@ -423,6 +423,18 @@ def ready(self): two_factor_auth_type_registry.register(TOTPAuthProviderType()) + from baserow.core.captcha.provider_types import ( + CloudflareTurnstileCaptchaProviderType, + ) + from baserow.core.captcha.registries import captcha_provider_registry + + captcha_provider_registry.register(CloudflareTurnstileCaptchaProviderType()) + + from baserow.api.settings.registries import settings_data_registry + from baserow.core.captcha.settings_data_type import CaptchaSettingsDataType + + settings_data_registry.register(CaptchaSettingsDataType()) + import baserow.core.notifications.receivers # noqa: F401 import baserow.core.notifications.tasks # noqa: F401 from baserow.core.notification_types import ( diff --git a/backend/src/baserow/core/captcha/__init__.py b/backend/src/baserow/core/captcha/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/core/captcha/exceptions.py b/backend/src/baserow/core/captcha/exceptions.py new file mode 100644 index 0000000000..cc8eb308cc --- /dev/null +++ b/backend/src/baserow/core/captcha/exceptions.py @@ -0,0 +1,9 @@ +from baserow.core.exceptions import InstanceTypeDoesNotExist + + +class CaptchaVerificationFailed(Exception): + """Raised when captcha token validation fails.""" + + +class CaptchaProviderDoesNotExist(InstanceTypeDoesNotExist): + """Raised when the requested captcha provider type does not exist.""" diff --git a/backend/src/baserow/core/captcha/handler.py b/backend/src/baserow/core/captcha/handler.py new file mode 100644 index 0000000000..a4a4bb340c --- /dev/null +++ b/backend/src/baserow/core/captcha/handler.py @@ -0,0 +1,101 @@ +from typing import Optional + +from django.conf import settings + +from baserow.core.captcha.exceptions import CaptchaVerificationFailed +from baserow.core.captcha.registries import ( + CaptchaProviderType, + captcha_provider_registry, +) + + +class CaptchaHandler: + @staticmethod + def is_enabled() -> bool: + """ + Return True if the captcha is enabled in the instance. + + :return: True if captcha is enabled in this instance. + """ + + return bool(getattr(settings, "BASEROW_ENABLE_CAPTCHA", "")) + + @staticmethod + def is_captcha_enabled_for(context: str) -> bool: + """ + Returns True if captcha is enabled for the given context. + + :param context: The context to check, e.g. "signup" or "invitations". + :return: True if captcha should be required for this context. + """ + + if not CaptchaHandler.is_enabled(): + return False + + enabled = getattr(settings, "BASEROW_ENABLE_CAPTCHA", "") + enabled = enabled.strip().lower() + if enabled == "all": + return True + + enabled_contexts = [c.strip().lower() for c in enabled.split(",") if c.strip()] + return context.lower() in enabled_contexts + + @staticmethod + def get_active_provider() -> CaptchaProviderType: + """ + Returns the active captcha provider based on the BASEROW_CAPTCHA_PROVIDER + setting. + + :raises CaptchaProviderDoesNotExist: If the configured provider type is not + registered. + :raises RuntimeError: If the provider is not properly configured (missing + env vars). + """ + + provider_type = getattr(settings, "BASEROW_CAPTCHA_PROVIDER", "") + if not provider_type: + raise RuntimeError( + "BASEROW_ENABLE_CAPTCHA is set but BASEROW_CAPTCHA_PROVIDER is empty. " + "Please configure a captcha provider." + ) + + provider = captcha_provider_registry.get(provider_type) + + if not provider.is_configured(): + raise RuntimeError( + f"Captcha provider '{provider_type}' is enabled but not properly " + f"configured. Please check that all required environment variables " + f"are set." + ) + + return provider + + @staticmethod + def validate_if_required( + context: str, + token: str, + remote_ip: Optional[str] = None, + ) -> None: + """ + Validates the captcha token if captcha is enabled for the given context. + Does nothing if captcha is not enabled. + + :param context: The context to check, e.g. "signup". + :param token: The captcha response token from the client. + :param remote_ip: Optional IP address of the client. + :raises CaptchaVerificationFailed: If captcha is required and the token is + invalid or missing. + :raises RuntimeError: If captcha is enabled but not properly configured. + """ + + if not CaptchaHandler.is_captcha_enabled_for(context): + return + + provider = CaptchaHandler.get_active_provider() + + if not token: + raise CaptchaVerificationFailed( + "Captcha token is required but was not provided." + ) + + provider.validate_token(token, remote_ip) diff --git a/backend/src/baserow/core/captcha/provider_types.py b/backend/src/baserow/core/captcha/provider_types.py new file mode 100644 index 0000000000..c489eb145c --- /dev/null +++ b/backend/src/baserow/core/captcha/provider_types.py @@ -0,0 +1,52 @@ +import logging +from typing import Optional + +from django.conf import settings + +import requests + +from baserow.core.captcha.exceptions import CaptchaVerificationFailed +from baserow.core.captcha.registries import CaptchaProviderType + +logger = logging.getLogger(__name__) + +TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + + +class CloudflareTurnstileCaptchaProviderType(CaptchaProviderType): + type = "cloudflare_turnstile" + + def is_configured(self) -> bool: + return bool( + getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY", "") + and getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY", "") + ) + + def get_frontend_config(self) -> dict: + return { + "site_key": getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY", ""), + } + + def validate_token(self, token: str, remote_ip: Optional[str] = None) -> bool: + secret_key = getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY", "") + + payload = { + "secret": secret_key, + "response": token, + } + + if remote_ip: + payload["remoteip"] = remote_ip + + # Let network errors and unexpected HTTP errors bubble up uncaught so they + # surface in Sentry. Only expected captcha failures are caught below. + response = requests.post(TURNSTILE_VERIFY_URL, data=payload, timeout=10) + response.raise_for_status() + result = response.json() + + if not result.get("success"): + error_codes = result.get("error-codes", []) + logger.warning("Cloudflare Turnstile verification failed: %s", error_codes) + raise CaptchaVerificationFailed("Captcha verification failed.") + + return True diff --git a/backend/src/baserow/core/captcha/registries.py b/backend/src/baserow/core/captcha/registries.py new file mode 100644 index 0000000000..a78cf21fb5 --- /dev/null +++ b/backend/src/baserow/core/captcha/registries.py @@ -0,0 +1,55 @@ +from typing import Optional + +from baserow.core.captcha.exceptions import CaptchaProviderDoesNotExist +from baserow.core.registry import Instance, Registry + + +class CaptchaProviderType(Instance): + """ + Base class for captcha provider types. Each captcha provider (e.g., Cloudflare + Turnstile, reCAPTCHA) should extend this class. + """ + + def is_configured(self) -> bool: + """ + Returns True if all required configuration (env vars, etc.) is present + for this provider to function. + """ + + raise NotImplementedError( + "The is_configured method must be implemented by the captcha provider." + ) + + def get_frontend_config(self) -> dict: + """ + Returns a dict of public configuration to send to the frontend. This must + not include secrets. For example, a site key but never a secret key. + """ + + raise NotImplementedError( + "The get_frontend_config method must be implemented by the captcha provider." + ) + + def validate_token(self, token: str, remote_ip: Optional[str] = None) -> bool: + """ + Validates the captcha response token by calling the provider's verification + API. Should raise CaptchaVerificationFailed on failure. + + :param token: The captcha response token from the client. + :param remote_ip: Optional IP address of the client. + :return: True if validation succeeds. + :raises CaptchaVerificationFailed: If the token is invalid or verification + fails. + """ + + raise NotImplementedError( + "The validate_token method must be implemented by the captcha provider." + ) + + +class CaptchaProviderRegistry(Registry[CaptchaProviderType]): + name = "captcha_provider" + does_not_exist_exception_class = CaptchaProviderDoesNotExist + + +captcha_provider_registry: CaptchaProviderRegistry = CaptchaProviderRegistry() diff --git a/backend/src/baserow/core/captcha/settings_data_type.py b/backend/src/baserow/core/captcha/settings_data_type.py new file mode 100644 index 0000000000..2c26f807fb --- /dev/null +++ b/backend/src/baserow/core/captcha/settings_data_type.py @@ -0,0 +1,32 @@ +from django.http import HttpRequest + +from baserow.api.settings.registries import SettingsDataType +from baserow.core.captcha.handler import CaptchaHandler + + +class CaptchaSettingsDataType(SettingsDataType): + type = "captcha" + + def get_settings_data(self, request: HttpRequest) -> dict: + disabled = {"enabled": False} + + if not CaptchaHandler.is_enabled(): + return disabled + + provider = CaptchaHandler.get_active_provider() + if provider is None: + return disabled + + enabled_contexts = [ + ctx for ctx in ["signup"] if CaptchaHandler.is_captcha_enabled_for(ctx) + ] + + if not enabled_contexts: + return disabled + + return { + "enabled": True, + "provider": provider.type, + "enabled_contexts": enabled_contexts, + **provider.get_frontend_config(), + } diff --git a/backend/tests/baserow/api/users/test_user_views.py b/backend/tests/baserow/api/users/test_user_views.py index 0e8ab2b532..e6b3f9c520 100755 --- a/backend/tests/baserow/api/users/test_user_views.py +++ b/backend/tests/baserow/api/users/test_user_views.py @@ -4,8 +4,10 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.shortcuts import reverse +from django.test.utils import override_settings import pytest +import responses from freezegun import freeze_time from rest_framework.status import ( HTTP_200_OK, @@ -1500,3 +1502,135 @@ def test_change_email_same_as_current(data_fixture, client): user.refresh_from_db() assert user.email == "test@test.nl" + + +@pytest.mark.django_db +@override_settings(BASEROW_ENABLE_CAPTCHA="") +def test_create_user_without_captcha_when_disabled(client, data_fixture): + data_fixture.create_password_provider() + response = client.post( + reverse("api:user:index"), + { + "name": "Test", + "email": "captcha_disabled@test.nl", + "password": "thisIsAValidPassword", + }, + format="json", + ) + assert response.status_code == HTTP_200_OK + assert User.objects.filter(email="captcha_disabled@test.nl").exists() + + +@pytest.mark.django_db +@override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +def test_create_user_captcha_required_when_enabled(client, data_fixture): + data_fixture.create_password_provider() + response = client.post( + reverse("api:user:index"), + { + "name": "Test", + "email": "captcha_required@test.nl", + "password": "thisIsAValidPassword", + }, + format="json", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_CAPTCHA_VERIFICATION_FAILED" + assert not User.objects.filter(email="captcha_required@test.nl").exists() + + +@pytest.mark.django_db +@responses.activate +@override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +def test_create_user_captcha_valid_token(client, data_fixture): + from baserow.core.captcha.provider_types import TURNSTILE_VERIFY_URL + + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": True}, + status=200, + ) + + data_fixture.create_password_provider() + response = client.post( + reverse("api:user:index"), + { + "name": "Test", + "email": "captcha_valid@test.nl", + "password": "thisIsAValidPassword", + "captcha_token": "valid-token", + }, + format="json", + ) + assert response.status_code == HTTP_200_OK + assert User.objects.filter(email="captcha_valid@test.nl").exists() + assert len(responses.calls) == 1 + + +@pytest.mark.django_db +@responses.activate +@override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +def test_create_user_captcha_invalid_token(client, data_fixture): + from baserow.core.captcha.provider_types import TURNSTILE_VERIFY_URL + + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": False, "error-codes": ["invalid-input-response"]}, + status=200, + ) + + data_fixture.create_password_provider() + response = client.post( + reverse("api:user:index"), + { + "name": "Test", + "email": "captcha_invalid@test.nl", + "password": "thisIsAValidPassword", + "captcha_token": "invalid-token", + }, + format="json", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_CAPTCHA_VERIFICATION_FAILED" + assert not User.objects.filter(email="captcha_invalid@test.nl").exists() + + +@pytest.mark.django_db +@override_settings( + BASEROW_ENABLE_CAPTCHA="invitations", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +def test_create_user_captcha_only_for_configured_context(client, data_fixture): + data_fixture.create_password_provider() + # Captcha is enabled for "invitations" only, not "signup", + # so signup should work without a captcha token. + response = client.post( + reverse("api:user:index"), + { + "name": "Test", + "email": "captcha_context@test.nl", + "password": "thisIsAValidPassword", + }, + format="json", + ) + assert response.status_code == HTTP_200_OK + assert User.objects.filter(email="captcha_context@test.nl").exists() diff --git a/backend/tests/baserow/core/captcha/__init__.py b/backend/tests/baserow/core/captcha/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/baserow/core/captcha/test_captcha_handler.py b/backend/tests/baserow/core/captcha/test_captcha_handler.py new file mode 100644 index 0000000000..c8925bed3b --- /dev/null +++ b/backend/tests/baserow/core/captcha/test_captcha_handler.py @@ -0,0 +1,129 @@ +from django.test.utils import override_settings + +import pytest +import responses + +from baserow.core.captcha.exceptions import CaptchaVerificationFailed +from baserow.core.captcha.handler import CaptchaHandler +from baserow.core.captcha.provider_types import TURNSTILE_VERIFY_URL + + +def test_is_captcha_enabled_for_all(): + with override_settings(BASEROW_ENABLE_CAPTCHA="all"): + assert CaptchaHandler.is_captcha_enabled_for("signup") is True + assert CaptchaHandler.is_captcha_enabled_for("invitations") is True + assert CaptchaHandler.is_captcha_enabled_for("anything") is True + + +def test_is_captcha_enabled_for_specific(): + with override_settings(BASEROW_ENABLE_CAPTCHA="signup"): + assert CaptchaHandler.is_captcha_enabled_for("signup") is True + assert CaptchaHandler.is_captcha_enabled_for("invitations") is False + + +def test_is_captcha_enabled_for_multiple(): + with override_settings(BASEROW_ENABLE_CAPTCHA="signup,invitations"): + assert CaptchaHandler.is_captcha_enabled_for("signup") is True + assert CaptchaHandler.is_captcha_enabled_for("invitations") is True + assert CaptchaHandler.is_captcha_enabled_for("other") is False + + +def test_is_captcha_enabled_case_insensitive(): + with override_settings(BASEROW_ENABLE_CAPTCHA="Signup,INVITATIONS"): + assert CaptchaHandler.is_captcha_enabled_for("signup") is True + assert CaptchaHandler.is_captcha_enabled_for("Signup") is True + assert CaptchaHandler.is_captcha_enabled_for("invitations") is True + assert CaptchaHandler.is_captcha_enabled_for("INVITATIONS") is True + + with override_settings(BASEROW_ENABLE_CAPTCHA="ALL"): + assert CaptchaHandler.is_captcha_enabled_for("signup") is True + + +def test_is_captcha_disabled(): + with override_settings(BASEROW_ENABLE_CAPTCHA=""): + assert CaptchaHandler.is_captcha_enabled_for("signup") is False + assert CaptchaHandler.is_captcha_enabled_for("invitations") is False + + +def test_validate_if_required_skips_when_disabled(): + with override_settings(BASEROW_ENABLE_CAPTCHA=""): + CaptchaHandler.validate_if_required("signup", "") + CaptchaHandler.validate_if_required("signup", "some-token") + + +@pytest.mark.django_db +@responses.activate +def test_validate_if_required_raises_when_token_missing(): + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": True}, + status=200, + ) + + with override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", + ): + with pytest.raises(CaptchaVerificationFailed): + CaptchaHandler.validate_if_required("signup", "") + + # No HTTP calls should have been made since token was empty + assert len(responses.calls) == 0 + + +@pytest.mark.django_db +@responses.activate +def test_validate_if_required_calls_provider(): + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": True}, + status=200, + ) + + with override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", + ): + CaptchaHandler.validate_if_required("signup", "valid-token", "1.2.3.4") + + assert len(responses.calls) == 1 + + +@pytest.mark.django_db +def test_validate_if_required_skips_for_unconfigured_context(): + with override_settings( + BASEROW_ENABLE_CAPTCHA="invitations", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", + ): + # Should not raise since "signup" is not in the enabled contexts + CaptchaHandler.validate_if_required("signup", "") + + +@pytest.mark.django_db +def test_get_active_provider_raises_when_provider_not_configured(): + with override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="cloudflare_turnstile", + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="", + ): + with pytest.raises(RuntimeError, match="not properly configured"): + CaptchaHandler.get_active_provider() + + +@pytest.mark.django_db +def test_get_active_provider_raises_when_provider_type_empty(): + with override_settings( + BASEROW_ENABLE_CAPTCHA="all", + BASEROW_CAPTCHA_PROVIDER="", + ): + with pytest.raises(RuntimeError, match="BASEROW_CAPTCHA_PROVIDER is empty"): + CaptchaHandler.get_active_provider() diff --git a/backend/tests/baserow/core/captcha/test_cloudflare_turnstile.py b/backend/tests/baserow/core/captcha/test_cloudflare_turnstile.py new file mode 100644 index 0000000000..d7692afeb7 --- /dev/null +++ b/backend/tests/baserow/core/captcha/test_cloudflare_turnstile.py @@ -0,0 +1,142 @@ +from django.test.utils import override_settings + +import pytest +import responses + +from baserow.core.captcha.exceptions import CaptchaVerificationFailed +from baserow.core.captcha.provider_types import ( + TURNSTILE_VERIFY_URL, + CloudflareTurnstileCaptchaProviderType, +) + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +def test_is_configured(): + provider = CloudflareTurnstileCaptchaProviderType() + assert provider.is_configured() is True + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="", +) +def test_is_not_configured_when_keys_empty(): + provider = CloudflareTurnstileCaptchaProviderType() + assert provider.is_configured() is False + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="", +) +def test_is_not_configured_when_secret_missing(): + provider = CloudflareTurnstileCaptchaProviderType() + assert provider.is_configured() is False + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY="test-site-key", + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +def test_get_frontend_config(): + provider = CloudflareTurnstileCaptchaProviderType() + config = provider.get_frontend_config() + assert config == {"site_key": "test-site-key"} + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +@responses.activate +def test_validate_token_success(): + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": True}, + status=200, + ) + + provider = CloudflareTurnstileCaptchaProviderType() + result = provider.validate_token("valid-token", "1.2.3.4") + assert result is True + + assert len(responses.calls) == 1 + assert "secret=test-secret-key" in responses.calls[0].request.body + assert "response=valid-token" in responses.calls[0].request.body + assert "remoteip=1.2.3.4" in responses.calls[0].request.body + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +@responses.activate +def test_validate_token_without_remote_ip(): + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": True}, + status=200, + ) + + provider = CloudflareTurnstileCaptchaProviderType() + provider.validate_token("valid-token") + + assert len(responses.calls) == 1 + assert "remoteip" not in responses.calls[0].request.body + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +@responses.activate +def test_validate_token_failure(): + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"success": False, "error-codes": ["invalid-input-response"]}, + status=200, + ) + + provider = CloudflareTurnstileCaptchaProviderType() + with pytest.raises(CaptchaVerificationFailed): + provider.validate_token("invalid-token") + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +@responses.activate +def test_validate_token_network_error(): + """Network errors should bubble up uncaught so they surface in Sentry.""" + + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + body=ConnectionError("Connection refused"), + ) + + provider = CloudflareTurnstileCaptchaProviderType() + with pytest.raises(ConnectionError): + provider.validate_token("some-token") + + +@override_settings( + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY="test-secret-key", +) +@responses.activate +def test_validate_token_http_500_error(): + """Unexpected HTTP errors should bubble up uncaught so they surface in Sentry.""" + + responses.add( + responses.POST, + TURNSTILE_VERIFY_URL, + json={"error": "internal server error"}, + status=500, + ) + + provider = CloudflareTurnstileCaptchaProviderType() + with pytest.raises(Exception): + provider.validate_token("some-token") diff --git a/changelog/entries/unreleased/feature/introduced_cloudflare_turnstile_captcha.json b/changelog/entries/unreleased/feature/introduced_cloudflare_turnstile_captcha.json new file mode 100644 index 0000000000..b2913a95bd --- /dev/null +++ b/changelog/entries/unreleased/feature/introduced_cloudflare_turnstile_captcha.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Introduced optional Cloudflare Turnstile captcha during signup.", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-02-26" +} diff --git a/docker-compose.yml b/docker-compose.yml index a08d0a1b0d..917755e525 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -247,6 +247,10 @@ x-backend-variables: BASEROW_EMBEDDINGS_API_URL: BASEROW_OAUTH_BACKEND_URL: BASEROW_TOTP_ISSUER_NAME: + BASEROW_ENABLE_CAPTCHA: + BASEROW_CAPTCHA_PROVIDER: + BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY: + BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY: services: # A caddy reverse proxy sitting in-front of all the services. Responsible for routing diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index fc65e449c7..c981f67a96 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -345,6 +345,15 @@ domain than your Baserow, you need to make sure CORS is configured correctly. |--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------| | BASEROW\_ALLOW\_MULTIPLE\_SSO\_PROVIDERS\_FOR\_SAME\_ACCOUNT | By default Baserow will show a "please use the provider that you originally signed up with" error if you attempt to login to an email which has been already registered in your Baserow server with a different SSO provider/authentication method. This is to increase the security of your Baserow server. However, if you wish to allow a user who for example signed up initially using email and password to now login using a new SSO provider, and are comfortable with the increased risk of allowing this, then set this environment variable to any non empty value to disable this check. When turned on this environment variable will allow a Baserow account to be logged into by any available authentication method and not just the first one that particular user signed up with. If you later turn off this environment variable by removing it, users who have previously logged into their account with multiple different providers will be able to continue to use all of those providers to login, however any new users will be forced to use the first provider they signed up with. | | +### Captcha Configuration + +| Name | Description | Defaults | +|------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------| +| BASEROW\_ENABLE\_CAPTCHA | Set to `all` to enable captcha verification on all supported pages (currently only signup). You can also provide a comma-separated list of specific contexts like `signup,invitations` to enable captcha only in those places. Leave empty to disable captcha entirely. | | +| BASEROW\_CAPTCHA\_PROVIDER | The captcha provider to use. Currently only `cloudflare_turnstile` is supported. | cloudflare\_turnstile | +| BASEROW\_CLOUDFLARE\_TURNSTILE\_SITE\_KEY | The Cloudflare Turnstile site key (public). Obtain this from the Cloudflare dashboard under Turnstile widget management. | | +| BASEROW\_CLOUDFLARE\_TURNSTILE\_SECRET\_KEY | The Cloudflare Turnstile secret key (private). Obtain this from the Cloudflare dashboard under Turnstile widget management. This key is used for server-side token validation and must be kept secret. | | + ### `baserow/baserow` Image only Configuration | Name | Description | Defaults | diff --git a/web-frontend/modules/core/captchaProviderTypes.js b/web-frontend/modules/core/captchaProviderTypes.js new file mode 100644 index 0000000000..5f4a532ae5 --- /dev/null +++ b/web-frontend/modules/core/captchaProviderTypes.js @@ -0,0 +1,42 @@ +import { Registerable } from '@baserow/modules/core/registry' +import CloudflareTurnstileWidget from '@baserow/modules/core/components/auth/CloudflareTurnstileWidget' + +/** + * A captcha provider type defines how a specific captcha service is rendered + * and interacted with on the frontend. Each provider must return a Vue component + * via getComponent() that handles rendering the captcha widget. + * + * The component must: + * - Accept a `captchaSettings` prop (Object) containing the full captcha settings + * from the backend (e.g. site_key, provider, enabled_contexts). Each provider + * component extracts the fields it needs from this object. + * - Emit a `token` event with the captcha response token (or empty string on + * expiry/error) + * - Expose a `reset()` method via defineExpose or $refs + */ +export class CaptchaProviderType extends Registerable { + /** + * Returns the Vue component that renders this captcha provider's widget. + */ + getComponent() { + throw new Error('The component of a captcha provider type must be set.') + } + + constructor(...args) { + super(...args) + + if (this.type === null) { + throw new Error('The type of a captcha provider type must be set.') + } + } +} + +export class CloudflareTurnstileCaptchaProviderType extends CaptchaProviderType { + static getType() { + return 'cloudflare_turnstile' + } + + getComponent() { + return CloudflareTurnstileWidget + } +} diff --git a/web-frontend/modules/core/components/auth/CaptchaWidget.vue b/web-frontend/modules/core/components/auth/CaptchaWidget.vue new file mode 100644 index 0000000000..8894fb6541 --- /dev/null +++ b/web-frontend/modules/core/components/auth/CaptchaWidget.vue @@ -0,0 +1,57 @@ + + + diff --git a/web-frontend/modules/core/components/auth/CloudflareTurnstileWidget.vue b/web-frontend/modules/core/components/auth/CloudflareTurnstileWidget.vue new file mode 100644 index 0000000000..e2f5f811ae --- /dev/null +++ b/web-frontend/modules/core/components/auth/CloudflareTurnstileWidget.vue @@ -0,0 +1,103 @@ + + + diff --git a/web-frontend/modules/core/components/auth/PasswordRegister.vue b/web-frontend/modules/core/components/auth/PasswordRegister.vue index fbe4f002eb..42f99613dd 100644 --- a/web-frontend/modules/core/components/auth/PasswordRegister.vue +++ b/web-frontend/modules/core/components/auth/PasswordRegister.vue @@ -93,6 +93,11 @@ :key="index" @updated-account="updatedAccount" > +