signup: Add optional Altcha to realm registration.

This commit is contained in:
Alex Vandiver
2025-04-16 16:28:08 +00:00
committed by Tim Abbott
parent f434c9d913
commit eae18738a6
16 changed files with 330 additions and 4 deletions

View File

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

25
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -36,6 +36,14 @@
</div>
<div class="input-box">
<button type="submit" class="new-organization-button register-button">{{ _("Create organization") }}</button>
{% if has_captcha %}
{% if form.captcha.errors %}
{% for error in form.captcha.errors %}
<p class="help-inline text-error">{{ error }}</p>
{% endfor %}
{% endif %}
{{ form.captcha }}
{% endif %}
</div>
</form>
</div>

25
uv.lock generated
View File

@@ -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" },

View File

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

View File

@@ -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<AltchaWidgetMethods & HTMLElement>("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);
}
});

View File

@@ -322,6 +322,11 @@ html {
.new-organization-button {
margin-top: 25px;
&[disabled] {
cursor: default;
opacity: 0.6;
}
}
}

View File

@@ -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-widget"
' name="captcha"'
' challengeurl="/json/antispam_challenge"'
" hidelogo"
" hidefooter"
' floating="bottom"'
" refetchonexpire"
' style="{}"'
' strings="{}"'
">"
),
"--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"),

View File

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

View File

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

50
zerver/views/antispam.py Normal file
View File

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

View File

@@ -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,6 +907,9 @@ 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":
if settings.USING_CAPTCHA:
form: RealmCreationForm = CaptchaRealmCreationForm(data=request.POST, request=request)
else:
form = RealmCreationForm(request.POST)
if form.is_valid():
try:
@@ -976,11 +980,15 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR
initial_data = {
"realm_default_language": default_language_code,
}
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,
}

View File

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

View File

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

View File

@@ -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/<creation_key>", create_realm, name="create_realm"),
# Realm reactivation