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 WeblateOrg#1462
  • Loading branch information
nijel committed Nov 18, 2024
1 parent 9e6db40 commit e5a981c
Show file tree
Hide file tree
Showing 16 changed files with 235 additions and 21 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.38.0",
"@tarekraafat/autocomplete.js": "10.2.9",
"altcha": "1.0.7",
"autosize": "6.0.1",
"daterangepicker": "3.1.0",
"jquery": "3.7.1",
Expand Down
7 changes: 7 additions & 0 deletions client/src/altcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright © Michal Čihař <[email protected]>
//
// SPDX-License-Identifier: GPL-3.0-or-later

import Altcha from "altcha";

window.Altcha = Altcha;
11 changes: 11 additions & 0 deletions client/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ function mainLicenseTransform(packages) {
"autosize",
"multi.js",
"mousetrap",
"@altcha",
"altcha",
];
return genericTransform(
packages,
Expand All @@ -70,6 +72,13 @@ function multiJsLicenseTransform(packages) {
return genericTransform(packages, (pkg) => pkg.name.startsWith("multi.js"));
}

function altchaLicenseTransform(packages) {
return genericTransform(
packages,
(pkg) => pkg.name.startsWith("altcha") || pkg.name.startsWith("@altcha"),
);
}

// REUSE-IgnoreStart
function mousetrapLicenseTransform(packages) {
const pkg = packages.find((pkg) => pkg.name.startsWith("mousetrap"));
Expand Down Expand Up @@ -115,6 +124,7 @@ module.exports = {
autosize: "./src/autosize.js",
multi: "./src/multi.js",
mousetrap: "./src/mousetrap.js",
altcha: "./src/altcha.js",
},
mode: "production",
optimization: {
Expand Down Expand Up @@ -149,6 +159,7 @@ module.exports = {
"multi.js.license": multiJsLicenseTransform,
"multi.css.license": multiJsLicenseTransform,
"mousetrap.js.license": mousetrapLicenseTransform,
"altcha.js.license": altchaLicenseTransform,
},
}),
new MiniCssExtractPlugin({
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.38.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.38.0.tgz#d7f6d398778906efb0c017e63d3c59d3203dfa7d"
Expand Down Expand Up @@ -346,6 +356,15 @@ ajv@^8.0.0, ajv@^8.9.0:
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"

[email protected]:
version "1.0.7"
resolved "https://registry.yarnpkg.com/altcha/-/altcha-1.0.7.tgz#47d180f0da5ccedd04c2dace67f82859b8cc430f"
integrity sha512-rQJpGW00ZJ0vlunQXf5AZqf8iTnoFjb8DmIXz+IUggB0o27Z9VD3jfrISiYtOtVa9AKSTyE2QIuxv9zPxrmW8A==
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
18 changes: 18 additions & 0 deletions docs/admin/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ Weblate can use Akismet to check incoming anonymous suggestions for spam.
Visit `akismet.com <https://akismet.com/>`_ to purchase an API key
and associate it with a site.

.. setting:: ALTCHA_MAX_NUMBER

ALTCHA_MAX_NUMBER
-----------------

.. versionadded:: 5.9

Configures a maximal number for ALTCHA proof-of-work mechanism.

.. seealso::

`ALTCHA Proof of Work Mechanism <https://altcha.org/docs/proof-of-work/>`_

.. setting:: ANONYMOUS_USER_NAME

ANONYMOUS_USER_NAME
Expand Down Expand Up @@ -1596,6 +1609,11 @@ If turned on, a CAPTCHA is added to all pages where a users enters their e-mail
* Adding e-mail to an account.
* Contact form for users that are not signed in.

The protection currently consists of following steps:

* Mathematical captcha to be solved by the user.
* Proof of work challenge calculated by the browser. The difficulty can be adjusted using :setting:`ALTCHA_MAX_NUMBER`.

.. setting:: REGISTRATION_EMAIL_MATCH

REGISTRATION_EMAIL_MATCH
Expand Down
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Not yet released.

**New features**

* The registration CAPTCHA now includes proof-of-work mechanism ALTCHA.

**Improvements**

* :ref:`mt-google-translate-api-v3` now supports :ref:`glossary-mt` (optional).
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ classifiers = [
dependencies = [
"aeidon>=1.14.1,<1.16",
"ahocorasick-rs>=0.20.0,<0.23.0",
"altcha>=0.1.4,<2.0",
"borgbackup>=1.2.5,<1.5",
"celery[redis]>=5.4.0,<5.5",
"certifi>=2024.8.30",
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.

114 changes: 95 additions & 19 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
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,22 +456,43 @@ 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,
max_number=settings.ALTCHA_MAX_NUMBER,
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 @@ -439,39 +504,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."""
"""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", "")

# 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."))

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())
# 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
2 changes: 2 additions & 0 deletions weblate/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class WeblateAccountsConf(AppConf):
# Captcha for registrations
REGISTRATION_CAPTCHA = True

ALTCHA_MAX_NUMBER = 1_000_000

REGISTRATION_HINTS = {}

# How long to keep auditlog entries
Expand Down
Loading

0 comments on commit e5a981c

Please sign in to comment.