mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
signup: Add optional Altcha to realm registration.
This commit is contained in:
committed by
Tim Abbott
parent
f434c9d913
commit
eae18738a6
@@ -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
25
pnpm-lock.yaml
generated
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
25
uv.lock
generated
@@ -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" },
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
});
|
||||
|
@@ -322,6 +322,11 @@ html {
|
||||
|
||||
.new-organization-button {
|
||||
margin-top: 25px;
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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"),
|
||||
|
@@ -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(
|
||||
|
@@ -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
50
zerver/views/antispam.py
Normal 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"))
|
@@ -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,
|
||||
}
|
||||
|
@@ -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
|
||||
########################################################################
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user