Skip to content

Commit

Permalink
feat: add altcha integration
Browse files Browse the repository at this point in the history
  • Loading branch information
nijel committed Nov 12, 2024
1 parent ba2741b commit 4f65422
Show file tree
Hide file tree
Showing 17 changed files with 136 additions and 183 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.

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
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 @@ -400,8 +402,31 @@ def audit(self, request: AuthenticatedHttpRequest) -> None:
)


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 @@ 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["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

0 comments on commit 4f65422

Please sign in to comment.