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/core": "^3.0.1",
"@zxcvbn-ts/language-common": "^3.0.2", "@zxcvbn-ts/language-common": "^3.0.2",
"@zxcvbn-ts/language-en": "^3.0.1", "@zxcvbn-ts/language-en": "^3.0.1",
"altcha": "^1.4.2",
"autosize": "^5.0.2", "autosize": "^5.0.2",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"babel-plugin-formatjs": "^10.2.6", "babel-plugin-formatjs": "^10.2.6",

25
pnpm-lock.yaml generated
View File

@@ -85,6 +85,9 @@ importers:
'@zxcvbn-ts/language-en': '@zxcvbn-ts/language-en':
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.2 version: 3.0.2
altcha:
specifier: ^1.4.2
version: 1.4.2
autosize: autosize:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
@@ -524,6 +527,9 @@ importers:
packages: packages:
'@altcha/crypto@0.0.1':
resolution: {integrity: sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==}
'@ampproject/remapping@2.3.0': '@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -2401,6 +2407,11 @@ packages:
cpu: [s390x] cpu: [s390x]
os: [linux] 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': '@rollup/rollup-linux-x64-gnu@4.39.0':
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==} resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
cpu: [x64] cpu: [x64]
@@ -3086,6 +3097,9 @@ packages:
ajv@8.17.1: ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
altcha@1.4.2:
resolution: {integrity: sha512-7UcWh4tHWqP5YHo+jC8vmm+sThYUi1R9qXsiiXtmdoOiimfA0LCLccSKqYSoDmYvqq+CBV79WpQVWafKQCKHmw==}
amator@1.1.0: amator@1.1.0:
resolution: {integrity: sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==} resolution: {integrity: sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==}
@@ -9487,6 +9501,8 @@ packages:
snapshots: snapshots:
'@altcha/crypto@0.0.1': {}
'@ampproject/remapping@2.3.0': '@ampproject/remapping@2.3.0':
dependencies: dependencies:
'@jridgewell/gen-mapping': 0.3.8 '@jridgewell/gen-mapping': 0.3.8
@@ -11546,6 +11562,9 @@ snapshots:
'@rollup/rollup-linux-s390x-gnu@4.39.0': '@rollup/rollup-linux-s390x-gnu@4.39.0':
optional: true optional: true
'@rollup/rollup-linux-x64-gnu@4.18.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.39.0': '@rollup/rollup-linux-x64-gnu@4.39.0':
optional: true optional: true
@@ -12380,6 +12399,12 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 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: amator@1.1.0:
dependencies: dependencies:
bezier-easing: 2.1.0 bezier-easing: 2.1.0

View File

@@ -194,6 +194,9 @@ prod = [
# Used for monitoring memcached # Used for monitoring memcached
"prometheus-client", "prometheus-client",
# For captchas on unauth'd pages which can generate emails
"altcha",
] ]
docs = [ docs = [
# Needed to build RTD docs # Needed to build RTD docs

View File

@@ -36,6 +36,14 @@
</div> </div>
<div class="input-box"> <div class="input-box">
<button type="submit" class="new-organization-button register-button">{{ _("Create organization") }}</button> <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> </div>
</form> </form>
</div> </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 }, { 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]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" 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/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/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/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/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/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 }, { 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 }, { 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]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.3.7" version = "4.3.7"
@@ -3368,6 +3386,7 @@ version = "2.2.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi" }, { 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 } 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 = [ dev = [
{ name = "aioapns" }, { name = "aioapns" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "altcha" },
{ name = "annotated-types" }, { name = "annotated-types" },
{ name = "asgiref" }, { name = "asgiref" },
{ name = "backoff" }, { name = "backoff" },
@@ -5220,6 +5240,7 @@ docs = [
] ]
prod = [ prod = [
{ name = "aioapns" }, { name = "aioapns" },
{ name = "altcha" },
{ name = "annotated-types" }, { name = "annotated-types" },
{ name = "asgiref" }, { name = "asgiref" },
{ name = "backoff" }, { name = "backoff" },
@@ -5303,6 +5324,7 @@ prod = [
dev = [ dev = [
{ name = "aioapns" }, { name = "aioapns" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "altcha" },
{ name = "annotated-types" }, { name = "annotated-types" },
{ name = "asgiref", url = "https://github.com/andersk/asgiref/archive/8a2717c14bce1b8dd37371c675ee3728e66c3fe3.zip" }, { name = "asgiref", url = "https://github.com/andersk/asgiref/archive/8a2717c14bce1b8dd37371c675ee3728e66c3fe3.zip" },
{ name = "backoff" }, { name = "backoff" },
@@ -5435,6 +5457,7 @@ docs = [
] ]
prod = [ prod = [
{ name = "aioapns" }, { name = "aioapns" },
{ name = "altcha" },
{ name = "annotated-types" }, { name = "annotated-types" },
{ name = "asgiref", url = "https://github.com/andersk/asgiref/archive/8a2717c14bce1b8dd37371c675ee3728e66c3fe3.zip" }, { name = "asgiref", url = "https://github.com/andersk/asgiref/archive/8a2717c14bce1b8dd37371c675ee3728e66c3fe3.zip" },
{ name = "backoff" }, { name = "backoff" },

View File

@@ -49,4 +49,4 @@ API_FEATURE_LEVEL = 380
# historical commits sharing the same major version, in which case a # historical commits sharing the same major version, in which case a
# minor version bump suffices. # 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"; import * as portico_modals from "./portico_modals.ts";
/* global AltchaWidgetMethods, AltchaStateChangeEvent */
import "altcha";
$(() => { $(() => {
// NB: this file is included on multiple pages. In each context, // NB: this file is included on multiple pages. In each context,
// some of the jQuery selectors below will return empty lists. // some of the jQuery selectors below will return empty lists.
@@ -360,4 +363,23 @@ $(() => {
showElement(selected_element); 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 { .new-organization-button {
margin-top: 25px; margin-top: 25px;
&[disabled] {
cursor: default;
opacity: 0.6;
}
} }
} }

View File

@@ -1,9 +1,12 @@
import base64
import logging import logging
import re import re
from email.headerregistry import Address from email.headerregistry import Address
from typing import Any from typing import Any
import dns.resolver import dns.resolver
import orjson
from altcha import verify_solution
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate, password_validation 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.contrib.auth.tokens import PasswordResetTokenGenerator, default_token_generator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.forms.renderers import BaseRenderer
from django.http import HttpRequest 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 as _
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
from markupsafe import Markup from markupsafe import Markup
@@ -333,6 +339,85 @@ class RealmCreationForm(RealmDetailsForm):
super().__init__(*args, **kwargs) 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): class LoggingSetPasswordForm(SetPasswordForm):
new_password1 = forms.CharField( new_password1 = forms.CharField(
label=_("New password"), label=_("New password"),

View File

@@ -897,6 +897,7 @@ Output:
realm_type: int = Realm.ORG_TYPES["business"]["id"], realm_type: int = Realm.ORG_TYPES["business"]["id"],
realm_default_language: str = "en", realm_default_language: str = "en",
realm_in_root_domain: str | None = None, realm_in_root_domain: str | None = None,
captcha: str | None = None,
) -> "TestHttpResponse": ) -> "TestHttpResponse":
payload = { payload = {
"email": email, "email": email,
@@ -905,6 +906,8 @@ Output:
"realm_default_language": realm_default_language, "realm_default_language": realm_default_language,
"realm_subdomain": realm_subdomain, "realm_subdomain": realm_subdomain,
} }
if captcha is not None:
payload["captcha"] = captcha
if realm_in_root_domain is not None: if realm_in_root_domain is not None:
payload["realm_in_root_domain"] = realm_in_root_domain payload["realm_in_root_domain"] = realm_in_root_domain
return self.client_post( return self.client_post(

View File

@@ -1,3 +1,4 @@
import base64
import re import re
import time import time
from collections.abc import Sequence 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")
check_subdomain_available("we-are-zulip-team", allow_reserved_subdomain=True) 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): class UserSignUpTest(ZulipTestCase):
def verify_signup( 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.decorator import add_google_analytics, do_login, require_post
from zerver.forms import ( from zerver.forms import (
CaptchaRealmCreationForm,
FindMyTeamForm, FindMyTeamForm,
HomepageForm, HomepageForm,
RealmCreationForm, 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, # When settings.OPEN_REALM_CREATION is enabled, anyone can create a new realm,
# with a few restrictions on their email address. # with a few restrictions on their email address.
if request.method == "POST": if request.method == "POST":
if settings.USING_CAPTCHA:
form: RealmCreationForm = CaptchaRealmCreationForm(data=request.POST, request=request)
else:
form = RealmCreationForm(request.POST) form = RealmCreationForm(request.POST)
if form.is_valid(): if form.is_valid():
try: try:
@@ -976,11 +980,15 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR
initial_data = { initial_data = {
"realm_default_language": default_language_code, "realm_default_language": default_language_code,
} }
if settings.USING_CAPTCHA:
form = CaptchaRealmCreationForm(request=request, initial=initial_data)
else:
form = RealmCreationForm(initial=initial_data) form = RealmCreationForm(initial=initial_data)
context = get_realm_create_form_context() context = get_realm_create_form_context()
context.update( context.update(
{ {
"has_captcha": settings.USING_CAPTCHA,
"form": form, "form": form,
"current_url": request.get_full_path, "current_url": request.get_full_path,
} }

View File

@@ -68,6 +68,7 @@ from .configured_settings import (
STATIC_URL, STATIC_URL,
SUBMIT_USAGE_STATISTICS, SUBMIT_USAGE_STATISTICS,
TORNADO_PORTS, TORNADO_PORTS,
USING_CAPTCHA,
USING_PGROONGA, USING_PGROONGA,
ZULIP_ADMINISTRATOR, ZULIP_ADMINISTRATOR,
ZULIP_SERVICE_PUSH_NOTIFICATIONS, ZULIP_SERVICE_PUSH_NOTIFICATIONS,
@@ -462,6 +463,11 @@ if DEVELOPMENT:
else: else:
TOR_EXIT_NODE_FILE_PATH = "/var/lib/zulip/tor-exit-nodes.json" 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 # SECURITY SETTINGS
######################################################################## ########################################################################

View File

@@ -259,6 +259,7 @@ RATE_LIMIT_TOR_TOGETHER = False
SEND_LOGIN_EMAILS = True SEND_LOGIN_EMAILS = True
EMBEDDED_BOTS_ENABLED = False EMBEDDED_BOTS_ENABLED = False
USING_CAPTCHA = False
DEFAULT_RATE_LIMITING_RULES = { DEFAULT_RATE_LIMITING_RULES = {
# Limits total number of API requests per unit time by each user. # Limits total number of API requests per unit time by each user.
# Rate limiting general API access protects the server against # Rate limiting general API access protects the server against

View File

@@ -26,6 +26,7 @@ from zerver.tornado.views import (
web_reload_clients, web_reload_clients,
) )
from zerver.views.alert_words import add_alert_words, list_alert_words, remove_alert_words 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.attachments import list_by_user, remove
from zerver.views.auth import ( from zerver.views.auth import (
api_fetch_api_key, api_fetch_api_key,
@@ -640,6 +641,7 @@ i18n_urls = [
# Go to organization subdomain # Go to organization subdomain
path("accounts/go/", realm_redirect, name="realm_redirect"), path("accounts/go/", realm_redirect, name="realm_redirect"),
# Realm creation # Realm creation
path("json/antispam_challenge", get_challenge),
path("new/", create_realm), path("new/", create_realm),
path("new/<creation_key>", create_realm, name="create_realm"), path("new/<creation_key>", create_realm, name="create_realm"),
# Realm reactivation # Realm reactivation