Skip to content

Commit

Permalink
feat: add altcha integration
Browse files Browse the repository at this point in the history
This adds altcha in addition to existing match captcha.

Issue #1462
  • Loading branch information
nijel committed Nov 12, 2024
1 parent ba2741b commit afca4dc
Show file tree
Hide file tree
Showing 15 changed files with 206 additions and 24 deletions.
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.

117 changes: 97 additions & 20 deletions weblate/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

from __future__ import annotations

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

from altcha import Challenge, ChallengeOptions, create_challenge, verify_solution

Check failure on line 14 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 @@ -16,6 +20,7 @@
from django.contrib.auth.forms import SetPasswordForm as DjangoSetPasswordForm
from django.db import transaction
from django.middleware.csrf import rotate_token
from django.utils import timezone
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
Expand Down Expand Up @@ -400,8 +405,47 @@ def audit(self, request: AuthenticatedHttpRequest) -> None:
)


class CaptchaWidget(forms.TextInput):
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)

return format_html(
"<altcha-widget challengejson='{}' strings='{}' hidefooter auto='onfocus'></altcha-widget>",
# Directly include challenge
json.dumps(
{
"algorithm": self.challenge.algorithm,
"challenge": self.challenge.challenge,
"maxnumber": self.challenge.maxnumber,
"salt": self.challenge.salt,
"signature": self.challenge.signature,
}
),
# Localize strings
json.dumps(
{
"error": gettext("Verification failed. Try again later."),
"expired": gettext("Verification expired. Try again."),
"label": gettext("I'm not a robot"),
"verified": gettext("Verification completed"),
"verifying": gettext("Verifying…"),
"waitAlert": gettext(
"Verification is still in progress, please wait."
),
}
),
)


class CaptchaForm(forms.Form):
captcha = forms.IntegerField(required=True)
altcha = forms.CharField(
required=True, widget=CaptchaWidget, label=gettext_lazy("Human verification")
)

def __init__(
self,
Expand All @@ -412,20 +456,42 @@ def __init__(
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["altcha"].widget = forms.HiddenInput()
self.fields["altcha"].required = 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.generate_challenge()
if data is None or "captcha" not in request.session:
self.generate_captcha()
else:
self.mathcaptcha = MathCaptcha.unserialize(request.session["captcha"])
self.set_label()

self.fields["altcha"].widget.challenge = self.challenge
if data is None:
self.store_challenge()

def generate_challenge(self) -> Challenge:
challenge_options = ChallengeOptions(
hmac_key=settings.SECRET_KEY,
expires=timezone.now() + timedelta(hours=2),
)
self.challenge = create_challenge(challenge_options)
return self.challenge

def generate_captcha(self) -> None:
self.mathcaptcha = MathCaptcha()
self.request.session["captcha"] = self.mathcaptcha.serialize()
self.set_label()

def set_label(self) -> None:
# Set correct label
"""Set correct math captcha label."""
self.fields["captcha"].label = format_html(
pgettext(
"Question for a mathematics-based CAPTCHA, "
Expand All @@ -437,39 +503,50 @@ def set_label(self) -> None:
if self.is_bound:
self["captcha"].label = cast(str, self.fields["captcha"].label)

def generate_captcha(self) -> None:
self.mathcaptcha = MathCaptcha()
self.request.session["captcha"] = self.mathcaptcha.serialize()
self.set_label()
def store_challenge(self):
self.request.session["captcha_challenge"] = self.challenge.challenge

def clean_captcha(self) -> None:
"""Validate CAPTCHA."""
if not settings.REGISTRATION_CAPTCHA:
"""Validate math captcha."""
if not self.has_captcha:
return
if self.fresh or not self.mathcaptcha.validate(self.cleaned_data["captcha"]):
if 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.")
)

mail = self.cleaned_data.get("email", "NONE")
def clean_altcha(self) -> None:
"""Validate altcha."""
if not self.has_captcha:
return
payload = self.data.get("altcha", "")

LOGGER.info(
"Correct CAPTCHA for %s (%s = %s)",
mail,
self.mathcaptcha.question,
self.cleaned_data["captcha"],
)
# Validate payload
result = verify_solution(payload, settings.SECRET_KEY, check_expires=True)
if not result[0]:
LOGGER.error("Invalid altcha solution: %s", result[1:])
raise forms.ValidationError(gettext("Validation failed, please try again."))

# Manually guard against replay attacks
payload = json.loads(base64.b64decode(payload).decode())
# Use get to gracefully handle already solved challenges
if payload["challenge"] != self.request.session.get("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 and self.has_captcha:
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
55 changes: 53 additions & 2 deletions weblate/accounts/tests/test_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@

"""Test for user handling."""

from __future__ import annotations

import base64
import json
from urllib.parse import parse_qs, urlparse

import responses
from altcha import Challenge, Solution, solve_challenge
from django.conf import settings
from django.core import mail
from django.test import Client, TestCase
Expand All @@ -26,6 +31,7 @@
"email": "[email protected]",
"fullname": "First Last",
"captcha": "9999",
"altcha": "value",
}

GH_BACKENDS = (
Expand Down Expand Up @@ -151,14 +157,59 @@ class RegistrationTest(BaseRegistrationTest):
def test_register_captcha_fail(self) -> None:
response = self.do_register()
self.assertContains(response, "That was not correct, please try again.")
self.assertContains(response, "Validation failed, please try again.")

def solve_altcha(self, response, data: dict):
form = response.context["form"]
challenge: Challenge = form.challenge
solution: Solution = solve_challenge(
challenge=challenge.challenge,
salt=challenge.salt,
algorithm=challenge.algorithm,
max_number=challenge.maxnumber,
start=0,
)
data["altcha"] = base64.b64encode(
json.dumps(
{
"algorithm": challenge.algorithm,
"challenge": challenge.challenge,
"number": solution.number,
"salt": challenge.salt,
"signature": challenge.signature,
}
).encode("utf-8")
).decode("utf-8")

def solve_math(self, response, data: dict):
form = response.context["form"]
data["captcha"] = form.mathcaptcha.result

@override_settings(REGISTRATION_CAPTCHA=True)
def test_register_partial_altcha(self) -> None:
"""Test registration with captcha enabled."""
response = self.client.get(reverse("register"))
data = REGISTRATION_DATA.copy()
self.solve_altcha(response, data)
response = self.do_register(data)
self.assertContains(response, "That was not correct, please try again.")

@override_settings(REGISTRATION_CAPTCHA=True)
def test_register_partial_match(self) -> None:
"""Test registration with captcha enabled."""
response = self.client.get(reverse("register"))
data = REGISTRATION_DATA.copy()
self.solve_math(response, data)
response = self.do_register(data)
self.assertContains(response, "Validation failed, please try again.")

@override_settings(REGISTRATION_CAPTCHA=True)
def test_register_captcha(self) -> None:
"""Test registration with captcha enabled."""
response = self.client.get(reverse("register"))
form = response.context["form"]
data = REGISTRATION_DATA.copy()
data["captcha"] = form.mathcaptcha.result
self.solve_altcha(response, data)
self.solve_math(response, data)
response = self.do_register(data)
self.assertContains(response, REGISTRATION_SUCCESS)

Expand Down
Loading

0 comments on commit afca4dc

Please sign in to comment.