Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
import base64
|
|
import logging
|
|
from typing import Any
|
|
|
|
import orjson
|
|
from altcha.v1 import verify_solution
|
|
from django import forms
|
|
from django.conf import settings
|
|
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 typing_extensions import override
|
|
|
|
|
|
def captcha_enabled() -> bool:
|
|
return bool(settings.USING_CAPTCHA and settings.ALTCHA_HMAC_KEY)
|
|
|
|
|
|
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"
|
|
" 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 CaptchaFormMixin(forms.Form):
|
|
captcha = forms.CharField(
|
|
widget=AltchaWidget,
|
|
error_messages={"required": gettext_lazy("Validation failed, please try again.")},
|
|
)
|
|
|
|
def __init__(self, *args: Any, request: HttpRequest, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.request = request
|
|
if not captcha_enabled():
|
|
del self.fields["captcha"]
|
|
|
|
@override
|
|
def clean(self) -> None:
|
|
super().clean()
|
|
# Validating the payload consumes the solved challenge, to
|
|
# prevent replay; defer it until the rest of the form is
|
|
# valid, so that a failure in some other field does not
|
|
# invalidate a successful solve. The captcha field does not
|
|
# exist when the captcha is not enabled.
|
|
if "captcha" in self.fields and not self.errors:
|
|
try:
|
|
validate_captcha_payload(self.request, self.cleaned_data["captcha"])
|
|
except forms.ValidationError as error:
|
|
self.add_error("captcha", error)
|
|
|
|
|
|
def validate_captcha_payload(request: HttpRequest, captcha_payload: str) -> None:
|
|
if not captcha_enabled(): # nocoverage
|
|
raise forms.ValidationError(_("Challenges are not enabled."))
|
|
|
|
try:
|
|
ok, err = verify_solution(captcha_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:
|
|
logging.exception("Error while validating altcha solution")
|
|
raise forms.ValidationError(_("Validation failed, please try again."))
|
|
|
|
captcha_data = orjson.loads(base64.b64decode(captcha_payload))
|
|
challenge = captcha_data["challenge"]
|
|
session_challenges = [e[0] for e in 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
|
|
request.session["altcha_challenges"] = [
|
|
e for e in request.session.get("altcha_challenges", []) if e[0] != challenge
|
|
]
|