This commit is contained in:
AlexBa16
2026-06-08 15:29:52 +02:00
commit 27903eed4a
9931 changed files with 1535659 additions and 0 deletions
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="captcha" method="upgrade">
<name>plg_captcha_powcaptcha</name>
<version>6.1.0</version>
<creationDate>2025-12</creationDate>
<author>Joomla! Project</author>
<authorEmail>admin@joomla.org</authorEmail>
<authorUrl>www.joomla.org</authorUrl>
<copyright>(C) 2025 Open Source Matters, Inc.</copyright>
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
<description>PLG_CAPTCHA_POWCAPTCHA_XML_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Captcha\POWCaptcha</namespace>
<files>
<folder plugin="powcaptcha">services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_captcha_powcaptcha.ini</language>
<language tag="en-GB">language/en-GB/plg_captcha_powcaptcha.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="difficulty"
type="list"
label="PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_LABEL"
description="PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_DESC"
default="MODERATE"
validate="options"
>
<option value="easy">PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_EASY</option>
<option value="moderate">PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_MODERATE</option>
<option value="hard">PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_HARD</option>
<option value="custom">PLG_CAPTCHA_POWCAPTCHA_DIFFICULTY_CUSTOM</option>
</field>
<field
name="maxnumber"
type="number"
label="PLG_CAPTCHA_POWCAPTCHA_MAXNUMBER_LABEL"
description="PLG_CAPTCHA_POWCAPTCHA_MAXNUMBER_DESC"
min="10000"
max="1000000"
default="250000"
showon="difficulty:custom"
/>
<field
name="autosolve"
type="list"
label="PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_LABEL"
description="PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_DESC"
default="onfocus"
validate="options"
>
<option value="off">PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_OFF</option>
<option value="onfocus">PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_ONFOCUS</option>
<option value="onload">PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_ONLOAD</option>
<option value="onsubmit">PLG_CAPTCHA_POWCAPTCHA_AUTOSOLVE_ONSUBMIT</option>
</field>
<field
name="expiration"
type="list"
label="PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_LABEL"
description="PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_DESC"
default="300"
validate="options"
>
<option value="60">PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_1MIN</option>
<option value="300">PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_5MIN</option>
<option value="600">PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_10MIN</option>
<option value="3600">PLG_CAPTCHA_POWCAPTCHA_EXPIRATION_1HOUR</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,44 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage Captcha.POWCaptcha
*
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
* @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\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 6.1.0
*/
public function register(Container $container)
{
$container->set(
PluginInterface::class,
$container->lazy(POWCaptcha::class, function (Container $container) {
$plugin = new POWCaptcha(
(array) PluginHelper::getPlugin('captcha', 'powcaptcha')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
})
);
}
};
@@ -0,0 +1,93 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage Captcha.POWCaptcha
*
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
* @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 6.1.0
*/
final class POWCaptcha extends CMSPlugin implements SubscriberInterface
{
/**
* Load the language file on instantiation.
*
* @var boolean
* @since 6.1.0
*/
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()
);
}
}
@@ -0,0 +1,229 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage Captcha.POWCaptcha
*
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
* @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 6.1.0
*/
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(
'<altcha-widget %s></altcha-widget>',
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
{
}
}