diff --git a/administrator/components/com_admin/sql/updates/mysql/6.1.0-2025-11-29.sql b/administrator/components/com_admin/sql/updates/mysql/6.1.0-2025-11-29.sql new file mode 100644 index 0000000000000..45073758a6543 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/6.1.0-2025-11-29.sql @@ -0,0 +1,3 @@ +INSERT INTO `#__extensions` (`name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`) +SELECT 'plg_captcha_powcaptcha', 'plugin', 'powcaptcha', 'captcha', 0, 1, 1, 0, 1, '', '{}', '', 0, 0 +WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'powcaptcha' AND e.`folder` = 'captcha' AND e.`client_id` = 0); diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.1.0-2025-11-29.sql b/administrator/components/com_admin/sql/updates/postgresql/6.1.0-2025-11-29.sql new file mode 100644 index 0000000000000..2cbc909b1fa68 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/6.1.0-2025-11-29.sql @@ -0,0 +1,4 @@ +INSERT INTO "#__extensions" ("name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") +SELECT 'plg_captcha_powcaptcha', 'plugin', 'powcaptcha', 'captcha', 0, 1, 1, 0, 1, '', '{}', '', 0, 0 +WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'powcaptcha' AND e."folder" = 'captcha' AND e."client_id" = 0); + diff --git a/administrator/language/en-GB/plg_captcha_powcaptcha.ini b/administrator/language/en-GB/plg_captcha_powcaptcha.ini new file mode 100644 index 0000000000000..04fe2e6410978 --- /dev/null +++ b/administrator/language/en-GB/plg_captcha_powcaptcha.ini @@ -0,0 +1,39 @@ +; Joomla! Project +; (C) 2025 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_CAPTCHA_POWCAPTCHA="CAPTCHA - Proof of Work" +PLG_CAPTCHA_POWCAPTCHA_XML_DESCRIPTION="This CAPTCHA plugin presents a math task to the user's browser that the browser can solve automatically. It's not supposed to prove that the user is human, but to prove that the user is willing to invest the necessary time to solve the task. Currently based on the Altcha (altcha.org) library." + +; Admin parameters +PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_DESC="Should the CAPTCHA be solved automatically, without requiring the user to actively interact with the respective box?" +PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_LABEL="Automatic Solution" +PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_OFF="Off" +PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_ONFOCUS="When CAPTCHA field receives focus" +PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_ONLOAD="When page is loaded" +PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_ONSUBMIT="When form is submitted" +PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_DESC="The harder the CAPTCHA gets, the more computing time will be required to solve it." +PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_LABEL="Difficulty" +PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_CUSTOM="Custom" +PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_EASY="Easy" +PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_HARD="Hard" +PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_MODERATE="Moderate" +PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_DESC="How long should a solution be valid?" +PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_LABEL="Expiration" +PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_1MIN="1 minute" +PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_5MIN="5 minutes" +PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_10MIN="10 minutes" +PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_1HOUR="1 hour" +PLG_CAPTCHA_POWCAPTCHA_MAXNUMBER_DESC="Defines the maximum number that's used in the task that needs to be solved. The higher the number, the harder to solve." +PLG_CAPTCHA_POWCAPTCHA_MAXNUMBER_LABEL="Maximum Number" + +; Client-side interface customisation +PLG_CAPTCHA_POWCAPTCHA_ARIALINKLABEL="Visit Altcha.org" +PLG_CAPTCHA_POWCAPTCHA_ERROR="Verification failed. Try again later." +PLG_CAPTCHA_POWCAPTCHA_EXPIRED="Verification expired. Try again." +PLG_CAPTCHA_POWCAPTCHA_FOOTER="Protected by ALTCHA" +PLG_CAPTCHA_POWCAPTCHA_LABEL="I'm not a robot" +PLG_CAPTCHA_POWCAPTCHA_VERIFIED="Verified" +PLG_CAPTCHA_POWCAPTCHA_VERIFYING="Verifying..." +PLG_CAPTCHA_POWCAPTCHA_WAITALERT="Verifying... please wait." diff --git a/administrator/language/en-GB/plg_captcha_powcaptcha.sys.ini b/administrator/language/en-GB/plg_captcha_powcaptcha.sys.ini new file mode 100644 index 0000000000000..59b0c44e46fbb --- /dev/null +++ b/administrator/language/en-GB/plg_captcha_powcaptcha.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2025 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_CAPTCHA_POWCAPTCHA="CAPTCHA - Proof of Work" +PLG_CAPTCHA_POWCAPTCHA_XML_DESCRIPTION="This CAPTCHA plugin presents a math task to the user's browser that the browser can solve automatically. It's not supposed to prove that the user is human, but to prove that the user is willing to invest the necessary time to solve the task. Currently based on the Altcha (altcha.org) library." diff --git a/build/build-modules-js/settings.json b/build/build-modules-js/settings.json index 9dbb3218c92c2..1529438ac3114 100644 --- a/build/build-modules-js/settings.json +++ b/build/build-modules-js/settings.json @@ -1,6 +1,43 @@ { "settings": { "vendors": { + "altcha": { + "name": "altcha", + "js": { + "dist_external/altcha.js": "js/altcha.js", + "dist_external/worker.js": "js/worker.js" + }, + "css": { + "dist_external/altcha.css": "css/altcha.css" + }, + "provideAssets": [ + { + "name": "altcha", + "type": "script", + "uri": "altcha.js", + "attributes": { + "defer": true, + "async": true, + "type": "module" + } + }, + { + "name": "altcha", + "type": "style", + "uri": "altcha.css" + }, + { + "name": "altcha", + "type": "preset", + "dependencies": [ + "altcha#script", + "altcha#style" + ] + } + ], + "dependencies": [], + "licenseFilename": "LICENSE.txt" + }, "awesomplete": { "name": "awesomplete", "js": { diff --git a/composer.json b/composer.json index 17f3f99a2b123..5a3004bb9db66 100644 --- a/composer.json +++ b/composer.json @@ -102,7 +102,8 @@ "phpseclib/bcmath_compat": "^2.0.3", "jfcherng/php-diff": "^6.16.2", "php-tuf/php-tuf": "^1.0.3", - "php-debugbar/php-debugbar": "^2.2.4" + "php-debugbar/php-debugbar": "^2.2.4", + "altcha-org/altcha": "^1.2" }, "require-dev": { "phpunit/phpunit": "^9.6.29", diff --git a/composer.lock b/composer.lock index b1b0d1eeac543..4963434fb06ba 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f577684c10af21b55e694ebddac803ca", + "content-hash": "ea140f44961f1d18b9bf369b84a4acc6", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -62,6 +62,53 @@ }, "time": "2025-08-11T09:03:12+00:00" }, + { + "name": "altcha-org/altcha", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/altcha-org/altcha-lib-php.git", + "reference": "725e604719a47fa9187a795bf5ca6f945342b017" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/altcha-org/altcha-lib-php/zipball/725e604719a47fa9187a795bf5ca6f945342b017", + "reference": "725e604719a47fa9187a795bf5ca6f945342b017", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.72", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "AltchaOrg\\Altcha\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Regeci", + "email": "536331+ovx@users.noreply.github.com" + } + ], + "support": { + "issues": "https://github.com/altcha-org/altcha-lib-php/issues", + "source": "https://github.com/altcha-org/altcha-lib-php/tree/v1.2.0" + }, + "time": "2025-11-17T00:15:21+00:00" + }, { "name": "brick/math", "version": "0.12.3", diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index 399315807fd52..42263cfb1ae18 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -265,6 +265,7 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, (0, 'plg_behaviour_compat6', 'plugin', 'compat6', 'behaviour', 0, 0, 1, 0, 1, '', '{"classes_aliases":"0","legacy_classes":"1"}', '', 1, 0), (0, 'plg_behaviour_taggable', 'plugin', 'taggable', 'behaviour', 0, 1, 1, 0, 1, '', '{}', '', 2, 0), (0, 'plg_behaviour_versionable', 'plugin', 'versionable', 'behaviour', 0, 1, 1, 0, 1, '', '{}', '', 3, 0), +(0, 'plg_captcha_powcaptcha', 'plugin', 'powcaptcha', 'captcha', 0, 1, 1, 0, 1, '', '{}', '', 1, 0), (0, 'plg_content_confirmconsent', 'plugin', 'confirmconsent', 'content', 0, 0, 1, 0, 1, '', '{}', '', 1, 0), (0, 'plg_content_contact', 'plugin', 'contact', 'content', 0, 1, 1, 0, 1, '', '', '', 2, 0), (0, 'plg_content_emailcloak', 'plugin', 'emailcloak', 'content', 0, 1, 1, 0, 1, '', '{"mode":"1"}', '', 3, 0), diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql index 483cdf9fca12f..4a894064b24f9 100644 --- a/installation/sql/postgresql/base.sql +++ b/installation/sql/postgresql/base.sql @@ -271,6 +271,7 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", (0, 'plg_behaviour_compat6', 'plugin', 'compat6', 'behaviour', 0, 0, 1, 0, 1, '', '{"classes_aliases":"0","legacy_classes":"1"}', '', 1, 0), (0, 'plg_behaviour_taggable', 'plugin', 'taggable', 'behaviour', 0, 1, 1, 0, 1, '', '{}', '', 2, 0), (0, 'plg_behaviour_versionable', 'plugin', 'versionable', 'behaviour', 0, 1, 1, 0, 1, '', '{}', '', 3, 0), +(0, 'plg_captcha_powcaptcha', 'plugin', 'powcaptcha', 'captcha', 0, 1, 1, 0, 1, '', '{}', '', 1, 0), (0, 'plg_content_confirmconsent', 'plugin', 'confirmconsent', 'content', 0, 0, 1, 0, 1, '', '{}', '', 1, 0), (0, 'plg_content_contact', 'plugin', 'contact', 'content', 0, 1, 1, 0, 1, '', '', '', 2, 0), (0, 'plg_content_emailcloak', 'plugin', 'emailcloak', 'content', 0, 1, 1, 0, 1, '', '{"mode":"1"}', '', 3, 0), diff --git a/libraries/src/Extension/ExtensionHelper.php b/libraries/src/Extension/ExtensionHelper.php index 6618d712773b4..9d52b7dc900be 100644 --- a/libraries/src/Extension/ExtensionHelper.php +++ b/libraries/src/Extension/ExtensionHelper.php @@ -177,6 +177,9 @@ class ExtensionHelper ['plugin', 'taggable', 'behaviour', 0], ['plugin', 'versionable', 'behaviour', 0], + // Core plugin extensions - captcha + ['plugin', 'powcaptcha', 'captcha', 0], + // Core plugin extensions - content ['plugin', 'confirmconsent', 'content', 0], ['plugin', 'contact', 'content', 0], diff --git a/package-lock.json b/package-lock.json index 0c60fe7968c0d..297d227283506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "joomla", - "version": "6.0.1", + "version": "6.0.2", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -29,6 +29,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@popperjs/core": "^2.11.8", "accessibility": "^3.0.17", + "altcha": "^2.2.4", "awesomplete": "^1.1.7", "bootstrap": "^5.3.8", "choices.js": "^11.1.0", @@ -104,6 +105,12 @@ "npm": ">=10.1.0" } }, + "node_modules/@altcha/crypto": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@altcha/crypto/-/crypto-0.0.1.tgz", + "integrity": "sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==", + "license": "MIT" + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -860,7 +867,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2653,7 +2659,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2677,7 +2682,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4128,7 +4132,6 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -5360,7 +5363,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.4", "@vue/compiler-core": "3.5.22", @@ -5463,7 +5465,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5512,6 +5513,31 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/altcha": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/altcha/-/altcha-2.2.4.tgz", + "integrity": "sha512-UrU2izh1pISqzd7TCAJiJB2N+r7roqA348Qxt1gJlW5k9pJpbDDmMcDaxfuet9h/WFE6Snrritu/WusmERarrg==", + "license": "MIT", + "dependencies": { + "@altcha/crypto": "^0.0.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.18.0" + } + }, + "node_modules/altcha/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -6066,7 +6092,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -7388,7 +7413,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9483,8 +9507,7 @@ "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jquery-migrate": { "version": "3.5.2", @@ -10906,7 +10929,6 @@ "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -11010,7 +11032,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11057,7 +11078,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11141,7 +11161,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -11566,7 +11585,6 @@ "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12692,7 +12710,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -13547,7 +13564,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", @@ -13639,7 +13655,6 @@ "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", "integrity": "sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.0.0-beta.11" }, diff --git a/package.json b/package.json index 9a19f06a8795e..36428191371b7 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@popperjs/core": "^2.11.8", "accessibility": "^3.0.17", + "altcha": "^2.2.4", "awesomplete": "^1.1.7", "bootstrap": "^5.3.8", "choices.js": "^11.1.0", diff --git a/plugins/captcha/powcaptcha/powcaptcha.xml b/plugins/captcha/powcaptcha/powcaptcha.xml new file mode 100644 index 0000000000000..5d94db414dde9 --- /dev/null +++ b/plugins/captcha/powcaptcha/powcaptcha.xml @@ -0,0 +1,79 @@ + + + plg_captcha_powcaptcha + 6.1.0 + 2025-12 + Joomla! Project + admin@joomla.org + www.joomla.org + (C) 2025 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + PLG_CAPTCHA_POWCAPTCHA_XML_DESCRIPTION + Joomla\Plugin\Captcha\POWCaptcha + + services + src + + + language/en-GB/plg_captcha_powcaptcha.ini + language/en-GB/plg_captcha_powcaptcha.sys.ini + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/plugins/captcha/powcaptcha/services/provider.php b/plugins/captcha/powcaptcha/services/provider.php new file mode 100644 index 0000000000000..917e56ce81549 --- /dev/null +++ b/plugins/captcha/powcaptcha/services/provider.php @@ -0,0 +1,46 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +\defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\Captcha\POWCaptcha\Extension\POWCaptcha; + +return new class () implements ServiceProviderInterface { + /** + * Registers the service provider with a DI container. + * + * @param Container $container The DI container. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function register(Container $container) + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new POWCaptcha( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('captcha', 'powcaptcha') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/plugins/captcha/powcaptcha/src/Extension/POWCaptcha.php b/plugins/captcha/powcaptcha/src/Extension/POWCaptcha.php new file mode 100644 index 0000000000000..cd28006f4b734 --- /dev/null +++ b/plugins/captcha/powcaptcha/src/Extension/POWCaptcha.php @@ -0,0 +1,93 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Plugin\Captcha\POWCaptcha\Extension; + +use Joomla\CMS\Event\Captcha\CaptchaSetupEvent; +use Joomla\CMS\Event\Plugin\AjaxEvent; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Session\Session; +use Joomla\Event\SubscriberInterface; +use Joomla\Plugin\Captcha\POWCaptcha\Provider\POWCaptchaProvider; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Proof of work captcha Plugin + * Based on the ALTCHA captcha library + * + * @since __DEPLOY_VERSION__ + */ +final class POWCaptcha extends CMSPlugin implements SubscriberInterface +{ + /** + * Load the language file on instantiation. + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onAjaxPowcaptcha' => 'handleAjaxRequest', + 'onCaptchaSetup' => 'setupCaptcha', + ]; + } + + /** + * Register Captcha instance + * + * @param CaptchaSetupEvent $event + * + * @return void + */ + public function setupCaptcha(CaptchaSetupEvent $event) + { + $event->getCaptchaRegistry()->add($this->getProvider()); + } + + /** + * Handles the ajax request triggered by altcha to fetch the challenge code + * + * @param AjaxEvent $event + */ + public function handleAjaxRequest(AjaxEvent $event) + { + // CRSF Token check + if (!Session::checkToken('get')) { + $event->updateEventResult(json_encode([])); + + return; + } + + $event->updateEventResult( + json_encode( + $this->getProvider()->getChallenge() + ) + ); + } + + /** + * Returns the actual captcha provider instance + * + * @return POWCaptchaProvider + */ + protected function getProvider(): POWCaptchaProvider + { + return new POWCaptchaProvider( + $this->params, + $this->getApplication() + ); + } +} diff --git a/plugins/captcha/powcaptcha/src/Provider/POWCaptchaProvider.php b/plugins/captcha/powcaptcha/src/Provider/POWCaptchaProvider.php new file mode 100644 index 0000000000000..e6d33a9a978a1 --- /dev/null +++ b/plugins/captcha/powcaptcha/src/Provider/POWCaptchaProvider.php @@ -0,0 +1,229 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Plugin\Captcha\POWCaptcha\Provider; + +use AltchaOrg\Altcha\Altcha; +use AltchaOrg\Altcha\Challenge; +use AltchaOrg\Altcha\ChallengeOptions; +use AltchaOrg\Altcha\Hasher\Algorithm; +use Joomla\CMS\Application\CMSWebApplicationInterface; +use Joomla\CMS\Captcha\CaptchaProviderInterface; +use Joomla\CMS\Date\Date; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; +use Joomla\Registry\Registry; +use Joomla\Utilities\ArrayHelper; + +/** + * Class POWCaptchaProvider + * + * @package Joomla\Plugin\Captcha\POWCaptcha\Provider + * + * @since __DEPLOY_VERSION__ + */ +final class POWCaptchaProvider implements CaptchaProviderInterface +{ + protected const int MAXNUMBER_EASY = 50000; + protected const int MAXNUMBER_MODERATE = 100000; + protected const int MAXNUMBER_HARD = 200000; + + public function __construct( + protected Registry $params, + protected CMSWebApplicationInterface|null $application + ) { + } + + /** + * Return Captcha name, CMD string. + * + * @return string + */ + public function getName(): string + { + return 'powcaptcha'; + } + + /** + * Gets the challenge HTML + * + * @param string $name The name of the field. Not Used. + * @param array $attributes The id of the field. + * + * @return string The HTML to be embedded in the form. + */ + public function display(string $name = '', array $attributes = []): string + { + if (!$this->application instanceof CMSWebApplicationInterface) { + return ''; + } + + // Load assets + $this->application->getDocument()->getWebAssetManager()->usePreset('altcha'); + + // Prepare markup + $htmlAttributes = [ + 'name' => $name, + 'id' => $attributes['id'] ?? '', + 'class' => $attributes['class'] ?? '', + 'hidefooter' => true, + 'hidelogo' => true, + 'auto' => $this->params->get('autosolve', 'onfocus'), + 'strings' => htmlentities( + json_encode( + [ + 'ariaLinkLabel' => Text::_('PLG_CAPTCHA_POWCAPTCHA_ARIALINKLABEL'), + 'error' => Text::_('PLG_CAPTCHA_POWCAPTCHA_ERROR'), + 'expired' => Text::_('PLG_CAPTCHA_POWCAPTCHA_EXPIRED'), + 'footer' => Text::_('PLG_CAPTCHA_POWCAPTCHA_FOOTER'), + 'label' => Text::_('PLG_CAPTCHA_POWCAPTCHA_LABEL'), + 'verified' => Text::_('PLG_CAPTCHA_POWCAPTCHA_VERIFIED'), + 'verifying' => Text::_('PLG_CAPTCHA_POWCAPTCHA_VERIFYING'), + 'waitAlert' => Text::_('PLG_CAPTCHA_POWCAPTCHA_WAITALERT'), + ] + ), + ENT_QUOTES, + 'UTF-8' + ), + 'challengeurl' => Route::_( + \sprintf( + "index.php?option=com_ajax&plugin=powcaptcha&group=captcha&format=raw&%s=1", + Session::getFormToken() + ), + false, + false, + true + ), + ]; + + return \sprintf( + '', + ArrayHelper::toString($htmlAttributes) + ); + } + + /** + * Verify the users answer + * + * @param null|string $code Answer provided by user. Not needed for the Recaptcha implementation + * + * @return bool True if the answer is correct, false otherwise + * + * @throws \RuntimeException + */ + public function checkAnswer(?string $code = null): bool + { + if (!$this->application instanceof CMSWebApplicationInterface) { + return false; + } + + // Before we verify the actual solution, let's first verify our challenge key + $decoded = base64_decode($code, true); + + // Check for base64 decode errors + if (!$decoded) { + return false; + } + + // Check for json Errors + try { + $data = json_decode($decoded, true, 2, \JSON_THROW_ON_ERROR); + } catch (\JsonException | \ValueError) { + return false; + } + + // Check for data errors + if (!\is_array($data) || empty($data)) { + return false; + } + + // Invalid salt format + if (empty($data['salt']) || !str_contains($data['salt'], 'challengeKey=')) { + return false; + } + + // Extract challengeKey + parse_str(explode("?", $data['salt'])[1], $challengeParams); + + // Check if challengeKey is valid + $session = $this->application->getSession(); + + if (!$session->get('plg_captcha_powcaptcha.' . $challengeParams['challengeKey'], false)) { + // Key is invalid, return + return false; + } + + // Key is valid, check for solution + if (!(new Altcha($this->application->get('secret')))->verifySolution((string) $code)) { + return false; + } + + // Solution was valid, invalidate key + $session->set('plg_captcha_powcaptcha.' . $challengeParams['challengeKey'], false); + + // It's valid! + return true; + } + + /** + * Method to generate the actual altcha challenge + * + * @return Challenge + */ + public function getChallenge(): Challenge + { + // Determine the max number - to be updated in future releases + $maxNumber = match ($this->params->get('difficulty', 'moderate')) { + "easy" => self::MAXNUMBER_EASY, + "moderate" => self::MAXNUMBER_MODERATE, + "hard" => self::MAXNUMBER_HARD, + "custom" => $this->params->get('maxnumber', 250000) + }; + + // Calculate expiration time + $expiration = Date::getInstance()->add(new \DateInterval('PT' . $this->params->get('expiration', 300) . 'S')); + + // Generate a random key for the challenge to prevent replay attacks. + // That key is stored in the session and will be checked and invalidated for re-use during the verification process. + $challengeKey = md5(random_bytes(16)); + + // Store the challenge key in the session + $this->application->getSession()->set('plg_captcha_powcaptcha.' . $challengeKey, true); + + $options = new ChallengeOptions( + Algorithm::SHA512, + $maxNumber, + $expiration, + [ + "challengeKey" => $challengeKey, + ] + ); + + // Generate the challenge + return (new Altcha($this->application->get('secret')))->createChallenge($options); + } + + /** + * Method to react on the setup of a captcha field. Gives the possibility + * to change the field and/or the XML element for the field. + * + * @param FormField $field Captcha field instance + * @param \SimpleXMLElement $element XML form definition + * + * @return void + * + * @throws \RuntimeException + */ + public function setupField(FormField $field, \SimpleXMLElement $element): void + { + } +}