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 @@
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