Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add altcha integration #12995

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dependencies": {
"@sentry/browser": "8.37.1",
"@tarekraafat/autocomplete.js": "10.2.9",
"altcha": "1.0.6",
"autosize": "6.0.1",
"daterangepicker": "3.1.0",
"jquery": "3.7.1",
Expand Down
Empty file added client/src/altcha.js
Empty file.
6 changes: 6 additions & 0 deletions client/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function mainLicenseTransform(packages) {
"tributejs",
"@tarekraafat/autocomplete.js",
"autosize",
"alcha",
];
return genericTransform(
packages,
Expand All @@ -54,6 +55,9 @@ function tributeLicenseTransform(packages) {
function autosizeLicenseTransform(packages) {
return genericTransform(packages, (pkg) => pkg.name.startsWith("autosize"));
}
function altchaLicenseTransform(packages) {
return genericTransform(packages, (pkg) => pkg.name.startsWith("altcha"));
}
// REUSE-IgnoreStart
function autoCompleteLicenseTransform(packages) {
const pkg = packages.find((pkgsItem) =>
Expand Down Expand Up @@ -81,6 +85,7 @@ module.exports = {
tribute: "./src/tribute.js",
autoComplete: "./src/autoComplete.js",
autosize: "./src/autosize.js",
altcha: "./src/altcha.js",
},
mode: "production",
optimization: {
Expand All @@ -104,6 +109,7 @@ module.exports = {
"tribute.js.license": tributeLicenseTransform,
"autoComplete.js.license": autoCompleteLicenseTransform,
"autosize.js.license": autosizeLicenseTransform,
"altcha.js.license": altchaLicenseTransform,
},
}),
],
Expand Down
19 changes: 19 additions & 0 deletions client/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# yarn lockfile v1


"@altcha/crypto@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@altcha/crypto/-/crypto-0.0.1.tgz#0e2f254559fb350c80ff56d29b8e3ab2e6bbea95"
integrity sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==

"@discoveryjs/json-ext@^0.5.0":
version "0.5.7"
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
Expand Down Expand Up @@ -47,6 +52,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"

"@rollup/[email protected]":
version "4.18.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz#1a7481137a54740bee1ded4ae5752450f155d942"
integrity sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==

"@sentry-internal/[email protected]":
version "8.37.1"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.37.1.tgz#374028d8e37047aeda14b226707e6601de65996e"
Expand Down Expand Up @@ -322,6 +332,15 @@ ajv@^6.12.5:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"

[email protected]:
version "1.0.6"
resolved "https://registry.yarnpkg.com/altcha/-/altcha-1.0.6.tgz#415ab2b52d4936bb50f95316fd854435dccd2705"
integrity sha512-H5bXDfbn/H9UQhW4kVdqPPRODvFsdOrftPUQ/hFWehjhV0LI8Mnq67knvJqCC3mw+s06h4KbIYGw43uVHCHEtQ==
dependencies:
"@altcha/crypto" "^0.0.1"
optionalDependencies:
"@rollup/rollup-linux-x64-gnu" "4.18.0"

[email protected]:
version "6.0.1"
resolved "https://registry.yarnpkg.com/autosize/-/autosize-6.0.1.tgz#64ee78dd7029be959eddd3afbbd33235b957e10f"
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ dependencies = [
"translation-finder>=2.16,<3.0",
"user-agents>=2.0,<2.3",
"weblate-language-data>=2024.9",
"weblate-schemas==2024.2"
"weblate-schemas==2024.2",
"altcha>=0.1.4,<2.0"
]
description = "A web-based continuous localization system with tight version control integration"
keywords = [
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 0 additions & 108 deletions weblate/accounts/captcha.py

This file was deleted.

101 changes: 57 additions & 44 deletions weblate/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

from __future__ import annotations

import base64
import json
from binascii import unhexlify
from time import time
from typing import TYPE_CHECKING, cast

from altcha import Challenge, ChallengeOptions, create_challenge, verify_solution

Check failure on line 13 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "altcha": module is installed, but missing library stubs or py.typed marker
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Div, Field, Fieldset, Layout, Submit
from django import forms
Expand All @@ -18,15 +21,14 @@
from django.middleware.csrf import rotate_token
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
from django.utils.translation import activate, gettext, gettext_lazy, ngettext, pgettext
from django.utils.translation import activate, gettext, gettext_lazy, ngettext
from django_otp.forms import OTPTokenForm as DjangoOTPTokenForm
from django_otp.forms import otp_verification_failed
from django_otp.oath import totp
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice

from weblate.accounts.auth import try_get_user
from weblate.accounts.captcha import MathCaptcha
from weblate.accounts.models import AuditLog, Profile
from weblate.accounts.notifications import NOTIFICATIONS, NotificationScope
from weblate.accounts.utils import (
Expand Down Expand Up @@ -165,16 +167,16 @@
# Remove empty choice from the form. We need it at the database level
# to initialize user profile, but it is filled in later based on
# languages configured in the browser.
self.fields["language"].choices = [

Check failure on line 170 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"
choice for choice in self.fields["language"].choices if choice[0]

Check failure on line 171 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"
]
# Limit languages to ones which have translation, do this by generating choices
# instead of queryset as the queryset would be evaluated twice as
# ModelChoiceField copies the queryset
languages = Language.objects.have_translation()
choices = list(languages.as_choices(use_code=False))
self.fields["languages"].choices = choices

Check failure on line 178 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"
self.fields["secondary_languages"].choices = choices

Check failure on line 179 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"
self.helper = FormHelper(self)
self.helper.disable_csrf = True
self.helper.form_tag = False
Expand Down Expand Up @@ -207,11 +209,11 @@
site_commit_email = self.instance.get_site_commit_email()
if site_commit_email:
if not settings.PRIVATE_COMMIT_EMAIL_OPT_IN:
self.fields["commit_email"].choices = [("", site_commit_email)]

Check failure on line 212 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"
else:
commit_emails.add(site_commit_email)

self.fields["commit_email"].choices += [(x, x) for x in sorted(commit_emails)]

Check failure on line 216 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"

self.helper = FormHelper(self)
self.helper.disable_csrf = True
Expand Down Expand Up @@ -246,7 +248,7 @@
super().__init__(*args, **kwargs)
emails = get_all_user_mails(self.instance.user)

self.fields["public_email"].choices += [(x, x) for x in sorted(emails)]

Check failure on line 251 in weblate/accounts/forms.py

View workflow job for this annotation

GitHub Actions / mypy

"Field" has no attribute "choices"

self.helper = FormHelper(self)
self.helper.disable_csrf = True
Expand Down Expand Up @@ -400,8 +402,31 @@
)


class CaptchaWidget(forms.HiddenInput):
challenge: Challenge | None = None

def render(self, name, value, attrs=None, renderer=None, **kwargs):
if self.challenge is None:
msg = "Challenge is missing!"
raise ValueError(msg)

# TODO: localize strings
return format_html(
"<altcha-widget challengejson='{}' auto='onfocus'></altcha-widget>",
json.dumps(
{
"algorithm": self.challenge.algorithm,
"challenge": self.challenge.challenge,
"maxnumber": self.challenge.maxnumber,
"salt": self.challenge.salt,
"signature": self.challenge.signature,
}
),
)


class CaptchaForm(forms.Form):
captcha = forms.IntegerField(required=True)
captcha = forms.IntegerField(required=False, widget=CaptchaWidget)

def __init__(
self,
Expand All @@ -412,64 +437,52 @@
initial=None,
) -> None:
super().__init__(data=data, initial=initial)
self.fresh = False
self.has_captcha = True
self.request = request
self.challenge: Challenge | None = None
if not settings.REGISTRATION_CAPTCHA or hide_captcha:
self.has_captcha = False
self.fields["captcha"].widget = forms.HiddenInput()
self.fields["captcha"].required = False
elif data is None or "captcha" not in request.session:
self.generate_captcha()
self.fresh = True
else:
self.mathcaptcha = MathCaptcha.unserialize(request.session["captcha"])
self.set_label()

def set_label(self) -> None:
# Set correct label
self.fields["captcha"].label = format_html(
pgettext(
"Question for a mathematics-based CAPTCHA, "
"the %s is an arithmetic problem",
"What is %s?",
).replace("%s", "{}"),
self.mathcaptcha.display,
)
if self.is_bound:
self["captcha"].label = cast(str, self.fields["captcha"].label)
self.generate_challenge()
self.fields["captcha"].widget.challenge = self.challenge
if data is None:
self.store_challenge()

def generate_captcha(self) -> None:
self.mathcaptcha = MathCaptcha()
self.request.session["captcha"] = self.mathcaptcha.serialize()
self.set_label()
def generate_challenge(self) -> Challenge:
challenge_options = ChallengeOptions(hmac_key=settings.SECRET_KEY)
self.challenge = create_challenge(challenge_options)
return self.challenge

def store_challenge(self):
self.request.session["captcha_challenge"] = self.challenge.challenge

def clean_captcha(self) -> None:
"""Validate CAPTCHA."""
if not settings.REGISTRATION_CAPTCHA:
if not self.has_captcha:
return
if self.fresh or not self.mathcaptcha.validate(self.cleaned_data["captcha"]):
self.generate_captcha()
rotate_token(self.request)
raise forms.ValidationError(
# Translators: Shown on wrong answer to the mathematics-based CAPTCHA
gettext("That was not correct, please try again.")
)
payload = self.data.get("altcha")

mail = self.cleaned_data.get("email", "NONE")
# Validate payload, check_expires is useless as it can be faked by the client
result = verify_solution(payload, settings.SECRET_KEY, check_expires=False)
if not result[0]:
LOGGER.error("Invalid altcha solution: %s", result[1:])
raise forms.ValidationError(gettext("Validation failed, please try again."))

LOGGER.info(
"Correct CAPTCHA for %s (%s = %s)",
mail,
self.mathcaptcha.question,
self.cleaned_data["captcha"],
)
# Manually guard against replay attacks
payload = json.loads(base64.b64decode(payload).decode())
if payload["challenge"] != self.request.session["captcha_challenge"]:
LOGGER.error("Outdated altcha solution")
raise forms.ValidationError(gettext("Validation failed, please try again."))

def is_valid(self) -> bool:
result = super().is_valid()
self.cleanup_session()
if not result:
self.store_challenge()
return result

def cleanup_session(self) -> None:
self.request.session.pop("captcha", None)
self.request.session.pop("captcha_challenge", None)


class ContactForm(CaptchaForm):
Expand Down
Loading
Loading