diff --git a/package.json b/package.json index 490e22542f..f96789fc63 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@zxcvbn-ts/core": "^3.0.1", "@zxcvbn-ts/language-common": "^3.0.2", "@zxcvbn-ts/language-en": "^3.0.1", + "altcha": "^1.4.2", "autosize": "^5.0.2", "babel-loader": "^10.0.0", "babel-plugin-formatjs": "^10.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf5aec881e..1cc51fc321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: '@zxcvbn-ts/language-en': specifier: ^3.0.1 version: 3.0.2 + altcha: + specifier: ^1.4.2 + version: 1.4.2 autosize: specifier: ^5.0.2 version: 5.0.2 @@ -524,6 +527,9 @@ importers: packages: + '@altcha/crypto@0.0.1': + resolution: {integrity: sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -2401,6 +2407,11 @@ packages: cpu: [s390x] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.18.0': + resolution: {integrity: sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.39.0': resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==} cpu: [x64] @@ -3086,6 +3097,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + altcha@1.4.2: + resolution: {integrity: sha512-7UcWh4tHWqP5YHo+jC8vmm+sThYUi1R9qXsiiXtmdoOiimfA0LCLccSKqYSoDmYvqq+CBV79WpQVWafKQCKHmw==} + amator@1.1.0: resolution: {integrity: sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==} @@ -9487,6 +9501,8 @@ packages: snapshots: + '@altcha/crypto@0.0.1': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -11546,6 +11562,9 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.39.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.18.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.39.0': optional: true @@ -12380,6 +12399,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + altcha@1.4.2: + dependencies: + '@altcha/crypto': 0.0.1 + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.18.0 + amator@1.1.0: dependencies: bezier-easing: 2.1.0 diff --git a/pyproject.toml b/pyproject.toml index 850221aa01..5d1fabbd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,6 +194,9 @@ prod = [ # Used for monitoring memcached "prometheus-client", + + # For captchas on unauth'd pages which can generate emails + "altcha", ] docs = [ # Needed to build RTD docs diff --git a/templates/zerver/create_realm.html b/templates/zerver/create_realm.html index 27523d2fe3..179aedb7f8 100644 --- a/templates/zerver/create_realm.html +++ b/templates/zerver/create_realm.html @@ -36,6 +36,14 @@
+ {% if has_captcha %} + {% if form.captcha.errors %} + {% for error in form.captcha.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} + {{ form.captcha }} + {% endif %}
diff --git a/uv.lock b/uv.lock index 60ccf5e55b..5a26be29b1 100644 --- a/uv.lock +++ b/uv.lock @@ -145,6 +145,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 }, ] +[[package]] +name = "altcha" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/c9911a6b96e7f62d7a68eb71d2b2f53bcc6d08561a05d6024f2b40df1f20/altcha-0.1.9.tar.gz", hash = "sha256:24833796af620c0f056a59435a1d3015ee3ebf5c52de7be12c74ea95d697a20d", size = 8797 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/a8/8327d1b64e4961b2c762788efacd3a91d7f38d5bbb59a3393aa316aa95e2/altcha-0.1.9-py3-none-any.whl", hash = "sha256:b93480349859dd5207bb32da7ba43d42257e5f55577bda904b6aab1c86fe1d27", size = 7911 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1997,7 +2006,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/de/8eb6fffecd9c5f129461edcdd7e1ac944f9de15783e3d89c84ed6e0374bc/lxml-5.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa837e6ee9534de8d63bc4c1249e83882a7ac22bd24523f83fad68e6ffdf41ae", size = 5652903 }, { url = "https://files.pythonhosted.org/packages/95/79/80f4102a08495c100014593680f3f0f7bd7c1333b13520aed855fc993326/lxml-5.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:da4c9223319400b97a2acdfb10926b807e51b69eb7eb80aad4942c0516934858", size = 5491813 }, { url = "https://files.pythonhosted.org/packages/15/f5/9b1f7edf6565ee31e4300edb1bcc61eaebe50a3cff4053c0206d8dc772f2/lxml-5.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc0e9bdb3aa4d1de703a437576007d366b54f52c9897cae1a3716bb44fc1fc85", size = 5227837 }, - { url = "https://files.pythonhosted.org/packages/5c/17/c31d94364c02e3492215658917f5590c00edce8074aeb06d05b7771465d9/lxml-5.3.2-cp310-cp310-win32.whl", hash = "sha256:5f94909a1022c8ea12711db7e08752ca7cf83e5b57a87b59e8a583c5f35016ad", size = 3477533 }, + { url = "https://files.pythonhosted.org/packages/dd/53/a187c4ccfcd5fbfca01e6c96da39499d8b801ab5dcf57717db95d7a968a8/lxml-5.3.2-cp310-cp310-win32.win32.whl", hash = "sha256:dd755a0a78dd0b2c43f972e7b51a43be518ebc130c9f1a7c4480cf08b4385486", size = 3477533 }, { url = "https://files.pythonhosted.org/packages/f2/2c/397c5a9d76a7a0faf9e5b13143ae1a7e223e71d2197a45da71c21aacb3d4/lxml-5.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:d64ea1686474074b38da13ae218d9fde0d1dc6525266976808f41ac98d9d7980", size = 3805160 }, { url = "https://files.pythonhosted.org/packages/84/b8/2b727f5a90902f7cc5548349f563b60911ca05f3b92e35dfa751349f265f/lxml-5.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d61a7d0d208ace43986a92b111e035881c4ed45b1f5b7a270070acae8b0bfb4", size = 8163457 }, { url = "https://files.pythonhosted.org/packages/91/84/23135b2dc72b3440d68c8f39ace2bb00fe78e3a2255f7c74f7e76f22498e/lxml-5.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856dfd7eda0b75c29ac80a31a6411ca12209183e866c33faf46e77ace3ce8a79", size = 4433445 }, @@ -2724,6 +2733,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/bc/b7db44f5f39f9d0494071bddae6880eb645970366d0a200022a1a93d57f5/pip-25.0.1-py3-none-any.whl", hash = "sha256:c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", size = 1841526 }, ] +[[package]] +name = "pkgconfig" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/e0/e05fee8b5425db6f83237128742e7e5ef26219b687ab8f0d41ed0422125e/pkgconfig-1.5.5.tar.gz", hash = "sha256:deb4163ef11f75b520d822d9505c1f462761b4309b1bb713d08689759ea8b899", size = 6082 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/af/89487c7bbf433f4079044f3dc32f9a9f887597fe04614a37a292e373e16b/pkgconfig-1.5.5-py3-none-any.whl", hash = "sha256:d20023bbeb42ee6d428a0fac6e0904631f545985a10cdd71a20aa58bc47a4209", size = 6732 }, +] + [[package]] name = "platformdirs" version = "4.3.7" @@ -3368,6 +3386,7 @@ version = "2.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, + { name = "pkgconfig" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/88/f73dae807ec68b228fba72507105e3ba80a561dc0bade0004ce24fd118fc/pyvips-2.2.3.tar.gz", hash = "sha256:43bceced0db492654c93008246a58a508e0373ae1621116b87b322f2ac72212f", size = 56626 } @@ -5089,6 +5108,7 @@ source = { virtual = "." } dev = [ { name = "aioapns" }, { name = "aiohttp" }, + { name = "altcha" }, { name = "annotated-types" }, { name = "asgiref" }, { name = "backoff" }, @@ -5220,6 +5240,7 @@ docs = [ ] prod = [ { name = "aioapns" }, + { name = "altcha" }, { name = "annotated-types" }, { name = "asgiref" }, { name = "backoff" }, @@ -5303,6 +5324,7 @@ prod = [ dev = [ { name = "aioapns" }, { name = "aiohttp" }, + { name = "altcha" }, { name = "annotated-types" }, { name = "asgiref", url = "https://github.com/andersk/asgiref/archive/8a2717c14bce1b8dd37371c675ee3728e66c3fe3.zip" }, { name = "backoff" }, @@ -5435,6 +5457,7 @@ docs = [ ] prod = [ { name = "aioapns" }, + { name = "altcha" }, { name = "annotated-types" }, { name = "asgiref", url = "https://github.com/andersk/asgiref/archive/8a2717c14bce1b8dd37371c675ee3728e66c3fe3.zip" }, { name = "backoff" }, diff --git a/version.py b/version.py index baa055c6bd..ef975069ba 100644 --- a/version.py +++ b/version.py @@ -49,4 +49,4 @@ API_FEATURE_LEVEL = 380 # historical commits sharing the same major version, in which case a # minor version bump suffices. -PROVISION_VERSION = (325, 1) # bumped 2025-04-16 to add hast dependencies to help-beta +PROVISION_VERSION = (325, 2) # bumped 2025-04-16 to add altcha diff --git a/web/src/portico/signup.ts b/web/src/portico/signup.ts index 6e4c15a2d0..f2efd00d00 100644 --- a/web/src/portico/signup.ts +++ b/web/src/portico/signup.ts @@ -10,6 +10,9 @@ import * as settings_config from "../settings_config.ts"; import * as portico_modals from "./portico_modals.ts"; +/* global AltchaWidgetMethods, AltchaStateChangeEvent */ +import "altcha"; + $(() => { // NB: this file is included on multiple pages. In each context, // some of the jQuery selectors below will return empty lists. @@ -360,4 +363,23 @@ $(() => { showElement(selected_element); } }); + + // Configure altcha + const altcha = document.querySelector("altcha-widget"); + if (altcha) { + altcha.configure({ + auto: "onload", + async customfetch(url: string, init?: RequestInit) { + return fetch(url, {...init, credentials: "include"}); + }, + }); + const $submit = $(altcha).closest("form").find("button[type=submit]"); + $submit.prop("disabled", true); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + altcha.addEventListener("statechange", ((ev: AltchaStateChangeEvent) => { + if (ev.detail.state === "verified") { + $submit.prop("disabled", false); + } + }) as EventListener); + } }); diff --git a/web/styles/portico/portico_signin.css b/web/styles/portico/portico_signin.css index 92f086f2e5..fd7d96acec 100644 --- a/web/styles/portico/portico_signin.css +++ b/web/styles/portico/portico_signin.css @@ -322,6 +322,11 @@ html { .new-organization-button { margin-top: 25px; + + &[disabled] { + cursor: default; + opacity: 0.6; + } } } diff --git a/zerver/forms.py b/zerver/forms.py index cf1722d37c..acf20d787c 100644 --- a/zerver/forms.py +++ b/zerver/forms.py @@ -1,9 +1,12 @@ +import base64 import logging import re from email.headerregistry import Address from typing import Any import dns.resolver +import orjson +from altcha import verify_solution from django import forms from django.conf import settings from django.contrib.auth import authenticate, password_validation @@ -11,7 +14,10 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, Set from django.contrib.auth.tokens import PasswordResetTokenGenerator, default_token_generator from django.core.exceptions import ValidationError from django.core.validators import validate_email +from django.forms.renderers import BaseRenderer from django.http import HttpRequest +from django.utils.html import format_html +from django.utils.safestring import SafeString from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy from markupsafe import Markup @@ -333,6 +339,85 @@ class RealmCreationForm(RealmDetailsForm): super().__init__(*args, **kwargs) +class AltchaWidget(forms.TextInput): + @override + def render( + self, + name: str, + value: Any, + attrs: dict[str, Any] | None = None, + renderer: BaseRenderer | None = None, + ) -> SafeString: + return format_html( + ( + "" + ), + "--altcha-max-width: 300px;", + orjson.dumps( + { + "verified": _("Verified that you're a human user!"), + "verifying": _("Verifying that you're not a bot..."), + } + ).decode(), + ) + + +class CaptchaRealmCreationForm(RealmCreationForm): + captcha = forms.CharField(required=True, widget=AltchaWidget) + + def __init__( + self, + *, + request: HttpRequest, + data: dict[str, Any] | None = None, + initial: dict[str, Any] | None = None, + ) -> None: + super().__init__(data=data, initial=initial) + self.request = request + + @override + def clean(self) -> None: + if not self.data.get("captcha"): + self.add_error("captcha", _("Validation failed, please try again.")) + + def clean_captcha(self) -> str: + payload = self.data.get("captcha", "") + + try: + ok, err = verify_solution(payload, settings.ALTCHA_HMAC_KEY, check_expires=True) + if not ok: + logging.warning("Invalid altcha solution: %s", err) + raise forms.ValidationError(_("Validation failed, please try again.")) + except forms.ValidationError: + raise + except Exception as e: + logging.exception(e) + raise forms.ValidationError(_("Validation failed, please try again.")) + + payload = orjson.loads(base64.b64decode(payload)) + challenge = payload["challenge"] + session_challenges = [e[0] for e in self.request.session.get("altcha_challenges", [])] + if challenge not in session_challenges: + logging.warning("Expired or replayed altcha solution") + raise forms.ValidationError(_("Validation failed, please try again.")) + + # Remove the successful solve from the session, to prevent replay + self.request.session["altcha_challenges"] = [ + e for e in self.request.session.get("altcha_challenges", []) if e[0] != challenge + ] + + return payload + + class LoggingSetPasswordForm(SetPasswordForm): new_password1 = forms.CharField( label=_("New password"), diff --git a/zerver/lib/test_classes.py b/zerver/lib/test_classes.py index 7a6369c2a8..723fac852e 100644 --- a/zerver/lib/test_classes.py +++ b/zerver/lib/test_classes.py @@ -897,6 +897,7 @@ Output: realm_type: int = Realm.ORG_TYPES["business"]["id"], realm_default_language: str = "en", realm_in_root_domain: str | None = None, + captcha: str | None = None, ) -> "TestHttpResponse": payload = { "email": email, @@ -905,6 +906,8 @@ Output: "realm_default_language": realm_default_language, "realm_subdomain": realm_subdomain, } + if captcha is not None: + payload["captcha"] = captcha if realm_in_root_domain is not None: payload["realm_in_root_domain"] = realm_in_root_domain return self.client_post( diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py index cd46bb90e6..e77d679645 100644 --- a/zerver/tests/test_signup.py +++ b/zerver/tests/test_signup.py @@ -1,3 +1,4 @@ +import base64 import re import time from collections.abc import Sequence @@ -2219,6 +2220,89 @@ class RealmCreationTest(ZulipTestCase): check_subdomain_available("we-are-zulip-team") check_subdomain_available("we-are-zulip-team", allow_reserved_subdomain=True) + @override_settings(OPEN_REALM_CREATION=True, USING_CAPTCHA=True, ALTCHA_HMAC_KEY="secret") + def test_create_realm_with_captcha(self) -> None: + string_id = "custom-test" + email = "user1@test.com" + realm_name = "Test" + + # Make sure the realm does not exist + with self.assertRaises(Realm.DoesNotExist): + get_realm(string_id) + + result = self.client_get("/new/") + self.assert_not_in_success_response(["Validation failed"], result) + + # Without the CAPTCHA value, we get an error + result = self.submit_realm_creation_form( + email, realm_subdomain=string_id, realm_name=realm_name + ) + self.assert_in_success_response(["Validation failed, please try again."], result) + + # With an invalid value, we also get an error + with self.assertLogs(level="WARNING") as logs: + result = self.submit_realm_creation_form( + email, realm_subdomain=string_id, realm_name=realm_name, captcha="moose" + ) + self.assert_in_success_response(["Validation failed, please try again."], result) + self.assert_length(logs.output, 1) + self.assertIn("Invalid altcha solution: Invalid altcha payload", logs.output[0]) + + # With something which raises an exception, we also get the same error + with self.assertLogs(level="WARNING") as logs: + result = self.submit_realm_creation_form( + email, + realm_subdomain=string_id, + realm_name=realm_name, + captcha=base64.b64encode( + orjson.dumps(["algorithm", "challenge", "number", "salt", "signature"]) + ).decode(), + ) + self.assert_in_success_response(["Validation failed, please try again."], result) + self.assert_length(logs.output, 1) + self.assertIn( + "TypeError: list indices must be integers or slices, not str", logs.output[0] + ) + + # If we override the validation, we get an error because it's not in the session + payload = base64.b64encode(orjson.dumps({"challenge": "moose"})).decode() + with ( + patch("zerver.forms.verify_solution", return_value=(True, None)) as verify, + self.assertLogs(level="WARNING") as logs, + ): + result = self.submit_realm_creation_form( + email, realm_subdomain=string_id, realm_name=realm_name, captcha=payload + ) + self.assert_in_success_response(["Validation failed, please try again."], result) + verify.assert_called_once_with(payload, "secret", check_expires=True) + self.assert_length(logs.output, 1) + self.assertIn("Expired or replayed altcha solution", logs.output[0]) + + self.assertEqual(self.client.session.get("altcha_challenges"), None) + result = self.client_get("/json/antispam_challenge") + data = self.assert_json_success(result) + self.assertEqual(data["algorithm"], "SHA-256") + self.assertEqual(data["maxnumber"], 500000) + self.assertIn("signature", data) + self.assertIn("challenge", data) + self.assertIn("salt", data) + + self.assert_length(self.client.session["altcha_challenges"], 1) + self.assertEqual(self.client.session["altcha_challenges"][0][0], data["challenge"]) + + # Update the payload so the challenge matches what is in the + # session. The real payload would have other keys. + payload = base64.b64encode(orjson.dumps({"challenge": data["challenge"]})).decode() + with patch("zerver.forms.verify_solution", return_value=(True, None)) as verify: + result = self.submit_realm_creation_form( + email, realm_subdomain=string_id, realm_name=realm_name, captcha=payload + ) + self.assertEqual(result.status_code, 302) + verify.assert_called_once_with(payload, "secret", check_expires=True) + + # And the challenge has been stripped out of the session + self.assertEqual(self.client.session["altcha_challenges"], []) + class UserSignUpTest(ZulipTestCase): def verify_signup( diff --git a/zerver/views/antispam.py b/zerver/views/antispam.py new file mode 100644 index 0000000000..897201c77b --- /dev/null +++ b/zerver/views/antispam.py @@ -0,0 +1,50 @@ +import logging +from datetime import timedelta + +from altcha import ChallengeOptions, create_challenge +from django.conf import settings +from django.http import HttpRequest, HttpResponseBase +from django.utils.timezone import now as timezone_now +from django.utils.translation import gettext as _ +from pydantic import BaseModel + +from zerver.lib.exceptions import JsonableError +from zerver.lib.response import json_success +from zerver.lib.typed_endpoint import typed_endpoint_without_parameters + + +class AltchaPayload(BaseModel): + algorithm: str + challenge: str + number: int + salt: str + signature: str + + +@typed_endpoint_without_parameters +def get_challenge( + request: HttpRequest, +) -> HttpResponseBase: + now = timezone_now() + expires = now + timedelta(minutes=1) + try: + challenge = create_challenge( + ChallengeOptions( + hmac_key=settings.ALTCHA_HMAC_KEY, + max_number=500000, + expires=expires, + ) + ) + session_challenges = request.session.get("altcha_challenges", []) + # We prune out expired challenges not for correctness (the + # expiration is validated separately) but to prevent this from + # growing without bound + session_challenges = [(c, e) for (c, e) in session_challenges if e > now.timestamp()] + request.session["altcha_challenges"] = [ + *session_challenges, + (challenge.challenge, expires.timestamp()), + ] + return json_success(request, data=challenge.__dict__) + except Exception as e: # nocoverage + logging.exception(e) + raise JsonableError(_("Failed to generate challenge")) diff --git a/zerver/views/registration.py b/zerver/views/registration.py index dd93c11be6..f5702d5450 100644 --- a/zerver/views/registration.py +++ b/zerver/views/registration.py @@ -46,6 +46,7 @@ from zerver.context_processors import ( ) from zerver.decorator import add_google_analytics, do_login, require_post from zerver.forms import ( + CaptchaRealmCreationForm, FindMyTeamForm, HomepageForm, RealmCreationForm, @@ -906,7 +907,10 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR # When settings.OPEN_REALM_CREATION is enabled, anyone can create a new realm, # with a few restrictions on their email address. if request.method == "POST": - form = RealmCreationForm(request.POST) + if settings.USING_CAPTCHA: + form: RealmCreationForm = CaptchaRealmCreationForm(data=request.POST, request=request) + else: + form = RealmCreationForm(request.POST) if form.is_valid(): try: rate_limit_request_by_ip(request, domain="sends_email_by_ip") @@ -976,11 +980,15 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR initial_data = { "realm_default_language": default_language_code, } - form = RealmCreationForm(initial=initial_data) + if settings.USING_CAPTCHA: + form = CaptchaRealmCreationForm(request=request, initial=initial_data) + else: + form = RealmCreationForm(initial=initial_data) context = get_realm_create_form_context() context.update( { + "has_captcha": settings.USING_CAPTCHA, "form": form, "current_url": request.get_full_path, } diff --git a/zproject/computed_settings.py b/zproject/computed_settings.py index 0acbeadb49..3050321988 100644 --- a/zproject/computed_settings.py +++ b/zproject/computed_settings.py @@ -68,6 +68,7 @@ from .configured_settings import ( STATIC_URL, SUBMIT_USAGE_STATISTICS, TORNADO_PORTS, + USING_CAPTCHA, USING_PGROONGA, ZULIP_ADMINISTRATOR, ZULIP_SERVICE_PUSH_NOTIFICATIONS, @@ -462,6 +463,11 @@ if DEVELOPMENT: else: TOR_EXIT_NODE_FILE_PATH = "/var/lib/zulip/tor-exit-nodes.json" +if USING_CAPTCHA: + ALTCHA_HMAC_KEY = get_mandatory_secret("altcha_hmac") +else: + ALTCHA_HMAC_KEY = "" + ######################################################################## # SECURITY SETTINGS ######################################################################## diff --git a/zproject/default_settings.py b/zproject/default_settings.py index 90eced0c34..543aab96de 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -259,6 +259,7 @@ RATE_LIMIT_TOR_TOGETHER = False SEND_LOGIN_EMAILS = True EMBEDDED_BOTS_ENABLED = False +USING_CAPTCHA = False DEFAULT_RATE_LIMITING_RULES = { # Limits total number of API requests per unit time by each user. # Rate limiting general API access protects the server against diff --git a/zproject/urls.py b/zproject/urls.py index a4f4a1fca3..2b0e34772b 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -26,6 +26,7 @@ from zerver.tornado.views import ( web_reload_clients, ) from zerver.views.alert_words import add_alert_words, list_alert_words, remove_alert_words +from zerver.views.antispam import get_challenge from zerver.views.attachments import list_by_user, remove from zerver.views.auth import ( api_fetch_api_key, @@ -640,6 +641,7 @@ i18n_urls = [ # Go to organization subdomain path("accounts/go/", realm_redirect, name="realm_redirect"), # Realm creation + path("json/antispam_challenge", get_challenge), path("new/", create_realm), path("new/", create_realm, name="create_realm"), # Realm reactivation