+ getAaguid() : '';
+ $authMetadata = $knownAuthenticators[$aaguid->toString()] ?? $knownAuthenticators[''];
+ ?>
+
+
+
+
-
+
-
+
@@ -141,8 +145,7 @@
+ class="btn btn-success w-100">
diff --git a/libraries/src/Cache/CacheControllerFactoryAwareInterface.php b/libraries/src/Cache/CacheControllerFactoryAwareInterface.php
index 7ccd49e9d0147..e3dd21ced2ad3 100644
--- a/libraries/src/Cache/CacheControllerFactoryAwareInterface.php
+++ b/libraries/src/Cache/CacheControllerFactoryAwareInterface.php
@@ -13,7 +13,7 @@
/**
* Interface to be implemented by classes depending on a cache controller factory.
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
interface CacheControllerFactoryAwareInterface
{
@@ -24,7 +24,7 @@ interface CacheControllerFactoryAwareInterface
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function setCacheControllerFactory(CacheControllerFactoryInterface $factory): void;
}
diff --git a/libraries/src/Cache/CacheControllerFactoryAwareTrait.php b/libraries/src/Cache/CacheControllerFactoryAwareTrait.php
index d6c4592416860..d40e946fcb28f 100644
--- a/libraries/src/Cache/CacheControllerFactoryAwareTrait.php
+++ b/libraries/src/Cache/CacheControllerFactoryAwareTrait.php
@@ -15,7 +15,7 @@
/**
* Defines the trait for a CacheControllerFactoryInterface Aware Class.
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
trait CacheControllerFactoryAwareTrait
{
@@ -24,7 +24,7 @@ trait CacheControllerFactoryAwareTrait
*
* @var CacheControllerFactoryInterface
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private $cacheControllerFactory;
@@ -33,7 +33,7 @@ trait CacheControllerFactoryAwareTrait
*
* @return CacheControllerFactoryInterface
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
protected function getCacheControllerFactory(): CacheControllerFactoryInterface
{
@@ -57,7 +57,7 @@ protected function getCacheControllerFactory(): CacheControllerFactoryInterface
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function setCacheControllerFactory(CacheControllerFactoryInterface $cacheControllerFactory = null): void
{
diff --git a/libraries/src/Console/AddUserCommand.php b/libraries/src/Console/AddUserCommand.php
index 22bb49a1512db..c44cf43f7071c 100644
--- a/libraries/src/Console/AddUserCommand.php
+++ b/libraries/src/Console/AddUserCommand.php
@@ -104,7 +104,7 @@ class AddUserCommand extends AbstractCommand
*
* @param DatabaseInterface $db The database
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DatabaseInterface $db)
{
diff --git a/libraries/src/Console/AddUserToGroupCommand.php b/libraries/src/Console/AddUserToGroupCommand.php
index 5cbbe0eef541e..07204d935b033 100644
--- a/libraries/src/Console/AddUserToGroupCommand.php
+++ b/libraries/src/Console/AddUserToGroupCommand.php
@@ -78,7 +78,7 @@ class AddUserToGroupCommand extends AbstractCommand
*
* @param DatabaseInterface $db The database
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DatabaseInterface $db)
{
diff --git a/libraries/src/Console/CleanCacheCommand.php b/libraries/src/Console/CleanCacheCommand.php
index 5336495693313..55fd1e3aac439 100644
--- a/libraries/src/Console/CleanCacheCommand.php
+++ b/libraries/src/Console/CleanCacheCommand.php
@@ -13,6 +13,7 @@
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -48,7 +49,30 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in
$symfonyStyle->title('Cleaning System Cache');
- Factory::getCache()->gc();
+ $cache = $this->getApplication()->bootComponent('com_cache')->getMVCFactory();
+ /** @var Joomla\Component\Cache\Administrator\Model\CacheModel $model */
+ $model = $cache->createModel('Cache', 'Administrator', ['ignore_request' => true]);
+
+ if ($input->getArgument('expired'))
+ {
+ if (!$model->purge())
+ {
+ $symfonyStyle->error('Expired Cache not cleaned');
+
+ return Command::FAILURE;
+ }
+
+ $symfonyStyle->success('Expired Cache cleaned');
+
+ return Command::SUCCESS;
+ }
+
+ if (!$model->clean())
+ {
+ $symfonyStyle->error('Cache not cleaned');
+
+ return Command::FAILURE;
+ }
$symfonyStyle->success('Cache cleaned');
@@ -64,10 +88,11 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in
*/
protected function configure(): void
{
- $help = "%command.name% will clear expired entries from the system cache
+ $help = "%command.name% will clear entries from the system cache
\nUsage: php %command.full_name% ";
- $this->setDescription('Clean expired cache entries');
+ $this->addArgument('expired', InputArgument::OPTIONAL, 'will clear expired entries from the system cache');
+ $this->setDescription('Clean cache entries');
$this->setHelp($help);
}
}
diff --git a/libraries/src/Console/DeleteUserCommand.php b/libraries/src/Console/DeleteUserCommand.php
index 4eae40c7b5084..704a3b299e7a6 100644
--- a/libraries/src/Console/DeleteUserCommand.php
+++ b/libraries/src/Console/DeleteUserCommand.php
@@ -69,7 +69,7 @@ class DeleteUserCommand extends AbstractCommand
*
* @param DatabaseInterface $db The database
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DatabaseInterface $db)
{
diff --git a/libraries/src/Console/ExtensionRemoveCommand.php b/libraries/src/Console/ExtensionRemoveCommand.php
index ac088ba0456dd..3b0dfe088cb11 100644
--- a/libraries/src/Console/ExtensionRemoveCommand.php
+++ b/libraries/src/Console/ExtensionRemoveCommand.php
@@ -99,7 +99,7 @@ class ExtensionRemoveCommand extends AbstractCommand
*
* @param DatabaseInterface $db The database
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DatabaseInterface $db)
{
diff --git a/libraries/src/Console/ListUserCommand.php b/libraries/src/Console/ListUserCommand.php
index 8a279d522cd8b..2c1b65e11f2da 100644
--- a/libraries/src/Console/ListUserCommand.php
+++ b/libraries/src/Console/ListUserCommand.php
@@ -48,7 +48,7 @@ class ListUserCommand extends AbstractCommand
*
* @param DatabaseInterface $db The database
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DatabaseInterface $db)
{
diff --git a/libraries/src/Console/RemoveUserFromGroupCommand.php b/libraries/src/Console/RemoveUserFromGroupCommand.php
index c66894a166d10..8afc9a2808e6a 100644
--- a/libraries/src/Console/RemoveUserFromGroupCommand.php
+++ b/libraries/src/Console/RemoveUserFromGroupCommand.php
@@ -78,7 +78,7 @@ class RemoveUserFromGroupCommand extends AbstractCommand
*
* @param DatabaseInterface $db The database
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DatabaseInterface $db)
{
diff --git a/libraries/src/Event/CoreEventAware.php b/libraries/src/Event/CoreEventAware.php
index ff8b76dc2d702..7657d4684b674 100644
--- a/libraries/src/Event/CoreEventAware.php
+++ b/libraries/src/Event/CoreEventAware.php
@@ -9,6 +9,13 @@
namespace Joomla\CMS\Event;
use Joomla\CMS\Event\Model\BeforeBatchEvent;
+use Joomla\CMS\Event\Plugin\System\Webauthn\Ajax as PlgSystemWebauthnAjax;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxChallenge as PlgSystemWebauthnAjaxChallenge;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxCreate as PlgSystemWebauthnAjaxCreate;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxDelete as PlgSystemWebauthnAjaxDelete;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxInitCreate as PlgSystemWebauthnAjaxInitCreate;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxLogin as PlgSystemWebauthnAjaxLogin;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxSaveLabel as PlgSystemWebauthnAjaxSaveLabel;
use Joomla\CMS\Event\QuickIcon\GetIconEvent;
use Joomla\CMS\Event\Table\AfterBindEvent;
use Joomla\CMS\Event\Table\AfterCheckinEvent;
@@ -97,6 +104,14 @@ trait CoreEventAware
'onWorkflowFunctionalityUsed' => WorkflowFunctionalityUsedEvent::class,
'onWorkflowAfterTransition' => WorkflowTransitionEvent::class,
'onWorkflowBeforeTransition' => WorkflowTransitionEvent::class,
+ // Plugin: System, WebAuthn
+ 'onAjaxWebauthn' => PlgSystemWebauthnAjax::class,
+ 'onAjaxWebauthnChallenge' => PlgSystemWebauthnAjaxChallenge::class,
+ 'onAjaxWebauthnCreate' => PlgSystemWebauthnAjaxCreate::class,
+ 'onAjaxWebauthnDelete' => PlgSystemWebauthnAjaxDelete::class,
+ 'onAjaxWebauthnInitcreate' => PlgSystemWebauthnAjaxInitCreate::class,
+ 'onAjaxWebauthnLogin' => PlgSystemWebauthnAjaxLogin::class,
+ 'onAjaxWebauthnSavelabel' => PlgSystemWebauthnAjaxSaveLabel::class,
];
/**
diff --git a/libraries/src/Event/Plugin/System/Webauthn/Ajax.php b/libraries/src/Event/Plugin/System/Webauthn/Ajax.php
new file mode 100644
index 0000000000000..c3b46fd813f2a
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/Ajax.php
@@ -0,0 +1,20 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use Joomla\CMS\Event\AbstractImmutableEvent;
+
+/**
+ * Concrete event class for the onAjaxWebauthn event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class Ajax extends AbstractImmutableEvent
+{
+}
diff --git a/libraries/src/Event/Plugin/System/Webauthn/AjaxChallenge.php b/libraries/src/Event/Plugin/System/Webauthn/AjaxChallenge.php
new file mode 100644
index 0000000000000..b2b657c6f59ef
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/AjaxChallenge.php
@@ -0,0 +1,45 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use InvalidArgumentException;
+use Joomla\CMS\Event\AbstractImmutableEvent;
+use Joomla\CMS\Event\Result\ResultAware;
+use Joomla\CMS\Event\Result\ResultAwareInterface;
+
+/**
+ * Concrete event class for the onAjaxWebauthnChallenge event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AjaxChallenge extends AbstractImmutableEvent implements ResultAwareInterface
+{
+ use ResultAware;
+
+ /**
+ * Make sure the result is valid JSON or boolean false
+ *
+ * @param mixed $data The data to check
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function typeCheckResult($data): void
+ {
+ if ($data === false)
+ {
+ return;
+ }
+
+ if (!is_string($data) || @json_decode($data) === null)
+ {
+ throw new InvalidArgumentException(sprintf('Event %s only accepts JSON results.', $this->getName()));
+ }
+ }
+}
diff --git a/libraries/src/Event/Plugin/System/Webauthn/AjaxCreate.php b/libraries/src/Event/Plugin/System/Webauthn/AjaxCreate.php
new file mode 100644
index 0000000000000..6a7f3bc6aac4f
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/AjaxCreate.php
@@ -0,0 +1,25 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use Joomla\CMS\Event\AbstractImmutableEvent;
+use Joomla\CMS\Event\Result\ResultAware;
+use Joomla\CMS\Event\Result\ResultAwareInterface;
+use Joomla\CMS\Event\Result\ResultTypeStringAware;
+
+/**
+ * Concrete event class for the onAjaxWebauthnCreate event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AjaxCreate extends AbstractImmutableEvent implements ResultAwareInterface
+{
+ use ResultAware;
+ use ResultTypeStringAware;
+}
diff --git a/libraries/src/Event/Plugin/System/Webauthn/AjaxDelete.php b/libraries/src/Event/Plugin/System/Webauthn/AjaxDelete.php
new file mode 100644
index 0000000000000..a86c2bab0609a
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/AjaxDelete.php
@@ -0,0 +1,25 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use Joomla\CMS\Event\AbstractImmutableEvent;
+use Joomla\CMS\Event\Result\ResultAware;
+use Joomla\CMS\Event\Result\ResultAwareInterface;
+use Joomla\CMS\Event\Result\ResultTypeBooleanAware;
+
+/**
+ * Concrete event class for the onAjaxWebauthnDelete event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AjaxDelete extends AbstractImmutableEvent implements ResultAwareInterface
+{
+ use ResultAware;
+ use ResultTypeBooleanAware;
+}
diff --git a/libraries/src/Event/Plugin/System/Webauthn/AjaxInitCreate.php b/libraries/src/Event/Plugin/System/Webauthn/AjaxInitCreate.php
new file mode 100644
index 0000000000000..5dec092fbb193
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/AjaxInitCreate.php
@@ -0,0 +1,46 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use Joomla\CMS\Event\AbstractImmutableEvent;
+use Joomla\CMS\Event\Result\ResultAware;
+use Joomla\CMS\Event\Result\ResultAwareInterface;
+use Joomla\CMS\Event\Result\ResultTypeObjectAware;
+use Webauthn\PublicKeyCredentialCreationOptions;
+
+/**
+ * Concrete event class for the onAjaxWebauthnInitcreate event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AjaxInitCreate extends AbstractImmutableEvent implements ResultAwareInterface
+{
+ use ResultAware;
+ use ResultTypeObjectAware;
+
+ /**
+ * Constructor
+ *
+ * @param string $name Event name
+ * @param array $arguments Event arguments
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(string $name, array $arguments = [])
+ {
+ parent::__construct($name, $arguments);
+
+ $this->resultAcceptableClasses = [
+ \stdClass::class,
+ PublicKeyCredentialCreationOptions::class
+ ];
+ }
+
+
+}
diff --git a/libraries/src/Event/Plugin/System/Webauthn/AjaxLogin.php b/libraries/src/Event/Plugin/System/Webauthn/AjaxLogin.php
new file mode 100644
index 0000000000000..5e84806472a19
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/AjaxLogin.php
@@ -0,0 +1,21 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use Joomla\CMS\Event\AbstractImmutableEvent;
+
+/**
+ * Concrete event class for the onAjaxWebauthnLogin event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AjaxLogin extends AbstractImmutableEvent
+{
+
+}
diff --git a/libraries/src/Event/Plugin/System/Webauthn/AjaxSaveLabel.php b/libraries/src/Event/Plugin/System/Webauthn/AjaxSaveLabel.php
new file mode 100644
index 0000000000000..377225f0b5294
--- /dev/null
+++ b/libraries/src/Event/Plugin/System/Webauthn/AjaxSaveLabel.php
@@ -0,0 +1,25 @@
+
+ * @license General Public License version 2 or later; see LICENSE
+ */
+
+namespace Joomla\CMS\Event\Plugin\System\Webauthn;
+
+use Joomla\CMS\Event\AbstractImmutableEvent;
+use Joomla\CMS\Event\Result\ResultAware;
+use Joomla\CMS\Event\Result\ResultAwareInterface;
+use Joomla\CMS\Event\Result\ResultTypeBooleanAware;
+
+/**
+ * Concrete event class for the onAjaxWebauthnSavelabel event
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AjaxSaveLabel extends AbstractImmutableEvent implements ResultAwareInterface
+{
+ use ResultAware;
+ use ResultTypeBooleanAware;
+}
diff --git a/libraries/src/Extension/ExtensionHelper.php b/libraries/src/Extension/ExtensionHelper.php
index ca38dc083ba67..5b20186fb519d 100644
--- a/libraries/src/Extension/ExtensionHelper.php
+++ b/libraries/src/Extension/ExtensionHelper.php
@@ -295,6 +295,7 @@ class ExtensionHelper
array('plugin', 'schedulerunner', 'system', 0),
array('plugin', 'sef', 'system', 0),
array('plugin', 'sessiongc', 'system', 0),
+ array('plugin', 'shortcut', 'system', 0),
array('plugin', 'skipto', 'system', 0),
array('plugin', 'stats', 'system', 0),
array('plugin', 'tasknotification', 'system', 0),
diff --git a/libraries/src/Feed/Parser/AtomParser.php b/libraries/src/Feed/Parser/AtomParser.php
index f854027fc7aec..dd7a902f28376 100644
--- a/libraries/src/Feed/Parser/AtomParser.php
+++ b/libraries/src/Feed/Parser/AtomParser.php
@@ -191,11 +191,10 @@ protected function handleUpdated(Feed $feed, \SimpleXMLElement $el)
*/
protected function initialise()
{
- // Read the version attribute.
- $this->version = ($this->stream->getAttribute('version') == '0.3') ? '0.3' : '1.0';
-
- // We want to move forward to the first element after the root element.
+ // We want to move forward to the first XML Element after the xml doc type declaration
$this->moveToNextElement();
+
+ $this->version = ($this->stream->getAttribute('version') == '0.3') ? '0.3' : '1.0';
}
/**
diff --git a/libraries/src/Feed/Parser/RssParser.php b/libraries/src/Feed/Parser/RssParser.php
index a732c8496be87..b3c5746a39f35 100644
--- a/libraries/src/Feed/Parser/RssParser.php
+++ b/libraries/src/Feed/Parser/RssParser.php
@@ -351,6 +351,9 @@ protected function handleWebmaster(Feed $feed, \SimpleXMLElement $el)
*/
protected function initialise()
{
+ // We want to move forward to the first XML Element after the xml doc type declaration
+ $this->moveToNextElement();
+
// Read the version attribute.
$this->version = $this->stream->getAttribute('version');
diff --git a/libraries/src/Installer/InstallerAdapter.php b/libraries/src/Installer/InstallerAdapter.php
index 2b0d99bcb3acc..460ce1b985f39 100644
--- a/libraries/src/Installer/InstallerAdapter.php
+++ b/libraries/src/Installer/InstallerAdapter.php
@@ -1395,7 +1395,7 @@ public function update()
*
* @return mixed The value of the element if set, null otherwise
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*
* @deprecated 5.0 Use getDatabase() instead of directly accessing db
*/
diff --git a/libraries/src/MVC/Factory/MVCFactory.php b/libraries/src/MVC/Factory/MVCFactory.php
index 55a2b2a953b8d..c1230467414d6 100644
--- a/libraries/src/MVC/Factory/MVCFactory.php
+++ b/libraries/src/MVC/Factory/MVCFactory.php
@@ -383,7 +383,7 @@ private function setRouterOnObject($object): void
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private function setCacheControllerOnObject($object): void
{
diff --git a/libraries/src/Plugin/CMSPlugin.php b/libraries/src/Plugin/CMSPlugin.php
index 2a544990c6cdd..c394b5e161fec 100644
--- a/libraries/src/Plugin/CMSPlugin.php
+++ b/libraries/src/Plugin/CMSPlugin.php
@@ -81,7 +81,7 @@ abstract class CMSPlugin implements DispatcherAwareInterface, PluginInterface
*
* @var CMSApplicationInterface
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private $application;
@@ -195,7 +195,7 @@ public function loadLanguage($extension = '', $basePath = JPATH_ADMINISTRATOR)
*
* @return string The translated string
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*
* @see sprintf
*/
@@ -406,7 +406,7 @@ private function parameterImplementsEventInterface(\ReflectionParameter $paramet
*
* @return CMSApplicationInterface|null
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
protected function getApplication(): ?CMSApplicationInterface
{
@@ -420,7 +420,7 @@ protected function getApplication(): ?CMSApplicationInterface
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function setApplication(CMSApplicationInterface $application): void
{
diff --git a/libraries/src/Updater/Adapter/ExtensionAdapter.php b/libraries/src/Updater/Adapter/ExtensionAdapter.php
index 59740ab424bc3..f84302bdafff1 100644
--- a/libraries/src/Updater/Adapter/ExtensionAdapter.php
+++ b/libraries/src/Updater/Adapter/ExtensionAdapter.php
@@ -155,10 +155,13 @@ protected function _endElement($parser, $name)
}
}
+ // $supportedDbs has uppercase keys because they are XML attribute names
+ $dbTypeUcase = strtoupper($dbType);
+
// Do we have an entry for the database?
- if (\array_key_exists($dbType, $supportedDbs))
+ if (\array_key_exists($dbTypeUcase, $supportedDbs))
{
- $minimumVersion = $supportedDbs[$dbType];
+ $minimumVersion = $supportedDbs[$dbTypeUcase];
$dbMatch = version_compare($dbVersion, $minimumVersion, '>=');
if (!$dbMatch)
@@ -168,7 +171,7 @@ protected function _endElement($parser, $name)
'JLIB_INSTALLER_AVAILABLE_UPDATE_DB_MINIMUM',
$this->currentUpdate->name,
$this->currentUpdate->version,
- Text::_($db->name),
+ Text::_('JLIB_DB_SERVER_TYPE_' . $dbTypeUcase),
$dbVersion,
$minimumVersion
);
@@ -183,7 +186,7 @@ protected function _endElement($parser, $name)
'JLIB_INSTALLER_AVAILABLE_UPDATE_DB_TYPE',
$this->currentUpdate->name,
$this->currentUpdate->version,
- Text::_($db->name)
+ Text::_('JLIB_DB_SERVER_TYPE_' . $dbTypeUcase)
);
Factory::getApplication()->enqueueMessage($dbMsg, 'warning');
diff --git a/libraries/src/Version.php b/libraries/src/Version.php
index 146ef1c188ef8..6071395f4b80b 100644
--- a/libraries/src/Version.php
+++ b/libraries/src/Version.php
@@ -63,7 +63,7 @@ final class Version
* @var string
* @since 3.8.0
*/
- const EXTRA_VERSION = 'beta2-dev';
+ const EXTRA_VERSION = 'dev';
/**
* Development status.
@@ -87,7 +87,7 @@ final class Version
* @var string
* @since 3.5
*/
- const RELDATE = '7-June-2022';
+ const RELDATE = '22-June-2022';
/**
* Release time.
@@ -95,7 +95,7 @@ final class Version
* @var string
* @since 3.5
*/
- const RELTIME = '16:53';
+ const RELTIME = '17:00';
/**
* Release timezone.
diff --git a/modules/mod_tags_popular/src/Helper/TagsPopularHelper.php b/modules/mod_tags_popular/src/Helper/TagsPopularHelper.php
index 7dcc83dd66d05..f8473845c0d1a 100644
--- a/modules/mod_tags_popular/src/Helper/TagsPopularHelper.php
+++ b/modules/mod_tags_popular/src/Helper/TagsPopularHelper.php
@@ -52,6 +52,7 @@ public static function getList(&$params)
'MAX(' . $db->quoteName('t.access') . ') AS ' . $db->quoteName('access'),
'MAX(' . $db->quoteName('t.alias') . ') AS ' . $db->quoteName('alias'),
'MAX(' . $db->quoteName('t.params') . ') AS ' . $db->quoteName('params'),
+ 'MAX(' . $db->quoteName('t.language') . ') AS ' . $db->quoteName('language'),
]
)
->group($db->quoteName(['tag_id', 't.title', 't.access', 't.alias']))
@@ -158,6 +159,7 @@ public static function getList(&$params)
'a.title',
'a.access',
'a.alias',
+ 'a.language',
]
)
)
diff --git a/modules/mod_tags_popular/tmpl/cloud.php b/modules/mod_tags_popular/tmpl/cloud.php
index 06050479095bc..ffdc0a0d7862b 100644
--- a/modules/mod_tags_popular/tmpl/cloud.php
+++ b/modules/mod_tags_popular/tmpl/cloud.php
@@ -49,7 +49,7 @@
endif;
?>
-
+
title, ENT_COMPAT, 'UTF-8'); ?>
count; ?>
diff --git a/modules/mod_tags_popular/tmpl/default.php b/modules/mod_tags_popular/tmpl/default.php
index 7faa01340a42d..9f7be61adb40a 100644
--- a/modules/mod_tags_popular/tmpl/default.php
+++ b/modules/mod_tags_popular/tmpl/default.php
@@ -24,7 +24,7 @@
-
+
title, ENT_COMPAT, 'UTF-8'); ?>
count; ?>
diff --git a/package-lock.json b/package-lock.json
index 4fbab7b55add9..c19a23256099c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"diff": "^5.0.0",
"dragula": "^3.7.3",
"focus-visible": "^5.2.0",
+ "hotkeys-js": "^3.9.3",
"joomla-ui-custom-elements": "^0.2.0",
"jquery": "^3.6.0",
"jquery-migrate": "^3.3.2",
@@ -4539,6 +4540,11 @@
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"dev": true
},
+ "node_modules/hotkeys-js": {
+ "version": "3.9.3",
+ "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.9.3.tgz",
+ "integrity": "sha512-s+f0xyvDmf6+DyrFQ2SY+eA7lbvMbjqkqi0I0SpMgnN5tZx7DeH8nsWhkJR4KEq3pxDPHJppDUhdt1rZFW5LeQ=="
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -11720,6 +11726,11 @@
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"dev": true
},
+ "hotkeys-js": {
+ "version": "3.9.3",
+ "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.9.3.tgz",
+ "integrity": "sha512-s+f0xyvDmf6+DyrFQ2SY+eA7lbvMbjqkqi0I0SpMgnN5tZx7DeH8nsWhkJR4KEq3pxDPHJppDUhdt1rZFW5LeQ=="
+ },
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
diff --git a/package.json b/package.json
index bf9f8287b1fc3..947f3bb980da6 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"diff": "^5.0.0",
"dragula": "^3.7.3",
"focus-visible": "^5.2.0",
+ "hotkeys-js": "^3.9.3",
"joomla-ui-custom-elements": "^0.2.0",
"jquery": "^3.6.0",
"jquery-migrate": "^3.3.2",
diff --git a/plugins/behaviour/taggable/services/provider.php b/plugins/behaviour/taggable/services/provider.php
index ac256d63c87a9..74b3f55f3f0d0 100644
--- a/plugins/behaviour/taggable/services/provider.php
+++ b/plugins/behaviour/taggable/services/provider.php
@@ -25,7 +25,7 @@
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function register(Container $container)
{
diff --git a/plugins/behaviour/taggable/src/Extension/Taggable.php b/plugins/behaviour/taggable/src/Extension/Taggable.php
index 07f94fdc39ba0..015ce31ef9ec3 100644
--- a/plugins/behaviour/taggable/src/Extension/Taggable.php
+++ b/plugins/behaviour/taggable/src/Extension/Taggable.php
@@ -40,7 +40,7 @@ final class Taggable extends CMSPlugin implements SubscriberInterface
*
* @return array
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public static function getSubscribedEvents(): array
{
diff --git a/plugins/behaviour/versionable/services/provider.php b/plugins/behaviour/versionable/services/provider.php
index 36a84b8ae7902..16b4795814cb7 100644
--- a/plugins/behaviour/versionable/services/provider.php
+++ b/plugins/behaviour/versionable/services/provider.php
@@ -28,7 +28,7 @@
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function register(Container $container)
{
diff --git a/plugins/behaviour/versionable/src/Extension/Versionable.php b/plugins/behaviour/versionable/src/Extension/Versionable.php
index 7cb6d41d5312e..f340976c061be 100644
--- a/plugins/behaviour/versionable/src/Extension/Versionable.php
+++ b/plugins/behaviour/versionable/src/Extension/Versionable.php
@@ -36,7 +36,7 @@ final class Versionable extends CMSPlugin implements SubscriberInterface
*
* @return array
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public static function getSubscribedEvents(): array
{
@@ -50,7 +50,7 @@ public static function getSubscribedEvents(): array
* The input filter
*
* @var InputFilter
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private $filter;
@@ -58,7 +58,7 @@ public static function getSubscribedEvents(): array
* The CMS helper
*
* @var CMSHelper
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private $helper;
diff --git a/plugins/editors/tinymce/src/PluginTraits/DisplayTrait.php b/plugins/editors/tinymce/src/PluginTraits/DisplayTrait.php
index 8ea116b8cd8c8..e29cc9b41863e 100644
--- a/plugins/editors/tinymce/src/PluginTraits/DisplayTrait.php
+++ b/plugins/editors/tinymce/src/PluginTraits/DisplayTrait.php
@@ -87,35 +87,36 @@ public function onDisplay(
// Prepare the instance specific options
if (empty($options['tinyMCE'][$fieldName]))
{
- // Width and height
- if ($width)
- {
- $options['tinyMCE'][$fieldName]['width'] = $width;
- }
+ $options['tinyMCE'][$fieldName] = [];
+ }
- if ($height)
- {
- $options['tinyMCE'][$fieldName]['height'] = $height;
- }
+ // Width and height
+ if ($width && empty($options['tinyMCE'][$fieldName]['width']))
+ {
+ $options['tinyMCE'][$fieldName]['width'] = $width;
+ }
- // Set editor to readonly mode
- if (!empty($params['readonly']))
- {
- $options['tinyMCE'][$fieldName]['readonly'] = 1;
- }
+ if ($height && empty($options['tinyMCE'][$fieldName]['height']))
+ {
+ $options['tinyMCE'][$fieldName]['height'] = $height;
+ }
- // The ext-buttons
- if (empty($options['tinyMCE'][$fieldName]['joomlaExtButtons']))
- {
- $btns = $this->tinyButtons($id, $buttons);
+ // Set editor to readonly mode
+ if (!empty($params['readonly']))
+ {
+ $options['tinyMCE'][$fieldName]['readonly'] = 1;
+ }
- $options['tinyMCE'][$fieldName]['joomlaMergeDefaults'] = true;
- $options['tinyMCE'][$fieldName]['joomlaExtButtons'] = $btns;
- }
+ // The ext-buttons
+ if (empty($options['tinyMCE'][$fieldName]['joomlaExtButtons']))
+ {
+ $btns = $this->tinyButtons($id, $buttons);
- $doc->addScriptOptions('plg_editor_tinymce', $options, false);
+ $options['tinyMCE'][$fieldName]['joomlaMergeDefaults'] = true;
+ $options['tinyMCE'][$fieldName]['joomlaExtButtons'] = $btns;
}
+ $doc->addScriptOptions('plg_editor_tinymce', $options, false);
// Setup Default (common) options for the Editor script
// Check whether we already have them
diff --git a/plugins/finder/tags/tags.php b/plugins/finder/tags/tags.php
index c5bd740196c8c..7442254c39fce 100644
--- a/plugins/finder/tags/tags.php
+++ b/plugins/finder/tags/tags.php
@@ -229,7 +229,7 @@ protected function index(Result $item)
$item->url = $this->getUrl($item->id, $this->extension, $this->layout);
// Build the necessary route and path information.
- $item->route = RouteHelper::getTagRoute($item->slug);
+ $item->route = RouteHelper::getComponentTagRoute($item->slug, $item->language);
// Get the menu title if it exists.
$title = $this->getItemMenuTitle($item->url);
diff --git a/plugins/multifactorauth/webauthn/tmpl/default.php b/plugins/multifactorauth/webauthn/tmpl/default.php
index 879dfda4a15e9..b01b34ddf39c1 100644
--- a/plugins/multifactorauth/webauthn/tmpl/default.php
+++ b/plugins/multifactorauth/webauthn/tmpl/default.php
@@ -13,7 +13,6 @@
//phpcs:ignorefile
use Joomla\CMS\Language\Text;
-use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Uri\Uri;
// This method is only available on HTTPS
@@ -33,7 +32,7 @@
return;
endif;
-$this->app->getDocument()->getWebAssetManager()->useScript('plg_multifactorauth_webauthn.webauthn');
+$this->getApplication()->getDocument()->getWebAssetManager()->useScript('plg_multifactorauth_webauthn.webauthn');
?>
diff --git a/plugins/system/httpheaders/httpheaders.php b/plugins/system/httpheaders/httpheaders.php
index 7adfb4f4eeedc..470fc400b36a5 100644
--- a/plugins/system/httpheaders/httpheaders.php
+++ b/plugins/system/httpheaders/httpheaders.php
@@ -323,7 +323,7 @@ private function setCspHeader(): void
/**
* That line is for B/C we do no longer require to add the nonce tag
* but add it once the setting is enabled so this line here is needed
- * to remove the outdated tag that was required until __DEPLOY_VERSION__
+ * to remove the outdated tag that was required until 4.2.0
*/
$cspValue->value = str_replace('{nonce}', '', $cspValue->value);
diff --git a/plugins/system/shortcut/services/provider.php b/plugins/system/shortcut/services/provider.php
new file mode 100644
index 0000000000000..c3a07ad557826
--- /dev/null
+++ b/plugins/system/shortcut/services/provider.php
@@ -0,0 +1,48 @@
+
+ * @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\System\Shortcut\Extension\Shortcut;
+
+return new class implements ServiceProviderInterface
+{
+ /**
+ * Registers the service provider with a DI container.
+ *
+ * @param Container $container The DI container.
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function register(Container $container)
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container)
+ {
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $plugin = new Shortcut(
+ $dispatcher,
+ (array) PluginHelper::getPlugin('system', 'shortcut')
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/plugins/system/shortcut/shortcut.xml b/plugins/system/shortcut/shortcut.xml
new file mode 100644
index 0000000000000..df15b1cfbfea8
--- /dev/null
+++ b/plugins/system/shortcut/shortcut.xml
@@ -0,0 +1,40 @@
+
+
+ plg_system_shortcut
+ Joomla! Project
+ 2022-06
+ (C) 2022 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 4.2.0
+ PLG_SYSTEM_SHORTCUT_XML_DESCRIPTION
+ Joomla\Plugin\System\Shortcut
+
+ js
+
+
+ services
+ src
+
+
+ language/en-GB/plg_system_shortcut.ini
+ language/en-GB/plg_system_shortcut.sys.ini
+
+
+
+
+
+
+
+
+
diff --git a/plugins/system/shortcut/src/Extension/Shortcut.php b/plugins/system/shortcut/src/Extension/Shortcut.php
new file mode 100644
index 0000000000000..82c47f702b2e0
--- /dev/null
+++ b/plugins/system/shortcut/src/Extension/Shortcut.php
@@ -0,0 +1,138 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Shortcut\Extension;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\Event\GenericEvent;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Uri\Uri;
+use Joomla\Event\Event;
+use Joomla\Event\SubscriberInterface;
+
+/**
+ * Shortcut plugin to add accessible keyboard shortcuts to the administrator templates.
+ *
+ * @since 4.2.0
+ */
+final class Shortcut extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * Load the language file on instantiation.
+ *
+ * @var boolean
+ * @since 4.2.0
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * The array keys are event names and the value can be:
+ *
+ * - The method name to call (priority defaults to 0)
+ * - An array composed of the method name to call and the priority
+ *
+ * For instance:
+ *
+ * * array('eventName' => 'methodName')
+ * * array('eventName' => array('methodName', $priority))
+ *
+ * @return array
+ *
+ * @since 4.2.0
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onBeforeCompileHead' => 'initialize',
+ 'onLoadShortcuts' => 'addShortcuts',
+ ];
+ }
+
+ /**
+ * Add the javascript for the shortcuts
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function initialize()
+ {
+ if (!$this->getApplication()->isClient('administrator'))
+ {
+ return;
+ }
+
+ $context = $this->getApplication()->input->get('option') . '.' . $this->getApplication()->input->get('view');
+
+ $shortcuts = [];
+
+ $event = new GenericEvent(
+ 'onLoadShortcuts',
+ [
+ 'context' => $context,
+ 'shortcuts' => $shortcuts,
+ ]
+ );
+
+ $this->getDispatcher()->dispatch('onLoadShortcuts', $event);
+
+ $shortcuts = $event->getArgument('shortcuts');
+
+ Text::script('PLG_SYSTEM_SHORTCUT_OVERVIEW_HINT');
+ Text::script('PLG_SYSTEM_SHORTCUT_OVERVIEW_TITLE');
+ Text::script('PLG_SYSTEM_SHORTCUT_OVERVIEW_DESC');
+ Text::script('JCLOSE');
+
+ $document = $this->getApplication()->getDocument();
+ $wa = $document->getWebAssetManager();
+ $wa->useScript('bootstrap.modal');
+ $wa->registerAndUseScript('script', 'plg_system_shortcut/shortcut.min.js', ['dependencies' => ['hotkeys.js']]);
+
+ $timeout = $this->params->get('timeout', 2000);
+
+ $document->addScriptOptions('plg_system_shortcut.shortcuts', $shortcuts);
+ $document->addScriptOptions('plg_system_shortcut.timeout', $timeout);
+ }
+
+ /**
+ * Add default shortcuts to the document
+ *
+ * @param Event $event The event
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function addShortcuts(Event $event)
+ {
+ $shortcuts = $event->getArgument('shortcuts', []);
+
+ $shortcuts = array_merge(
+ $shortcuts,
+ [
+ 'applyKey' => (object) ['selector' => 'joomla-toolbar-button .button-apply', 'shortcut' => 'A', 'title' => Text::_('JAPPLY')],
+ 'saveKey' => (object) ['selector' => 'joomla-toolbar-button .button-save', 'shortcut' => 'S', 'title' => Text::_('JTOOLBAR_SAVE')],
+ 'cancelKey' => (object) ['selector' => 'joomla-toolbar-button .button-cancel', 'shortcut' => 'Q', 'title' => Text::_('JCANCEL')],
+ 'newKey' => (object) ['selector' => 'joomla-toolbar-button .button-new', 'shortcut' => 'N', 'title' => Text::_('JTOOLBAR_NEW')],
+ 'searchKey' => (object) ['selector' => 'input[placeholder=' . Text::_('JSEARCH_FILTER') . ']', 'shortcut' => 'F', 'title' => Text::_('JSEARCH_FILTER')],
+ 'optionKey' => (object) ['selector' => 'joomla-toolbar-button .button-options', 'shortcut' => 'O', 'title' => Text::_('JOPTIONS')],
+ 'helpKey' => (object) ['selector' => 'joomla-toolbar-button .button-help', 'shortcut' => 'H', 'title' => Text::_('JHELP')],
+ 'toggleMenu' => (object) ['selector' => '#menu-collapse', 'shortcut' => 'M', 'title' => Text::_('JTOGGLE_SIDEBAR_MENU')],
+ 'dashboard' => (object) ['selector' => (string) new Uri(Route::_('index.php?')), 'shortcut' => 'D', 'title' => Text::_('COM_CPANEL_DASHBOARD_BASE_TITLE')],
+ ]
+ );
+
+ $event->setArgument('shortcuts', $shortcuts);
+ }
+}
diff --git a/plugins/system/webauthn/services/provider.php b/plugins/system/webauthn/services/provider.php
new file mode 100644
index 0000000000000..bb3e639d9c248
--- /dev/null
+++ b/plugins/system/webauthn/services/provider.php
@@ -0,0 +1,89 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') || die;
+
+use Joomla\Application\ApplicationInterface;
+use Joomla\Application\SessionAwareWebApplicationInterface;
+use Joomla\CMS\Application\CMSApplicationInterface;
+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\System\Webauthn\Authentication;
+use Joomla\Plugin\System\Webauthn\CredentialRepository;
+use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
+use Joomla\Plugin\System\Webauthn\MetadataRepository;
+use Webauthn\MetadataService\MetadataStatementRepository;
+use Webauthn\PublicKeyCredentialSourceRepository;
+
+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) {
+ $config = (array) PluginHelper::getPlugin('system', 'webauthn');
+ $subject = $container->get(DispatcherInterface::class);
+
+ $app = Factory::getApplication();
+ $session = $container->has('session') ? $container->get('session') : $this->getSession($app);
+
+ $db = $container->get('DatabaseDriver');
+ $credentialsRepository = $container->has(PublicKeyCredentialSourceRepository::class)
+ ? $container->get(PublicKeyCredentialSourceRepository::class)
+ : new CredentialRepository($db);
+
+ $metadataRepository = null;
+ $params = new Joomla\Registry\Registry($config['params'] ?? '{}');
+
+ if ($params->get('attestationSupport', 1) == 1)
+ {
+ $metadataRepository = $container->has(MetadataStatementRepository::class)
+ ? $container->get(MetadataStatementRepository::class)
+ : new MetadataRepository;
+ }
+
+ $authenticationHelper = $container->has(Authentication::class)
+ ? $container->get(Authentication::class)
+ : new Authentication($app, $session, $credentialsRepository, $metadataRepository);
+
+ $plugin = new Webauthn($subject, $config, $authenticationHelper);
+ $plugin->setApplication($app);
+
+ return $plugin;
+ }
+ );
+ }
+
+ /**
+ * Get the current application session object
+ *
+ * @param ApplicationInterface $app The application we are running in
+ *
+ * @return \Joomla\Session\SessionInterface|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getSession(ApplicationInterface $app)
+ {
+ return $app instanceof SessionAwareWebApplicationInterface ? $app->getSession() : null;
+ }
+};
diff --git a/plugins/system/webauthn/src/Authentication.php b/plugins/system/webauthn/src/Authentication.php
new file mode 100644
index 0000000000000..eed39e452f2ad
--- /dev/null
+++ b/plugins/system/webauthn/src/Authentication.php
@@ -0,0 +1,570 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Webauthn;
+
+// Protect from unauthorized access
+\defined('_JEXEC') or die();
+
+use Exception;
+use Joomla\Application\ApplicationInterface;
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Factory;
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
+use Joomla\Plugin\System\Webauthn\Hotfix\Server;
+use Joomla\Session\SessionInterface;
+use Laminas\Diactoros\ServerRequestFactory;
+use RuntimeException;
+use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
+use Webauthn\AuthenticatorSelectionCriteria;
+use Webauthn\MetadataService\MetadataStatementRepository;
+use Webauthn\PublicKeyCredentialCreationOptions;
+use Webauthn\PublicKeyCredentialDescriptor;
+use Webauthn\PublicKeyCredentialRequestOptions;
+use Webauthn\PublicKeyCredentialRpEntity;
+use Webauthn\PublicKeyCredentialSource;
+use Webauthn\PublicKeyCredentialSourceRepository;
+use Webauthn\PublicKeyCredentialUserEntity;
+
+/**
+ * Helper class to aid in credentials creation (link an authenticator to a user account)
+ *
+ * @since __DEPLOY_VERSION__
+ * @internal
+ */
+final class Authentication
+{
+ /**
+ * The credentials repository
+ *
+ * @var CredentialRepository
+ * @since __DEPLOY_VERSION__
+ */
+ private $credentialsRepository;
+
+ /**
+ * The application we are running in.
+ *
+ * @var CMSApplication
+ * @since __DEPLOY_VERSION__
+ */
+ private $app;
+
+ /**
+ * The application session
+ *
+ * @var SessionInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private $session;
+
+ /**
+ * A simple metadata statement repository
+ *
+ * @var MetadataStatementRepository
+ * @since __DEPLOY_VERSION__
+ */
+ private $metadataRepository;
+
+ /**
+ * Should I permit attestation support if a Metadata Statement Repository object is present and
+ * non-empty?
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ private $attestationSupport = true;
+
+ /**
+ * Public constructor.
+ *
+ * @param ApplicationInterface|null $app The app we are running in
+ * @param SessionInterface|null $session The app session object
+ * @param PublicKeyCredentialSourceRepository|null $credRepo Credentials repo
+ * @param MetadataStatementRepository|null $mdsRepo Authenticator metadata repo
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(
+ ApplicationInterface $app = null,
+ SessionInterface $session = null,
+ PublicKeyCredentialSourceRepository $credRepo = null,
+ ?MetadataStatementRepository $mdsRepo = null
+ )
+ {
+ $this->app = $app;
+ $this->session = $session;
+ $this->credentialsRepository = $credRepo;
+ $this->metadataRepository = $mdsRepo;
+ }
+
+ /**
+ * Get the known FIDO authenticators and their metadata
+ *
+ * @return object[]
+ * @since __DEPLOY_VERSION__
+ */
+ public function getKnownAuthenticators(): array
+ {
+ $return = (!empty($this->metadataRepository) && method_exists($this->metadataRepository, 'getKnownAuthenticators'))
+ ? $this->metadataRepository->getKnownAuthenticators()
+ : [];
+
+ // Add a generic authenticator entry
+ $image = HTMLHelper::_('image', 'plg_system_webauthn/fido.png', '', '', true, true);
+ $image = $image ? JPATH_ROOT . substr($image, \strlen(Uri::root(true))) : (JPATH_BASE . '/media/plg_system_webauthn/images/fido.png');
+ $image = file_exists($image) ? file_get_contents($image) : '';
+
+ $return[''] = (object) [
+ 'description' => Text::_('PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR'),
+ 'icon' => 'data:image/png;base64,' . base64_encode($image)
+ ];
+
+ return $return;
+ }
+
+ /**
+ * Returns the Public Key credential source repository object
+ *
+ * @return PublicKeyCredentialSourceRepository|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getCredentialsRepository(): ?PublicKeyCredentialSourceRepository
+ {
+ return $this->credentialsRepository;
+ }
+
+ /**
+ * Returns the authenticator metadata repository object
+ *
+ * @return MetadataStatementRepository|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getMetadataRepository(): ?MetadataStatementRepository
+ {
+ return $this->metadataRepository;
+ }
+
+ /**
+ * Generate the public key creation options.
+ *
+ * This is used for the first step of attestation (key registration).
+ *
+ * The PK creation options and the user ID are stored in the session.
+ *
+ * @param User $user The Joomla user to create the public key for
+ *
+ * @return PublicKeyCredentialCreationOptions
+ *
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function getPubKeyCreationOptions(User $user): PublicKeyCredentialCreationOptions
+ {
+ /**
+ * We will only ask for attestation information if our MDS is guaranteed not empty.
+ *
+ * We check that by trying to load a known good AAGUID (Yubico Security Key NFC). If it's
+ * missing, we have failed to load the MDS data e.g. we could not contact the server, it
+ * was taking too long, the cache is unwritable etc. In this case asking for attestation
+ * conveyance would cause the attestation to fail (since we cannot verify its signature).
+ * Therefore we have to ask for no attestation to be conveyed. The downside is that in this
+ * case we do not have any information about the make and model of the authenticator. So be
+ * it! After all, that's a convenience feature for us.
+ */
+ $attestationMode = $this->hasAttestationSupport()
+ ? PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT
+ : PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
+
+ $publicKeyCredentialCreationOptions = $this->getWebauthnServer()->generatePublicKeyCredentialCreationOptions(
+ $this->getUserEntity($user),
+ $attestationMode,
+ $this->getPubKeyDescriptorsForUser($user),
+ new AuthenticatorSelectionCriteria(
+ AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
+ false,
+ AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED
+ ),
+ new AuthenticationExtensionsClientInputs
+ );
+
+ // Save data in the session
+ $this->session->set('plg_system_webauthn.publicKeyCredentialCreationOptions', base64_encode(serialize($publicKeyCredentialCreationOptions)));
+ $this->session->set('plg_system_webauthn.registration_user_id', $user->id);
+
+ return $publicKeyCredentialCreationOptions;
+ }
+
+ /**
+ * Get the public key request options.
+ *
+ * This is used in the first step of the assertion (login) flow.
+ *
+ * @param User $user The Joomla user to get the PK request options for
+ *
+ * @return PublicKeyCredentialRequestOptions
+ *
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function getPubkeyRequestOptions(User $user): ?PublicKeyCredentialRequestOptions
+ {
+ Log::add('Creating PK request options', Log::DEBUG, 'webauthn.system');
+ $publicKeyCredentialRequestOptions = $this->getWebauthnServer()->generatePublicKeyCredentialRequestOptions(
+ PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
+ $this->getPubKeyDescriptorsForUser($user)
+ );
+
+ // Save in session. This is used during the verification stage to prevent replay attacks.
+ $this->session->set('plg_system_webauthn.publicKeyCredentialRequestOptions', base64_encode(serialize($publicKeyCredentialRequestOptions)));
+
+ return $publicKeyCredentialRequestOptions;
+ }
+
+ /**
+ * Validate the authenticator assertion.
+ *
+ * This is used in the second step of the assertion (login) flow. The server verifies that the
+ * assertion generated by the authenticator has not been tampered with.
+ *
+ * @param string $data The data
+ * @param User $user The user we are trying to log in
+ *
+ * @return PublicKeyCredentialSource
+ *
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function validateAssertionResponse(string $data, User $user): PublicKeyCredentialSource
+ {
+ // Make sure the public key credential request options in the session are valid
+ $encodedPkOptions = $this->session->get('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
+ $serializedOptions = base64_decode($encodedPkOptions);
+ $publicKeyCredentialRequestOptions = unserialize($serializedOptions);
+
+ if (!is_object($publicKeyCredentialRequestOptions)
+ || empty($publicKeyCredentialRequestOptions)
+ || !($publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions))
+ {
+ Log::add('Cannot retrieve valid plg_system_webauthn.publicKeyCredentialRequestOptions from the session', Log::NOTICE, 'webauthn.system');
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ $data = base64_decode($data);
+
+ if (empty($data))
+ {
+ Log::add('No or invalid assertion data received from the browser', Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ return $this->getWebauthnServer()->loadAndCheckAssertionResponse(
+ $data,
+ $this->getPKCredentialRequestOptions(),
+ $this->getUserEntity($user),
+ ServerRequestFactory::fromGlobals()
+ );
+ }
+
+ /**
+ * Validate the authenticator attestation.
+ *
+ * This is used for the second step of attestation (key registration), when the user has
+ * interacted with the authenticator and we need to validate the legitimacy of its response.
+ *
+ * An exception will be returned on error. Also, under very rare conditions, you may receive
+ * NULL instead of a PublicKeyCredentialSource object which means that something was off in the
+ * returned data from the browser.
+ *
+ * @param string $data The data
+ *
+ * @return PublicKeyCredentialSource|null
+ *
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function validateAttestationResponse(string $data): PublicKeyCredentialSource
+ {
+ // Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
+ $encodedOptions = $this->session->get('plg_system_webauthn.publicKeyCredentialCreationOptions', null);
+
+ if (empty($encodedOptions))
+ {
+ Log::add('Cannot retrieve plg_system_webauthn.publicKeyCredentialCreationOptions from the session', Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
+ }
+
+ /** @var PublicKeyCredentialCreationOptions|null $publicKeyCredentialCreationOptions */
+ try
+ {
+ $publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
+ }
+ catch (Exception $e)
+ {
+ Log::add('The plg_system_webauthn.publicKeyCredentialCreationOptions in the session is invalid', Log::NOTICE, 'webauthn.system');
+ $publicKeyCredentialCreationOptions = null;
+ }
+
+ if (!is_object($publicKeyCredentialCreationOptions) || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialCreationOptions))
+ {
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
+ }
+
+ // Retrieve the stored user ID and make sure it's the same one in the request.
+ $storedUserId = $this->session->get('plg_system_webauthn.registration_user_id', 0);
+ $myUser = $this->app->getIdentity() ?? new User;
+ $myUserId = $myUser->id;
+
+ if (($myUser->guest) || ($myUserId != $storedUserId))
+ {
+ $message = sprintf('Invalid user! We asked the authenticator to attest user ID %d, the current user ID is %d', $storedUserId, $myUserId);
+ Log::add($message, Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
+ }
+
+ // We init the PSR-7 request object using Diactoros
+ return $this->getWebauthnServer()->loadAndCheckAttestationResponse(
+ base64_decode($data),
+ $publicKeyCredentialCreationOptions,
+ ServerRequestFactory::fromGlobals()
+ );
+ }
+
+ /**
+ * Get the authentiactor attestation support.
+ *
+ * @return boolean
+ * @since __DEPLOY_VERSION__
+ */
+ public function hasAttestationSupport(): bool
+ {
+ return $this->attestationSupport
+ && ($this->metadataRepository instanceof MetadataStatementRepository)
+ && $this->metadataRepository->findOneByAAGUID('6d44ba9b-f6ec-2e49-b930-0c8fe920cb73');
+ }
+
+ /**
+ * Change the authenticator attestation support.
+ *
+ * @param bool $attestationSupport The desired setting
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function setAttestationSupport(bool $attestationSupport): void
+ {
+ $this->attestationSupport = $attestationSupport;
+ }
+
+ /**
+ * Try to find the site's favicon in the site's root, images, media, templates or current
+ * template directory.
+ *
+ * @return string|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getSiteIcon(): ?string
+ {
+ $filenames = [
+ 'apple-touch-icon.png',
+ 'apple_touch_icon.png',
+ 'favicon.ico',
+ 'favicon.png',
+ 'favicon.gif',
+ 'favicon.bmp',
+ 'favicon.jpg',
+ 'favicon.svg',
+ ];
+
+ try
+ {
+ $paths = [
+ '/',
+ '/images/',
+ '/media/',
+ '/templates/',
+ '/templates/' . $this->app->getTemplate(),
+ ];
+ }
+ catch (Exception $e)
+ {
+ return null;
+ }
+
+ foreach ($paths as $path)
+ {
+ foreach ($filenames as $filename)
+ {
+ $relFile = $path . $filename;
+ $filePath = JPATH_BASE . $relFile;
+
+ if (is_file($filePath))
+ {
+ break 2;
+ }
+
+ $relFile = null;
+ }
+ }
+
+ if (!isset($relFile) || \is_null($relFile))
+ {
+ return null;
+ }
+
+ return rtrim(Uri::base(), '/') . '/' . ltrim($relFile, '/');
+ }
+
+ /**
+ * Returns a User Entity object given a Joomla user
+ *
+ * @param User $user The Joomla user to get the user entity for
+ *
+ * @return PublicKeyCredentialUserEntity
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getUserEntity(User $user): PublicKeyCredentialUserEntity
+ {
+ $repository = $this->credentialsRepository;
+
+ return new PublicKeyCredentialUserEntity(
+ $user->username,
+ $repository->getHandleFromUserId($user->id),
+ $user->name,
+ $this->getAvatar($user, 64)
+ );
+ }
+
+ /**
+ * Get the user's avatar (through Gravatar)
+ *
+ * @param User $user The Joomla user object
+ * @param int $size The dimensions of the image to fetch (default: 64 pixels)
+ *
+ * @return string The URL to the user's avatar
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getAvatar(User $user, int $size = 64)
+ {
+ $scheme = Uri::getInstance()->getScheme();
+ $subdomain = ($scheme == 'https') ? 'secure' : 'www';
+
+ return sprintf('%s://%s.gravatar.com/avatar/%s.jpg?s=%u&d=mm', $scheme, $subdomain, md5($user->email), $size);
+ }
+
+ /**
+ * Returns an array of the PK credential descriptors (registered authenticators) for the given
+ * user.
+ *
+ * @param User $user The Joomla user to get the PK descriptors for
+ *
+ * @return PublicKeyCredentialDescriptor[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getPubKeyDescriptorsForUser(User $user): array
+ {
+ $userEntity = $this->getUserEntity($user);
+ $repository = $this->credentialsRepository;
+ $descriptors = [];
+ $records = $repository->findAllForUserEntity($userEntity);
+
+ foreach ($records as $record)
+ {
+ $descriptors[] = $record->getPublicKeyCredentialDescriptor();
+ }
+
+ return $descriptors;
+ }
+
+ /**
+ * Retrieve the public key credential request options saved in the session.
+ *
+ * If they do not exist or are corrupt it is a hacking attempt and we politely tell the
+ * attacker to go away.
+ *
+ * @return PublicKeyCredentialRequestOptions
+ *
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ private function getPKCredentialRequestOptions(): PublicKeyCredentialRequestOptions
+ {
+ $encodedOptions = $this->session->get('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
+
+ if (empty($encodedOptions))
+ {
+ Log::add('Cannot retrieve plg_system_webauthn.publicKeyCredentialRequestOptions from the session', Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ try
+ {
+ $publicKeyCredentialRequestOptions = unserialize(base64_decode($encodedOptions));
+ }
+ catch (Exception $e)
+ {
+ Log::add('Invalid plg_system_webauthn.publicKeyCredentialRequestOptions in the session', Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ if (!is_object($publicKeyCredentialRequestOptions) || !($publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions))
+ {
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ return $publicKeyCredentialRequestOptions;
+ }
+
+ /**
+ * Get the WebAuthn library's Server object which facilitates WebAuthn operations
+ *
+ * @return Server
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ private function getWebauthnServer(): \Webauthn\Server
+ {
+ $siteName = $this->app->get('sitename');
+
+ // Credentials repository
+ $repository = $this->credentialsRepository;
+
+ // Relaying Party -- Our site
+ $rpEntity = new PublicKeyCredentialRpEntity(
+ $siteName,
+ Uri::getInstance()->toString(['host']),
+ $this->getSiteIcon()
+ );
+
+ $server = new Server($rpEntity, $repository, $this->metadataRepository);
+
+ // Ed25519 is only available with libsodium
+ if (!function_exists('sodium_crypto_sign_seed_keypair'))
+ {
+ $server->setSelectedAlgorithms(['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512']);
+ }
+
+ return $server;
+ }
+}
diff --git a/plugins/system/webauthn/src/CredentialRepository.php b/plugins/system/webauthn/src/CredentialRepository.php
index 87394ca3d30d2..e74d8f33ec75c 100644
--- a/plugins/system/webauthn/src/CredentialRepository.php
+++ b/plugins/system/webauthn/src/CredentialRepository.php
@@ -14,11 +14,16 @@
use Exception;
use InvalidArgumentException;
+use Joomla\CMS\Date\Date;
use Joomla\CMS\Encrypt\Aes;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\Database\DatabaseAwareInterface;
+use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\DatabaseDriver;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Database\DatabaseInterface;
+use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
use Joomla\Registry\Registry;
use JsonException;
use RuntimeException;
@@ -32,8 +37,22 @@
*
* @since 4.0.0
*/
-class CredentialRepository implements PublicKeyCredentialSourceRepository
+final class CredentialRepository implements PublicKeyCredentialSourceRepository, DatabaseAwareInterface
{
+ use DatabaseAwareTrait;
+
+ /**
+ * Public constructor.
+ *
+ * @param DatabaseInterface|null $db The database driver object to use for persistence.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $db = null)
+ {
+ $this->setDatabase($db);
+ }
+
/**
* Returns a PublicKeyCredentialSource object given the public key credential ID
*
@@ -46,7 +65,7 @@ class CredentialRepository implements PublicKeyCredentialSourceRepository
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
{
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
$credentialId = base64_encode($publicKeyCredentialId);
$query = $db->getQuery(true)
->select($db->qn('credential'))
@@ -86,7 +105,7 @@ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKey
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
{
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
$userHandle = $publicKeyCredentialUserEntity->getId();
$query = $db->getQuery(true)
->select('*')
@@ -123,12 +142,12 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre
}
catch (JsonException $e)
{
- return;
+ return null;
}
if (empty($data))
{
- return;
+ return null;
}
try
@@ -137,7 +156,7 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre
}
catch (InvalidArgumentException $e)
{
- return;
+ return null;
}
};
@@ -177,18 +196,27 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
{
// Default values for saving a new credential source
- $credentialId = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
- $user = Factory::getApplication()->getIdentity();
- $o = (object) [
+ /** @var Webauthn $plugin */
+ $plugin = Factory::getApplication()->bootPlugin('webauthn', 'system');
+ $knownAuthenticators = $plugin->getAuthenticationHelper()->getKnownAuthenticators();
+ $aaguid = (string) ($publicKeyCredentialSource->getAaguid() ?? '');
+ $defaultName = ($knownAuthenticators[$aaguid] ?? $knownAuthenticators[''])->description;
+ $credentialId = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
+ $user = Factory::getApplication()->getIdentity();
+ $o = (object) [
'id' => $credentialId,
'user_id' => $this->getHandleFromUserId($user->id),
- 'label' => Text::sprintf('PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL', Joomla::formatDate('now')),
+ 'label' => Text::sprintf(
+ 'PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL',
+ $defaultName,
+ $this->formatDate('now')
+ ),
'credential' => json_encode($publicKeyCredentialSource),
];
- $update = false;
+ $update = false;
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
// Try to find an existing record
try
@@ -259,7 +287,7 @@ public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredent
public function getAll(int $userId): array
{
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
$userHandle = $this->getHandleFromUserId($userId);
$query = $db->getQuery(true)
->select('*')
@@ -281,7 +309,50 @@ public function getAll(int $userId): array
return [];
}
- return $results;
+ /**
+ * Decodes the credentials on each record.
+ *
+ * @param array $record The record to convert
+ *
+ * @return array
+ * @since __DEPLOY_VERSION__
+ */
+ $recordsMapperClosure = function ($record)
+ {
+ try
+ {
+ $json = $this->decryptCredential($record['credential']);
+ $data = json_decode($json, true);
+ }
+ catch (JsonException $e)
+ {
+ $record['credential'] = null;
+
+ return $record;
+ }
+
+ if (empty($data))
+ {
+ $record['credential'] = null;
+
+ return $record;
+ }
+
+ try
+ {
+ $record['credential'] = PublicKeyCredentialSource::createFromArray($data);
+
+ return $record;
+ }
+ catch (InvalidArgumentException $e)
+ {
+ $record['credential'] = null;
+
+ return $record;
+ }
+ };
+
+ return array_map($recordsMapperClosure, $results);
}
/**
@@ -296,7 +367,7 @@ public function getAll(int $userId): array
public function has(string $credentialId): bool
{
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
$credentialId = base64_encode($credentialId);
$query = $db->getQuery(true)
->select('COUNT(*)')
@@ -329,7 +400,7 @@ public function has(string $credentialId): bool
public function setLabel(string $credentialId, string $label): void
{
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
$credentialId = base64_encode($credentialId);
$o = (object) [
'id' => $credentialId,
@@ -356,7 +427,7 @@ public function remove(string $credentialId): void
}
/** @var DatabaseDriver $db */
- $db = Factory::getContainer()->get('DatabaseDriver');
+ $db = $this->getDatabase();
$credentialId = base64_encode($credentialId);
$query = $db->getQuery(true)
->delete($db->qn('#__webauthn_credentials'))
@@ -410,6 +481,105 @@ public function getHandleFromUserId(int $id): string
return hash_hmac('sha256', $data, $key, false);
}
+ /**
+ * Get the user ID from the user handle
+ *
+ * This is a VERY inefficient method. Since the user handle is an HMAC-SHA-256 of the user ID we can't just go
+ * directly from a handle back to an ID. We have to iterate all user IDs, calculate their handles and compare them
+ * to the given handle.
+ *
+ * To prevent a lengthy infinite loop in case of an invalid user handle we don't iterate the entire 2+ billion valid
+ * 32-bit integer range. We load the user IDs of active users (not blocked, not pending activation) and iterate
+ * through them.
+ *
+ * To avoid memory outage on large sites with thousands of active user records we load up to 10000 users at a time.
+ * Each block of 10,000 user IDs takes about 60-80 msec to iterate. On a site with 200,000 active users this method
+ * will take less than 1.5 seconds. This is slow but not impractical, even on crowded shared hosts with a quarter of
+ * the performance of my test subject (a mid-range, shared hosting server).
+ *
+ * @param string|null $userHandle The user handle which will be converted to a user ID.
+ *
+ * @return integer|null
+ * @since __DEPLOY_VERSION__
+ */
+ public function getUserIdFromHandle(?string $userHandle): ?int
+ {
+ if (empty($userHandle))
+ {
+ return null;
+ }
+
+ /** @var DatabaseDriver $db */
+ $db = $this->getDatabase();
+
+ // Check that the userHandle does exist in the database
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->qn('#__webauthn_credentials'))
+ ->where($db->qn('user_id') . ' = ' . $db->q($userHandle));
+
+ try
+ {
+ $numRecords = $db->setQuery($query)->loadResult();
+ }
+ catch (Exception $e)
+ {
+ return null;
+ }
+
+ if (is_null($numRecords) || ($numRecords < 1))
+ {
+ return null;
+ }
+
+ // Prepare the query
+ $query = $db->getQuery(true)
+ ->select([$db->qn('id')])
+ ->from($db->qn('#__users'))
+ ->where($db->qn('block') . ' = 0')
+ ->where(
+ '(' .
+ $db->qn('activation') . ' IS NULL OR ' .
+ $db->qn('activation') . ' = 0 OR ' .
+ $db->qn('activation') . ' = ' . $db->q('') .
+ ')'
+ );
+
+ $key = $this->getEncryptionKey();
+ $start = 0;
+ $limit = 10000;
+
+ while (true)
+ {
+ try
+ {
+ $ids = $db->setQuery($query, $start, $limit)->loadColumn();
+ }
+ catch (Exception $e)
+ {
+ return null;
+ }
+
+ if (empty($ids))
+ {
+ return null;
+ }
+
+ foreach ($ids as $userId)
+ {
+ $data = sprintf('%010u', $userId);
+ $thisHandle = hash_hmac('sha256', $data, $key, false);
+
+ if ($thisHandle == $userHandle)
+ {
+ return $userId;
+ }
+ }
+
+ $start += $limit;
+ }
+ }
+
/**
* Encrypt the credential source before saving it to the database
*
@@ -485,4 +655,67 @@ private function getEncryptionKey(): string
return $secret;
}
+
+ /**
+ * Format a date for display.
+ *
+ * The $tzAware parameter defines whether the formatted date will be timezone-aware. If set to false the formatted
+ * date will be rendered in the UTC timezone. If set to true the code will automatically try to use the logged in
+ * user's timezone or, if none is set, the site's default timezone (Server Timezone). If set to a positive integer
+ * the same thing will happen but for the specified user ID instead of the currently logged in user.
+ *
+ * @param string|\DateTime $date The date to format
+ * @param string|null $format The format string, default is Joomla's DATE_FORMAT_LC6 (usually "Y-m-d
+ * H:i:s")
+ * @param bool $tzAware Should the format be timezone aware? See notes above.
+ *
+ * @return string
+ * @since __DEPLOY_VERSION__
+ */
+ private function formatDate($date, ?string $format = null, bool $tzAware = true): string
+ {
+ $utcTimeZone = new \DateTimeZone('UTC');
+ $jDate = new Date($date, $utcTimeZone);
+
+ // Which timezone should I use?
+ $tz = null;
+
+ if ($tzAware !== false)
+ {
+ $userId = is_bool($tzAware) ? null : (int) $tzAware;
+
+ try
+ {
+ $tzDefault = Factory::getApplication()->get('offset');
+ }
+ catch (\Exception $e)
+ {
+ $tzDefault = 'GMT';
+ }
+
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId ?? 0);
+ $tz = $user->getParam('timezone', $tzDefault);
+ }
+
+ if (!empty($tz))
+ {
+ try
+ {
+ $userTimeZone = new \DateTimeZone($tz);
+
+ $jDate->setTimezone($userTimeZone);
+ }
+ catch (\Exception $e)
+ {
+ // Nothing. Fall back to UTC.
+ }
+ }
+
+ if (empty($format))
+ {
+ $format = Text::_('DATE_FORMAT_LC6');
+ }
+
+ return $jDate->format($format, true);
+ }
}
diff --git a/plugins/system/webauthn/src/Exception/AjaxNonCmsAppException.php b/plugins/system/webauthn/src/Exception/AjaxNonCmsAppException.php
deleted file mode 100644
index fcf3e3f98f615..0000000000000
--- a/plugins/system/webauthn/src/Exception/AjaxNonCmsAppException.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-namespace Joomla\Plugin\System\Webauthn\Exception;
-
-// Protect from unauthorized access
-\defined('_JEXEC') or die();
-
-use RuntimeException;
-
-/**
- * Exception indicating that the Joomla application object is not a CMSApplication subclass.
- *
- * @since 4.0.0
- */
-class AjaxNonCmsAppException extends RuntimeException
-{
-}
diff --git a/plugins/system/webauthn/src/Extension/Webauthn.php b/plugins/system/webauthn/src/Extension/Webauthn.php
new file mode 100644
index 0000000000000..787b41d3699bd
--- /dev/null
+++ b/plugins/system/webauthn/src/Extension/Webauthn.php
@@ -0,0 +1,189 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Webauthn\Extension;
+
+// Protect from unauthorized access
+defined('_JEXEC') or die();
+
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Application\CMSApplicationInterface;
+use Joomla\CMS\Event\CoreEventAware;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\Database\DatabaseAwareInterface;
+use Joomla\Database\DatabaseAwareTrait;
+use Joomla\Database\DatabaseDriver;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Plugin\System\Webauthn\Authentication;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AdditionalLoginButtons;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandler;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerChallenge;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerCreate;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerDelete;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerInitCreate;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerLogin;
+use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerSaveLabel;
+use Joomla\Plugin\System\Webauthn\PluginTraits\EventReturnAware;
+use Joomla\Plugin\System\Webauthn\PluginTraits\UserDeletion;
+use Joomla\Plugin\System\Webauthn\PluginTraits\UserProfileFields;
+
+/**
+ * WebAuthn Passwordless Login plugin
+ *
+ * The plugin features are broken down into Traits for the sole purpose of making an otherwise
+ * supermassive class somewhat manageable. You can find the Traits inside the Webauthn/PluginTraits
+ * folder.
+ *
+ * @since 4.0.0
+ */
+final class Webauthn extends CMSPlugin implements SubscriberInterface
+{
+ use CoreEventAware;
+
+ /**
+ * Autoload the language files
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * Should I try to detect and register legacy event listeners?
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated
+ */
+ protected $allowLegacyListeners = false;
+
+ /**
+ * The WebAuthn authentication helper object
+ *
+ * @var Authentication
+ * @since __DEPLOY_VERSION__
+ */
+ protected $authenticationHelper;
+
+ // AJAX request handlers
+ use AjaxHandler;
+ use AjaxHandlerInitCreate;
+ use AjaxHandlerCreate;
+ use AjaxHandlerSaveLabel;
+ use AjaxHandlerDelete;
+ use AjaxHandlerChallenge;
+ use AjaxHandlerLogin;
+
+ // Custom user profile fields
+ use UserProfileFields;
+
+ // Handle user profile deletion
+ use UserDeletion;
+
+ // Add WebAuthn buttons
+ use AdditionalLoginButtons;
+
+ // Utility methods for setting the events' return values
+ use EventReturnAware;
+
+ /**
+ * Constructor. Loads the language files as well.
+ *
+ * @param DispatcherInterface $subject The object to observe
+ * @param array $config An optional associative array of configuration
+ * settings. Recognized key values include 'name',
+ * 'group', 'params', 'language (this list is not meant
+ * to be comprehensive).
+ * @param Authentication|null $authHelper The WebAuthn helper object
+ *
+ * @since 4.0.0
+ */
+ public function __construct(&$subject, array $config = [], Authentication $authHelper = null)
+ {
+ parent::__construct($subject, $config);
+
+ /**
+ * Note: Do NOT try to load the language in the constructor. This is called before Joomla initializes the
+ * application language. Therefore the temporary Joomla language object and all loaded strings in it will be
+ * destroyed on application initialization. As a result we need to call loadLanguage() in each method
+ * individually, even though all methods make use of language strings.
+ */
+
+ // Register a debug log file writer
+ $logLevels = Log::ERROR | Log::CRITICAL | Log::ALERT | Log::EMERGENCY;
+
+ if (\defined('JDEBUG') && JDEBUG)
+ {
+ $logLevels = Log::ALL;
+ }
+
+ Log::addLogger([
+ 'text_file' => "webauthn_system.php",
+ 'text_entry_format' => '{DATETIME} {PRIORITY} {CLIENTIP} {MESSAGE}',
+ ], $logLevels, ["webauthn.system"]
+ );
+
+ $this->authenticationHelper = $authHelper ?? (new Authentication);
+ $this->authenticationHelper->setAttestationSupport($this->params->get('attestationSupport', 1) == 1);
+ }
+
+ /**
+ * Returns the Authentication helper object
+ *
+ * @return Authentication
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getAuthenticationHelper(): Authentication
+ {
+ return $this->authenticationHelper;
+ }
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ try
+ {
+ $app = Factory::getApplication();
+ }
+ catch (\Exception $e)
+ {
+ return [];
+ }
+
+ if (!$app->isClient('site') && !$app->isClient('administrator'))
+ {
+ return [];
+ }
+
+ return [
+ 'onAjaxWebauthn' => 'onAjaxWebauthn',
+ 'onAjaxWebauthnChallenge' => 'onAjaxWebauthnChallenge',
+ 'onAjaxWebauthnCreate' => 'onAjaxWebauthnCreate',
+ 'onAjaxWebauthnDelete' => 'onAjaxWebauthnDelete',
+ 'onAjaxWebauthnInitcreate' => 'onAjaxWebauthnInitcreate',
+ 'onAjaxWebauthnLogin' => 'onAjaxWebauthnLogin',
+ 'onAjaxWebauthnSavelabel' => 'onAjaxWebauthnSavelabel',
+ 'onUserAfterDelete' => 'onUserAfterDelete',
+ 'onUserLoginButtons' => 'onUserLoginButtons',
+ 'onContentPrepareForm' => 'onContentPrepareForm',
+ 'onContentPrepareData' => 'onContentPrepareData',
+ ];
+ }
+}
diff --git a/plugins/system/webauthn/src/Field/WebauthnField.php b/plugins/system/webauthn/src/Field/WebauthnField.php
index 973c576c5c01a..c4dbc1158413d 100644
--- a/plugins/system/webauthn/src/Field/WebauthnField.php
+++ b/plugins/system/webauthn/src/Field/WebauthnField.php
@@ -16,9 +16,9 @@
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
+use Joomla\CMS\Layout\FileLayout;
use Joomla\CMS\User\UserFactoryInterface;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
/**
* Custom Joomla Form Field to display the WebAuthn interface
@@ -58,17 +58,25 @@ public function getInput()
Text::script('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_CANCEL_LABEL', true);
Text::script('PLG_SYSTEM_WEBAUTHN_MSG_SAVED_LABEL', true);
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED', true);
+ Text::script('PLG_SYSTEM_WEBAUTHN_ERR_XHR_INITCREATE', true);
$app = Factory::getApplication();
- $credentialRepository = new CredentialRepository;
+ /** @var Webauthn $plugin */
+ $plugin = $app->bootPlugin('webauthn', 'system');
$app->getDocument()->getWebAssetManager()
->registerAndUseScript('plg_system_webauthn.management', 'plg_system_webauthn/management.js', [], ['defer' => true], ['core']);
- return Joomla::renderLayout('plugins.system.webauthn.manage', [
- 'user' => Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId),
- 'allow_add' => $userId == $app->getIdentity()->id,
- 'credentials' => $credentialRepository->getAll($userId),
+ $layoutFile = new FileLayout('plugins.system.webauthn.manage');
+
+ return $layoutFile->render([
+ 'user' => Factory::getContainer()
+ ->get(UserFactoryInterface::class)
+ ->loadUserById($userId),
+ 'allow_add' => $userId == $app->getIdentity()->id,
+ 'credentials' => $plugin->getAuthenticationHelper()->getCredentialsRepository()->getAll($userId),
+ 'knownAuthenticators' => $plugin->getAuthenticationHelper()->getKnownAuthenticators(),
+ 'attestationSupport' => $plugin->getAuthenticationHelper()->hasAttestationSupport(),
]
);
}
diff --git a/plugins/system/webauthn/src/Helper/CredentialsCreation.php b/plugins/system/webauthn/src/Helper/CredentialsCreation.php
deleted file mode 100644
index ed8dc34f24afa..0000000000000
--- a/plugins/system/webauthn/src/Helper/CredentialsCreation.php
+++ /dev/null
@@ -1,358 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-namespace Joomla\Plugin\System\Webauthn\Helper;
-
-// Protect from unauthorized access
-\defined('_JEXEC') or die();
-
-use CBOR\Decoder;
-use CBOR\OtherObject\OtherObjectManager;
-use CBOR\Tag\TagObjectManager;
-use Cose\Algorithm\Manager;
-use Cose\Algorithm\Signature\ECDSA;
-use Cose\Algorithm\Signature\EdDSA;
-use Cose\Algorithm\Signature\RSA;
-use Cose\Algorithms;
-use Exception;
-use Joomla\CMS\Application\CMSApplication;
-use Joomla\CMS\Crypt\Crypt;
-use Joomla\CMS\Factory;
-use Joomla\CMS\Language\Text;
-use Joomla\CMS\Uri\Uri;
-use Joomla\CMS\User\User;
-use Joomla\CMS\User\UserFactoryInterface;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
-use Laminas\Diactoros\ServerRequestFactory;
-use RuntimeException;
-use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
-use Webauthn\AttestationStatement\AttestationObjectLoader;
-use Webauthn\AttestationStatement\AttestationStatementSupportManager;
-use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
-use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
-use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
-use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
-use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
-use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
-use Webauthn\AuthenticatorAttestationResponse;
-use Webauthn\AuthenticatorAttestationResponseValidator;
-use Webauthn\AuthenticatorSelectionCriteria;
-use Webauthn\PublicKeyCredentialCreationOptions;
-use Webauthn\PublicKeyCredentialDescriptor;
-use Webauthn\PublicKeyCredentialLoader;
-use Webauthn\PublicKeyCredentialParameters;
-use Webauthn\PublicKeyCredentialRpEntity;
-use Webauthn\PublicKeyCredentialSource;
-use Webauthn\PublicKeyCredentialUserEntity;
-use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
-
-/**
- * Helper class to aid in credentials creation (link an authenticator to a user account)
- *
- * @since 4.0.0
- */
-abstract class CredentialsCreation
-{
- /**
- * Create a public key for credentials creation. The result is a JSON string which can be used in Javascript code
- * with navigator.credentials.create().
- *
- * @param User $user The Joomla user to create the public key for
- *
- * @return string
- *
- * @since 4.0.0
- */
- public static function createPublicKey(User $user): string
- {
- /** @var CMSApplication $app */
- try
- {
- $app = Factory::getApplication();
- $siteName = $app->getConfig()->get('sitename', 'Joomla! Site');
- }
- catch (Exception $e)
- {
- $siteName = 'Joomla! Site';
- }
-
- // Credentials repository
- $repository = new CredentialRepository;
-
- // Relaying Party -- Our site
- $rpEntity = new PublicKeyCredentialRpEntity(
- $siteName,
- Uri::getInstance()->toString(['host']),
- self::getSiteIcon()
- );
-
- // User Entity
- $userEntity = new PublicKeyCredentialUserEntity(
- $user->username,
- $repository->getHandleFromUserId($user->id),
- $user->name
- );
-
- // Challenge
- try
- {
- $challenge = random_bytes(32);
- }
- catch (Exception $e)
- {
- $challenge = Crypt::genRandomBytes(32);
- }
-
- // Public Key Credential Parameters
- $publicKeyCredentialParametersList = [
- new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256),
- ];
-
- // Timeout: 60 seconds (given in milliseconds)
- $timeout = 60000;
-
- // Devices to exclude (already set up authenticators)
- $excludedPublicKeyDescriptors = [];
- $records = $repository->findAllForUserEntity($userEntity);
-
- /** @var PublicKeyCredentialSource $record */
- foreach ($records as $record)
- {
- $excludedPublicKeyDescriptors[] = new PublicKeyCredentialDescriptor($record->getType(), $record->getCredentialPublicKey());
- }
-
- // Authenticator Selection Criteria (we used default values)
- $authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria;
-
- // Extensions (not yet supported by the library)
- $extensions = new AuthenticationExtensionsClientInputs;
-
- // Attestation preference
- $attestationPreference = PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
-
- // Public key credential creation options
- $publicKeyCredentialCreationOptions = new PublicKeyCredentialCreationOptions(
- $rpEntity,
- $userEntity,
- $challenge,
- $publicKeyCredentialParametersList,
- $timeout,
- $excludedPublicKeyDescriptors,
- $authenticatorSelectionCriteria,
- $attestationPreference,
- $extensions
- );
-
- // Save data in the session
- Joomla::setSessionVar('publicKeyCredentialCreationOptions',
- base64_encode(serialize($publicKeyCredentialCreationOptions)),
- 'plg_system_webauthn'
- );
- Joomla::setSessionVar('registration_user_id', $user->id, 'plg_system_webauthn');
-
- return json_encode($publicKeyCredentialCreationOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
- }
-
- /**
- * Validate the authentication data returned by the device and return the public key credential source on success.
- *
- * An exception will be returned on error. Also, under very rare conditions, you may receive NULL instead of
- * a PublicKeyCredentialSource object which means that something was off in the returned data from the browser.
- *
- * @param string $data The JSON-encoded data returned by the browser during the authentication flow
- *
- * @return PublicKeyCredentialSource|null
- *
- * @since 4.0.0
- */
- public static function validateAuthenticationData(string $data): ?PublicKeyCredentialSource
- {
- // Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
- $encodedOptions = Joomla::getSessionVar('publicKeyCredentialCreationOptions', null, 'plg_system_webauthn');
-
- if (empty($encodedOptions))
- {
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
- }
-
- try
- {
- $publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
- }
- catch (Exception $e)
- {
- $publicKeyCredentialCreationOptions = null;
- }
-
- if (!\is_object($publicKeyCredentialCreationOptions) || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialCreationOptions))
- {
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
- }
-
- // Retrieve the stored user ID and make sure it's the same one in the request.
- $storedUserId = Joomla::getSessionVar('registration_user_id', 0, 'plg_system_webauthn');
-
- try
- {
- $myUser = Factory::getApplication()->getIdentity();
- }
- catch (Exception $e)
- {
- $dummyUserId = 0;
- $myUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($dummyUserId);
- }
-
- $myUserId = $myUser->id;
-
- if (($myUser->guest) || ($myUserId != $storedUserId))
- {
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
- }
-
- // Cose Algorithm Manager
- $coseAlgorithmManager = new Manager;
- $coseAlgorithmManager->add(new ECDSA\ES256);
- $coseAlgorithmManager->add(new ECDSA\ES512);
- $coseAlgorithmManager->add(new EdDSA\EdDSA);
- $coseAlgorithmManager->add(new RSA\RS1);
- $coseAlgorithmManager->add(new RSA\RS256);
- $coseAlgorithmManager->add(new RSA\RS512);
-
- // Create a CBOR Decoder object
- $otherObjectManager = new OtherObjectManager;
- $tagObjectManager = new TagObjectManager;
- $decoder = new Decoder($tagObjectManager, $otherObjectManager);
-
- // The token binding handler
- $tokenBindingHandler = new TokenBindingNotSupportedHandler;
-
- // Attestation Statement Support Manager
- $attestationStatementSupportManager = new AttestationStatementSupportManager;
- $attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
- $attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport($decoder));
-
- /**
- $attestationStatementSupportManager->add(
- new AndroidSafetyNetAttestationStatementSupport(HttpFactory::getHttp(),
- 'GOOGLE_SAFETYNET_API_KEY',
- new RequestFactory
- )
- );
- */
- $attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport($decoder));
- $attestationStatementSupportManager->add(new TPMAttestationStatementSupport);
- $attestationStatementSupportManager->add(new PackedAttestationStatementSupport($decoder, $coseAlgorithmManager));
-
- // Attestation Object Loader
- $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager, $decoder);
-
- // Public Key Credential Loader
- $publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader, $decoder);
-
- // Credential Repository
- $credentialRepository = new CredentialRepository;
-
- // Extension output checker handler
- $extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler;
-
- // Authenticator Attestation Response Validator
- $authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
- $attestationStatementSupportManager,
- $credentialRepository,
- $tokenBindingHandler,
- $extensionOutputCheckerHandler
- );
-
- // Any Throwable from this point will bubble up to the GUI
-
- // We init the PSR-7 request object using Diactoros
- $request = ServerRequestFactory::fromGlobals();
-
- // Load the data
- $publicKeyCredential = $publicKeyCredentialLoader->load(base64_decode($data));
- $response = $publicKeyCredential->getResponse();
-
- // Check if the response is an Authenticator Attestation Response
- if (!$response instanceof AuthenticatorAttestationResponse)
- {
- throw new RuntimeException('Not an authenticator attestation response');
- }
-
- // Check the response against the request
- $authenticatorAttestationResponseValidator->check($response, $publicKeyCredentialCreationOptions, $request);
-
- /**
- * Everything is OK here. You can get the Public Key Credential Source. This object should be persisted using
- * the Public Key Credential Source repository.
- */
- return PublicKeyCredentialSource::createFromPublicKeyCredential(
- $publicKeyCredential,
- $publicKeyCredentialCreationOptions->getUser()->getId()
- );
- }
-
- /**
- * Try to find the site's favicon in the site's root, images, media, templates or current template directory.
- *
- * @return string|null
- *
- * @since 4.0.0
- */
- protected static function getSiteIcon(): ?string
- {
- $filenames = [
- 'apple-touch-icon.png',
- 'apple_touch_icon.png',
- 'favicon.ico',
- 'favicon.png',
- 'favicon.gif',
- 'favicon.bmp',
- 'favicon.jpg',
- 'favicon.svg',
- ];
-
- try
- {
- $paths = [
- '/',
- '/images/',
- '/media/',
- '/templates/',
- '/templates/' . Factory::getApplication()->getTemplate(),
- ];
- }
- catch (Exception $e)
- {
- return null;
- }
-
- foreach ($paths as $path)
- {
- foreach ($filenames as $filename)
- {
- $relFile = $path . $filename;
- $filePath = JPATH_BASE . $relFile;
-
- if (is_file($filePath))
- {
- break 2;
- }
-
- $relFile = null;
- }
- }
-
- if (!isset($relFile) || \is_null($relFile))
- {
- return null;
- }
-
- return rtrim(Uri::base(), '/') . '/' . ltrim($relFile, '/');
- }
-}
diff --git a/plugins/system/webauthn/src/Helper/Joomla.php b/plugins/system/webauthn/src/Helper/Joomla.php
deleted file mode 100644
index 4d6deae9bf44d..0000000000000
--- a/plugins/system/webauthn/src/Helper/Joomla.php
+++ /dev/null
@@ -1,744 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-namespace Joomla\Plugin\System\Webauthn\Helper;
-
-// Protect from unauthorized access
-\defined('_JEXEC') or die();
-
-use DateTime;
-use DateTimeZone;
-use Exception;
-use JLoader;
-use Joomla\Application\AbstractApplication;
-use Joomla\CMS\Application\CliApplication;
-use Joomla\CMS\Application\CMSApplication;
-use Joomla\CMS\Application\ConsoleApplication;
-use Joomla\CMS\Authentication\Authentication;
-use Joomla\CMS\Authentication\AuthenticationResponse;
-use Joomla\CMS\Date\Date;
-use Joomla\CMS\Factory;
-use Joomla\CMS\Language\Text;
-use Joomla\CMS\Layout\FileLayout;
-use Joomla\CMS\Log\Log;
-use Joomla\CMS\Plugin\PluginHelper;
-use Joomla\CMS\User\User;
-use Joomla\CMS\User\UserFactoryInterface;
-use Joomla\CMS\User\UserHelper;
-use Joomla\Registry\Registry;
-use RuntimeException;
-
-/**
- * A helper class for abstracting core features in Joomla! 3.4 and later, including 4.x
- *
- * @since 4.0.0
- */
-abstract class Joomla
-{
- /**
- * A fake session storage for CLI apps. Since CLI applications cannot have a session we are
- * using a Registry object we manage internally.
- *
- * @var Registry
- * @since 4.0.0
- */
- protected static $fakeSession = null;
-
- /**
- * Are we inside the administrator application
- *
- * @var boolean
- * @since 4.0.0
- */
- protected static $isAdmin = null;
-
- /**
- * Are we inside a CLI application
- *
- * @var boolean
- * @since 4.0.0
- */
- protected static $isCli = null;
-
- /**
- * Which plugins have already registered a text file logger. Prevents double registration of a
- * log file.
- *
- * @var array
- * @since 4.0.0
- */
- protected static $registeredLoggers = [];
-
- /**
- * The current Joomla Document type
- *
- * @var string|null
- * @since 4.0.0
- */
- protected static $joomlaDocumentType = null;
-
- /**
- * Is the current user allowed to edit the social login configuration of $user? To do so I must
- * either be editing my own account OR I have to be a Super User.
- *
- * @param User $user The user you want to know if we're allowed to edit
- *
- * @return boolean
- *
- * @since 4.0.0
- */
- public static function canEditUser(User $user = null): bool
- {
- // I can edit myself
- if (empty($user))
- {
- return true;
- }
-
- // Guests can't have social logins associated
- if ($user->guest)
- {
- return false;
- }
-
- // Get the currently logged in used
- try
- {
- $myUser = Factory::getApplication()->getIdentity();
- }
- catch (Exception $e)
- {
- // Cannot get the application; no user, therefore no edit privileges.
- return false;
- }
-
- // Same user? I can edit myself
- if ($myUser->id == $user->id)
- {
- return true;
- }
-
- // To edit a different user I must be a Super User myself. If I'm not, I can't edit another user!
- if (!$myUser->authorise('core.admin'))
- {
- return false;
- }
-
- // I am a Super User editing another user. That's allowed.
- return true;
- }
-
- /**
- * Helper method to render a JLayout.
- *
- * @param string $layoutFile Dot separated path to the layout file, relative to base path
- * (plugins/system/webauthn/layout)
- * @param object $displayData Object which properties are used inside the layout file to
- * build displayed output
- * @param string $includePath Additional path holding layout files
- * @param mixed $options Optional custom options to load. Registry or array format.
- * Set 'debug'=>true to output debug information.
- *
- * @return string
- *
- * @since 4.0.0
- */
- public static function renderLayout(string $layoutFile, $displayData = null,
- string $includePath = '', array $options = []
- ): string
- {
- $basePath = JPATH_SITE . '/plugins/system/webauthn/layout';
- $layout = new FileLayout($layoutFile, $basePath, $options);
-
- if (!empty($includePath))
- {
- $layout->addIncludePath($includePath);
- }
-
- return $layout->render($displayData);
- }
-
- /**
- * Unset a variable from the user session
- *
- * This method cannot be replaced with a call to Factory::getSession->set(). This method takes
- * into account running under CLI, using a fake session storage. In the end of the day this
- * plugin doesn't work under CLI but being able to fake session storage under CLI means that we
- * don't have to add gnarly if-blocks everywhere in the code to make sure it doesn't break CLI
- * either!
- *
- * @param string $name The name of the variable to unset
- * @param string $namespace (optional) The variable's namespace e.g. the component name.
- * Default: 'default'
- *
- * @return void
- *
- * @since 4.0.0
- */
- public static function unsetSessionVar(string $name, string $namespace = 'default'): void
- {
- self::setSessionVar($name, null, $namespace);
- }
-
- /**
- * Set a variable in the user session.
- *
- * This method cannot be replaced with a call to Factory::getSession->set(). This method takes
- * into account running under CLI, using a fake session storage. In the end of the day this
- * plugin doesn't work under CLI but being able to fake session storage under CLI means that we
- * don't have to add gnarly if-blocks everywhere in the code to make sure it doesn't break CLI
- * either!
- *
- * @param string $name The name of the variable to set
- * @param string $value (optional) The value to set it to, default is null
- * @param string $namespace (optional) The variable's namespace e.g. the component name.
- * Default: 'default'
- *
- * @return void
- *
- * @since 4.0.0
- */
- public static function setSessionVar(string $name, ?string $value = null,
- string $namespace = 'default'
- ): void
- {
- $qualifiedKey = "$namespace.$name";
-
- if (self::isCli())
- {
- self::getFakeSession()->set($qualifiedKey, $value);
-
- return;
- }
-
- try
- {
- Factory::getApplication()->getSession()->set($qualifiedKey, $value);
- }
- catch (Exception $e)
- {
- return;
- }
- }
-
- /**
- * Are we inside a CLI application
- *
- * @param CMSApplication $app The current CMS application which tells us if we are inside
- * an admin page
- *
- * @return boolean
- *
- * @since 4.0.0
- */
- public static function isCli(CMSApplication $app = null): bool
- {
- if (\is_null(self::$isCli))
- {
- if (\is_null($app))
- {
- try
- {
- $app = Factory::getApplication();
- }
- catch (Exception $e)
- {
- $app = null;
- }
- }
-
- if (\is_null($app))
- {
- self::$isCli = true;
- }
-
- if (\is_object($app))
- {
- self::$isCli = $app instanceof Exception;
-
- if (class_exists('Joomla\\CMS\\Application\\CliApplication'))
- {
- self::$isCli = self::$isCli || $app instanceof CliApplication || $app instanceof ConsoleApplication;
- }
- }
- }
-
- return self::$isCli;
- }
-
- /**
- * Get a fake session registry for CLI applications
- *
- * @return Registry
- *
- * @since 4.0.0
- */
- protected static function getFakeSession(): Registry
- {
- if (!\is_object(self::$fakeSession))
- {
- self::$fakeSession = new Registry;
- }
-
- return self::$fakeSession;
- }
-
- /**
- * Return the session token. This method goes through our session abstraction to prevent a
- * fatal exception if it's accidentally called under CLI.
- *
- * @return mixed
- *
- * @since 4.0.0
- */
- public static function getToken(): string
- {
- // For CLI apps we implement our own fake token system
- if (self::isCli())
- {
- $token = self::getSessionVar('session.token');
-
- // Create a token
- if (\is_null($token))
- {
- $token = UserHelper::genRandomPassword(32);
-
- self::setSessionVar('session.token', $token);
- }
-
- return (string) $token;
- }
-
- // Web application, go through the regular Joomla! API.
- try
- {
- return Factory::getApplication()->getSession()->getToken();
- }
- catch (Exception $e)
- {
- return '';
- }
- }
-
- /**
- * Get a variable from the user session
- *
- * This method cannot be replaced with a call to Factory::getSession->get(). This method takes
- * into account running under CLI, using a fake session storage. In the end of the day this
- * plugin doesn't work under CLI but being able to fake session storage under CLI means that we
- * don't have to add gnarly if-blocks everywhere in the code to make sure it doesn't break CLI
- * either!
- *
- * @param string $name The name of the variable to set
- * @param string $default (optional) The default value to return if the variable does not
- * exit, default: null
- * @param string $namespace (optional) The variable's namespace e.g. the component name.
- * Default: 'default'
- *
- * @return mixed
- *
- * @since 4.0.0
- */
- public static function getSessionVar(string $name, ?string $default = null,
- string $namespace = 'default'
- )
- {
- $qualifiedKey = "$namespace.$name";
-
- if (self::isCli())
- {
- return self::getFakeSession()->get("$namespace.$name", $default);
- }
-
- try
- {
- return Factory::getApplication()->getSession()->get($qualifiedKey, $default);
- }
- catch (Exception $e)
- {
- return $default;
- }
- }
-
- /**
- * Register a debug log file writer for a Social Login plugin.
- *
- * @param string $plugin The Social Login plugin for which to register a debug log file
- * writer
- *
- * @return void
- *
- * @since 4.0.0
- */
- public static function addLogger(string $plugin): void
- {
- // Make sure this logger is not already registered
- if (\in_array($plugin, self::$registeredLoggers))
- {
- return;
- }
-
- self::$registeredLoggers[] = $plugin;
-
- // We only log errors unless Site Debug is enabled
- $logLevels = Log::ERROR | Log::CRITICAL | Log::ALERT | Log::EMERGENCY;
-
- if (\defined('JDEBUG') && JDEBUG)
- {
- $logLevels = Log::ALL;
- }
-
- // Add a formatted text logger
- Log::addLogger([
- 'text_file' => "webauthn_{$plugin}.php",
- 'text_entry_format' => '{DATETIME} {PRIORITY} {CLIENTIP} {MESSAGE}',
- ], $logLevels, [
- "webauthn.{$plugin}",
- ]
- );
- }
-
- /**
- * Logs in a user to the site, bypassing the authentication plugins.
- *
- * @param int $userId The user ID to log in
- * @param AbstractApplication $app The application we are running in. Skip to
- * auto-detect (recommended).
- *
- * @return void
- *
- * @throws Exception
- *
- * @since 4.0.0
- */
- public static function loginUser(int $userId, AbstractApplication $app = null): void
- {
- // Trick the class auto-loader into loading the necessary classes
- class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
-
- // Fake a successful login message
- if (!\is_object($app))
- {
- $app = Factory::getApplication();
- }
-
- $isAdmin = $app->isClient('administrator');
- /** @var User $user */
- $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
-
- // Does the user account have a pending activation?
- if (!empty($user->activation))
- {
- throw new RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
- }
-
- // Is the user account blocked?
- if ($user->block)
- {
- throw new RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
- }
-
- $statusSuccess = Authentication::STATUS_SUCCESS;
-
- $response = self::getAuthenticationResponseObject();
- $response->status = $statusSuccess;
- $response->username = $user->username;
- $response->fullname = $user->name;
- // phpcs:ignore
- $response->error_message = '';
- $response->language = $user->getParam('language');
- $response->type = 'Passwordless';
-
- if ($isAdmin)
- {
- $response->language = $user->getParam('admin_language');
- }
-
- /**
- * Set up the login options.
- *
- * The 'remember' element forces the use of the Remember Me feature when logging in with Webauthn, as the
- * users would expect.
- *
- * The 'action' element is actually required by plg_user_joomla. It is the core ACL action the logged in user
- * must be allowed for the login to succeed. Please note that front-end and back-end logins use a different
- * action. This allows us to provide the social login button on both front- and back-end and be sure that if a
- * used with no backend access tries to use it to log in Joomla! will just slap him with an error message about
- * insufficient privileges - the same thing that'd happen if you tried to use your front-end only username and
- * password in a back-end login form.
- */
- $options = [
- 'remember' => true,
- 'action' => 'core.login.site',
- ];
-
- if (self::isAdminPage())
- {
- $options['action'] = 'core.login.admin';
- }
-
- // Run the user plugins. They CAN block login by returning boolean false and setting $response->error_message.
- PluginHelper::importPlugin('user');
-
- /** @var CMSApplication $app */
- $results = $app->triggerEvent('onUserLogin', [(array) $response, $options]);
-
- // If there is no boolean FALSE result from any plugin the login is successful.
- if (\in_array(false, $results, true) == false)
- {
- // Set the user in the session, letting Joomla! know that we are logged in.
- $app->getSession()->set('user', $user);
-
- // Trigger the onUserAfterLogin event
- $options['user'] = $user;
- $options['responseType'] = $response->type;
-
- // The user is successfully logged in. Run the after login events
- $app->triggerEvent('onUserAfterLogin', [$options]);
-
- return;
- }
-
- // If we are here the plugins marked a login failure. Trigger the onUserLoginFailure Event.
- $app->triggerEvent('onUserLoginFailure', [(array) $response]);
-
- // Log the failure
- // phpcs:ignore
- Log::add($response->error_message, Log::WARNING, 'jerror');
-
- // Throw an exception to let the caller know that the login failed
- // phpcs:ignore
- throw new RuntimeException($response->error_message);
- }
-
- /**
- * Returns a (blank) Joomla! authentication response
- *
- * @return AuthenticationResponse
- *
- * @since 4.0.0
- */
- public static function getAuthenticationResponseObject(): AuthenticationResponse
- {
- // Force the class auto-loader to load the JAuthentication class
- JLoader::import('joomla.user.authentication');
- class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
-
- return new AuthenticationResponse;
- }
-
- /**
- * Are we inside an administrator page?
- *
- * @param CMSApplication $app The current CMS application which tells us if we are inside
- * an admin page
- *
- * @return boolean
- *
- * @throws Exception
- *
- * @since 4.0.0
- */
- public static function isAdminPage(CMSApplication $app = null): bool
- {
- if (\is_null(self::$isAdmin))
- {
- if (\is_null($app))
- {
- $app = Factory::getApplication();
- }
-
- self::$isAdmin = $app->isClient('administrator');
- }
-
- return self::$isAdmin;
- }
-
- /**
- * Have Joomla! process a login failure
- *
- * @param AuthenticationResponse $response The Joomla! auth response object
- * @param AbstractApplication $app The application we are running in. Skip to
- * auto-detect (recommended).
- * @param string $logContext Logging context (plugin name). Default:
- * system.
- *
- * @return boolean
- *
- * @throws Exception
- *
- * @since 4.0.0
- */
- public static function processLoginFailure(AuthenticationResponse $response,
- AbstractApplication $app = null,
- string $logContext = 'system'
- )
- {
- // Import the user plugin group.
- PluginHelper::importPlugin('user');
-
- if (!\is_object($app))
- {
- $app = Factory::getApplication();
- }
-
- // Trigger onUserLoginFailure Event.
- self::log($logContext, "Calling onUserLoginFailure plugin event");
- /** @var CMSApplication $app */
- $app->triggerEvent('onUserLoginFailure', [(array) $response]);
-
- // If status is success, any error will have been raised by the user plugin
- $expectedStatus = Authentication::STATUS_SUCCESS;
-
- if ($response->status !== $expectedStatus)
- {
- self::log($logContext, "The login failure has been logged in Joomla's error log");
-
- // Everything logged in the 'jerror' category ends up being enqueued in the application message queue.
- // phpcs:ignore
- Log::add($response->error_message, Log::WARNING, 'jerror');
- }
- else
- {
- $message = "The login failure was caused by a third party user plugin but it did not " .
- "return any further information. Good luck figuring this one out...";
- self::log($logContext, $message, Log::WARNING);
- }
-
- return false;
- }
-
- /**
- * Writes a log message to the debug log
- *
- * @param string $plugin The Social Login plugin which generated this log message
- * @param string $message The message to write to the log
- * @param int $priority Log message priority, default is Log::DEBUG
- *
- * @return void
- *
- * @since 4.0.0
- */
- public static function log(string $plugin, string $message, $priority = Log::DEBUG): void
- {
- Log::add($message, $priority, 'webauthn.' . $plugin);
- }
-
- /**
- * Format a date for display.
- *
- * The $tzAware parameter defines whether the formatted date will be timezone-aware. If set to
- * false the formatted date will be rendered in the UTC timezone. If set to true the code will
- * automatically try to use the logged in user's timezone or, if none is set, the site's
- * default timezone (Server Timezone). If set to a positive integer the same thing will happen
- * but for the specified user ID instead of the currently logged in user.
- *
- * @param string|DateTime $date The date to format
- * @param string $format The format string, default is Joomla's DATE_FORMAT_LC6
- * (usually "Y-m-d H:i:s")
- * @param bool|int $tzAware Should the format be timezone aware? See notes above.
- *
- * @return string
- *
- * @since 4.0.0
- */
- public static function formatDate($date, ?string $format = null, bool $tzAware = true): string
- {
- $utcTimeZone = new DateTimeZone('UTC');
- $jDate = new Date($date, $utcTimeZone);
-
- // Which timezone should I use?
- $tz = null;
-
- if ($tzAware !== false)
- {
- $userId = \is_bool($tzAware) ? null : (int) $tzAware;
-
- try
- {
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $tzDefault = $app->get('offset');
- }
- catch (Exception $e)
- {
- $tzDefault = 'GMT';
- }
-
- /** @var User $user */
- if (empty($userId))
- {
- $user = $app->getIdentity();
- }
- else
- {
- $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
- }
-
- $tz = $user->getParam('timezone', $tzDefault);
- }
-
- if (!empty($tz))
- {
- try
- {
- $userTimeZone = new DateTimeZone($tz);
-
- $jDate->setTimezone($userTimeZone);
- }
- catch (Exception $e)
- {
- // Nothing. Fall back to UTC.
- }
- }
-
- if (empty($format))
- {
- $format = Text::_('DATE_FORMAT_LC6');
- }
-
- return $jDate->format($format, true);
- }
-
- /**
- * Returns the current Joomla document type.
- *
- * The error catching is necessary because the application document object or even the
- * application object itself may have not yet been initialized. For example, a system plugin
- * running inside a custom application object which does not create a document object or which
- * does not go through Joomla's Factory to create the application object. In practice these are
- * CLI and custom web applications used for maintenance and third party service callbacks. They
- * end up loading the system plugins but either don't go through Factory or at least don't
- * create a document object.
- *
- * @return string
- *
- * @since 4.0.0
- */
- public static function getDocumentType(): string
- {
- if (\is_null(self::$joomlaDocumentType))
- {
- try
- {
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $document = $app->getDocument();
- }
- catch (Exception $e)
- {
- $document = null;
- }
-
- self::$joomlaDocumentType = (\is_null($document)) ? 'error' : $document->getType();
- }
-
- return self::$joomlaDocumentType;
- }
-}
diff --git a/plugins/system/webauthn/src/Hotfix/AndroidKeyAttestationStatementSupport.php b/plugins/system/webauthn/src/Hotfix/AndroidKeyAttestationStatementSupport.php
new file mode 100644
index 0000000000000..c280ddfd5fd5d
--- /dev/null
+++ b/plugins/system/webauthn/src/Hotfix/AndroidKeyAttestationStatementSupport.php
@@ -0,0 +1,270 @@
+
+ * @license MIT; see libraries/vendor/web-auth/webauthn-lib/LICENSE
+ */
+
+namespace Joomla\Plugin\System\Webauthn\Hotfix;
+
+// Protect from unauthorized access
+defined('_JEXEC') or die();
+
+use Assert\Assertion;
+use CBOR\Decoder;
+use CBOR\OtherObject\OtherObjectManager;
+use CBOR\Tag\TagObjectManager;
+use Cose\Algorithms;
+use Cose\Key\Ec2Key;
+use Cose\Key\Key;
+use Cose\Key\RsaKey;
+use FG\ASN1\ASNObject;
+use FG\ASN1\ExplicitlyTaggedObject;
+use FG\ASN1\Universal\OctetString;
+use FG\ASN1\Universal\Sequence;
+use Webauthn\AttestationStatement\AttestationStatement;
+use Webauthn\AttestationStatement\AttestationStatementSupport;
+use Webauthn\AuthenticatorData;
+use Webauthn\CertificateToolbox;
+use Webauthn\MetadataService\MetadataStatementRepository;
+use Webauthn\StringStream;
+use Webauthn\TrustPath\CertificateTrustPath;
+
+/**
+ * We had to fork the key attestation support object from the WebAuthn server package to address an
+ * issue with PHP 8.
+ *
+ * We are currently using an older version of the WebAuthn library (2.x) which was written before
+ * PHP 8 was developed. We cannot upgrade the WebAuthn library to a newer major version because of
+ * Joomla's Semantic Versioning promise.
+ *
+ * The AndroidKeyAttestationStatementSupport class forces an assertion on the result of the
+ * openssl_pkey_get_public() function, assuming it will return a resource. However, starting with
+ * PHP 8.0 this function returns an OpenSSLAsymmetricKey object and the assertion fails. As a
+ * result, you cannot use Android or FIDO U2F keys with WebAuthn.
+ *
+ * The assertion check is in a private method, therefore we have to fork both attestation support
+ * class to change the assertion. The assertion takes place through a third party library we cannot
+ * (and should not!) modify.
+ *
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated 5.0 We will upgrade the WebAuthn library to version 3 or later and this will go away.
+ */
+final class AndroidKeyAttestationStatementSupport implements AttestationStatementSupport
+{
+ /**
+ * @var Decoder
+ * @since __DEPLOY_VERSION__
+ */
+ private $decoder;
+
+ /**
+ * @var MetadataStatementRepository|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $metadataStatementRepository;
+
+ /**
+ * @param Decoder|null $decoder Obvious
+ * @param MetadataStatementRepository|null $metadataStatementRepository Obvious
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(
+ ?Decoder $decoder = null,
+ ?MetadataStatementRepository $metadataStatementRepository = null
+ )
+ {
+ if ($decoder !== null)
+ {
+ @trigger_error('The argument "$decoder" is deprecated since 2.1 and will be removed in v3.0. Set null instead', E_USER_DEPRECATED);
+ }
+
+ if ($metadataStatementRepository === null)
+ {
+ @trigger_error(
+ 'Setting "null" for argument "$metadataStatementRepository" is deprecated since 2.1 and will be mandatory in v3.0.',
+ E_USER_DEPRECATED
+ );
+ }
+
+ $this->decoder = $decoder ?? new Decoder(new TagObjectManager, new OtherObjectManager);
+ $this->metadataStatementRepository = $metadataStatementRepository;
+ }
+
+ /**
+ * @return string
+ * @since __DEPLOY_VERSION__
+ */
+ public function name(): string
+ {
+ return 'android-key';
+ }
+
+ /**
+ * @param array $attestation Obvious
+ *
+ * @return AttestationStatement
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ public function load(array $attestation): AttestationStatement
+ {
+ Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
+
+ foreach (['sig', 'x5c', 'alg'] as $key)
+ {
+ Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
+ }
+
+ $certificates = $attestation['attStmt']['x5c'];
+
+ Assertion::isArray($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
+ Assertion::greaterThan(\count($certificates), 0, 'The attestation statement value "x5c" must be a list with at least one certificate.');
+ Assertion::allString($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
+
+ $certificates = CertificateToolbox::convertAllDERToPEM($certificates);
+
+ return AttestationStatement::createBasic($attestation['fmt'], $attestation['attStmt'], new CertificateTrustPath($certificates));
+ }
+
+ /**
+ * @param string $clientDataJSONHash Obvious
+ * @param AttestationStatement $attestationStatement Obvious
+ * @param AuthenticatorData $authenticatorData Obvious
+ *
+ * @return boolean
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ public function isValid(
+ string $clientDataJSONHash,
+ AttestationStatement $attestationStatement,
+ AuthenticatorData $authenticatorData
+ ): bool
+ {
+ $trustPath = $attestationStatement->getTrustPath();
+ Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
+
+ $certificates = $trustPath->getCertificates();
+
+ if ($this->metadataStatementRepository !== null)
+ {
+ $certificates = CertificateToolbox::checkAttestationMedata(
+ $attestationStatement,
+ $authenticatorData->getAttestedCredentialData()->getAaguid()->toString(),
+ $certificates,
+ $this->metadataStatementRepository
+ );
+ }
+
+ // Decode leaf attestation certificate
+ $leaf = $certificates[0];
+ $this->checkCertificateAndGetPublicKey($leaf, $clientDataJSONHash, $authenticatorData);
+
+ $signedData = $authenticatorData->getAuthData() . $clientDataJSONHash;
+ $alg = $attestationStatement->get('alg');
+
+ return openssl_verify($signedData, $attestationStatement->get('sig'), $leaf, Algorithms::getOpensslAlgorithmFor((int) $alg)) === 1;
+ }
+
+ /**
+ * @param string $certificate Obvious
+ * @param string $clientDataHash Obvious
+ * @param AuthenticatorData $authenticatorData Obvious
+ *
+ * @return void
+ * @throws \Assert\AssertionFailedException
+ * @throws \FG\ASN1\Exception\ParserException
+ * @since __DEPLOY_VERSION__
+ */
+ private function checkCertificateAndGetPublicKey(
+ string $certificate,
+ string $clientDataHash,
+ AuthenticatorData $authenticatorData
+ ): void
+ {
+ $resource = openssl_pkey_get_public($certificate);
+
+ if (version_compare(PHP_VERSION, '8.0', 'lt'))
+ {
+ Assertion::isResource($resource, 'Unable to read the certificate');
+ }
+ else
+ {
+ /** @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection */
+ Assertion::isInstanceOf($resource, \OpenSSLAsymmetricKey::class, 'Unable to read the certificate');
+ }
+
+ $details = openssl_pkey_get_details($resource);
+ Assertion::isArray($details, 'Unable to read the certificate');
+
+ // Check that authData publicKey matches the public key in the attestation certificate
+ $attestedCredentialData = $authenticatorData->getAttestedCredentialData();
+ Assertion::notNull($attestedCredentialData, 'No attested credential data found');
+ $publicKeyData = $attestedCredentialData->getCredentialPublicKey();
+ Assertion::notNull($publicKeyData, 'No attested public key found');
+ $publicDataStream = new StringStream($publicKeyData);
+ $coseKey = $this->decoder->decode($publicDataStream)->getNormalizedData(false);
+ Assertion::true($publicDataStream->isEOF(), 'Invalid public key data. Presence of extra bytes.');
+ $publicDataStream->close();
+ $publicKey = Key::createFromData($coseKey);
+
+ Assertion::true(($publicKey instanceof Ec2Key) || ($publicKey instanceof RsaKey), 'Unsupported key type');
+ Assertion::eq($publicKey->asPEM(), $details['key'], 'Invalid key');
+
+ $certDetails = openssl_x509_parse($certificate);
+
+ // Find Android KeyStore Extension with OID “1.3.6.1.4.1.11129.2.1.17” in certificate extensions
+ Assertion::keyExists($certDetails, 'extensions', 'The certificate has no extension');
+ Assertion::isArray($certDetails['extensions'], 'The certificate has no extension');
+ Assertion::keyExists(
+ $certDetails['extensions'],
+ '1.3.6.1.4.1.11129.2.1.17',
+ 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is missing'
+ );
+ $extension = $certDetails['extensions']['1.3.6.1.4.1.11129.2.1.17'];
+ $extensionAsAsn1 = ASNObject::fromBinary($extension);
+ Assertion::isInstanceOf($extensionAsAsn1, Sequence::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ $objects = $extensionAsAsn1->getChildren();
+
+ // Check that attestationChallenge is set to the clientDataHash.
+ Assertion::keyExists($objects, 4, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ Assertion::isInstanceOf($objects[4], OctetString::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ Assertion::eq($clientDataHash, hex2bin(($objects[4])->getContent()), 'The client data hash is not valid');
+
+ // Check that both teeEnforced and softwareEnforced structures don’t contain allApplications(600) tag.
+ Assertion::keyExists($objects, 6, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ $softwareEnforcedFlags = $objects[6];
+ Assertion::isInstanceOf($softwareEnforcedFlags, Sequence::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ $this->checkAbsenceOfAllApplicationsTag($softwareEnforcedFlags);
+
+ Assertion::keyExists($objects, 7, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ $teeEnforcedFlags = $objects[6];
+ Assertion::isInstanceOf($teeEnforcedFlags, Sequence::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
+ $this->checkAbsenceOfAllApplicationsTag($teeEnforcedFlags);
+ }
+
+ /**
+ * @param Sequence $sequence Obvious
+ *
+ * @return void
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ private function checkAbsenceOfAllApplicationsTag(Sequence $sequence): void
+ {
+ foreach ($sequence->getChildren() as $tag)
+ {
+ Assertion::isInstanceOf($tag, ExplicitlyTaggedObject::class, 'Invalid tag');
+
+ /**
+ * @var ExplicitlyTaggedObject $tag It is silly that I have to do that for PHPCS to be happy.
+ */
+ Assertion::notEq(600, (int) $tag->getTag(), 'Forbidden tag 600 found');
+ }
+ }
+}
diff --git a/plugins/system/webauthn/src/Hotfix/FidoU2FAttestationStatementSupport.php b/plugins/system/webauthn/src/Hotfix/FidoU2FAttestationStatementSupport.php
new file mode 100644
index 0000000000000..6ad177b47406e
--- /dev/null
+++ b/plugins/system/webauthn/src/Hotfix/FidoU2FAttestationStatementSupport.php
@@ -0,0 +1,230 @@
+
+ * @license MIT; see libraries/vendor/web-auth/webauthn-lib/LICENSE
+ */
+
+namespace Joomla\Plugin\System\Webauthn\Hotfix;
+
+// Protect from unauthorized access
+defined('_JEXEC') or die();
+
+use Assert\Assertion;
+use CBOR\Decoder;
+use CBOR\MapObject;
+use CBOR\OtherObject\OtherObjectManager;
+use CBOR\Tag\TagObjectManager;
+use Cose\Key\Ec2Key;
+use Webauthn\AttestationStatement\AttestationStatement;
+use Webauthn\AttestationStatement\AttestationStatementSupport;
+use Webauthn\AuthenticatorData;
+use Webauthn\CertificateToolbox;
+use Webauthn\MetadataService\MetadataStatementRepository;
+use Webauthn\StringStream;
+use Webauthn\TrustPath\CertificateTrustPath;
+
+/**
+ * We had to fork the key attestation support object from the WebAuthn server package to address an
+ * issue with PHP 8.
+ *
+ * We are currently using an older version of the WebAuthn library (2.x) which was written before
+ * PHP 8 was developed. We cannot upgrade the WebAuthn library to a newer major version because of
+ * Joomla's Semantic Versioning promise.
+ *
+ * The FidoU2FAttestationStatementSupport class forces an assertion on the result of the
+ * openssl_pkey_get_public() function, assuming it will return a resource. However, starting with
+ * PHP 8.0 this function returns an OpenSSLAsymmetricKey object and the assertion fails. As a
+ * result, you cannot use Android or FIDO U2F keys with WebAuthn.
+ *
+ * The assertion check is in a private method, therefore we have to fork both attestation support
+ * class to change the assertion. The assertion takes place through a third party library we cannot
+ * (and should not!) modify.
+ *
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated 5.0 We will upgrade the WebAuthn library to version 3 or later and this will go away.
+ */
+final class FidoU2FAttestationStatementSupport implements AttestationStatementSupport
+{
+ /**
+ * @var Decoder
+ * @since __DEPLOY_VERSION__
+ */
+ private $decoder;
+
+ /**
+ * @var MetadataStatementRepository|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $metadataStatementRepository;
+
+ /**
+ * @param Decoder|null $decoder Obvious
+ * @param MetadataStatementRepository|null $metadataStatementRepository Obvious
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(
+ ?Decoder $decoder = null,
+ ?MetadataStatementRepository $metadataStatementRepository = null
+ )
+ {
+ if ($decoder !== null)
+ {
+ @trigger_error('The argument "$decoder" is deprecated since 2.1 and will be removed in v3.0. Set null instead', E_USER_DEPRECATED);
+ }
+
+ if ($metadataStatementRepository === null)
+ {
+ @trigger_error(
+ 'Setting "null" for argument "$metadataStatementRepository" is deprecated since 2.1 and will be mandatory in v3.0.',
+ E_USER_DEPRECATED
+ );
+ }
+
+ $this->decoder = $decoder ?? new Decoder(new TagObjectManager, new OtherObjectManager);
+ $this->metadataStatementRepository = $metadataStatementRepository;
+ }
+
+ /**
+ * @return string
+ * @since __DEPLOY_VERSION__
+ */
+ public function name(): string
+ {
+ return 'fido-u2f';
+ }
+
+ /**
+ * @param array $attestation Obvious
+ *
+ * @return AttestationStatement
+ * @throws \Assert\AssertionFailedException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function load(array $attestation): AttestationStatement
+ {
+ Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
+
+ foreach (['sig', 'x5c'] as $key)
+ {
+ Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
+ }
+
+ $certificates = $attestation['attStmt']['x5c'];
+ Assertion::isArray($certificates, 'The attestation statement value "x5c" must be a list with one certificate.');
+ Assertion::count($certificates, 1, 'The attestation statement value "x5c" must be a list with one certificate.');
+ Assertion::allString($certificates, 'The attestation statement value "x5c" must be a list with one certificate.');
+
+ reset($certificates);
+ $certificates = CertificateToolbox::convertAllDERToPEM($certificates);
+ $this->checkCertificate($certificates[0]);
+
+ return AttestationStatement::createBasic($attestation['fmt'], $attestation['attStmt'], new CertificateTrustPath($certificates));
+ }
+
+ /**
+ * @param string $clientDataJSONHash Obvious
+ * @param AttestationStatement $attestationStatement Obvious
+ * @param AuthenticatorData $authenticatorData Obvious
+ *
+ * @return boolean
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ public function isValid(
+ string $clientDataJSONHash,
+ AttestationStatement $attestationStatement,
+ AuthenticatorData $authenticatorData
+ ): bool
+ {
+ Assertion::eq(
+ $authenticatorData->getAttestedCredentialData()->getAaguid()->toString(),
+ '00000000-0000-0000-0000-000000000000',
+ 'Invalid AAGUID for fido-u2f attestation statement. Shall be "00000000-0000-0000-0000-000000000000"'
+ );
+
+ if ($this->metadataStatementRepository !== null)
+ {
+ CertificateToolbox::checkAttestationMedata(
+ $attestationStatement,
+ $authenticatorData->getAttestedCredentialData()->getAaguid()->toString(),
+ [],
+ $this->metadataStatementRepository
+ );
+ }
+
+ $trustPath = $attestationStatement->getTrustPath();
+ Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
+ $dataToVerify = "\0";
+ $dataToVerify .= $authenticatorData->getRpIdHash();
+ $dataToVerify .= $clientDataJSONHash;
+ $dataToVerify .= $authenticatorData->getAttestedCredentialData()->getCredentialId();
+ $dataToVerify .= $this->extractPublicKey($authenticatorData->getAttestedCredentialData()->getCredentialPublicKey());
+
+ return openssl_verify($dataToVerify, $attestationStatement->get('sig'), $trustPath->getCertificates()[0], OPENSSL_ALGO_SHA256) === 1;
+ }
+
+ /**
+ * @param string|null $publicKey Obvious
+ *
+ * @return string
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ private function extractPublicKey(?string $publicKey): string
+ {
+ Assertion::notNull($publicKey, 'The attested credential data does not contain a valid public key.');
+
+ $publicKeyStream = new StringStream($publicKey);
+ $coseKey = $this->decoder->decode($publicKeyStream);
+ Assertion::true($publicKeyStream->isEOF(), 'Invalid public key. Presence of extra bytes.');
+ $publicKeyStream->close();
+ Assertion::isInstanceOf($coseKey, MapObject::class, 'The attested credential data does not contain a valid public key.');
+
+ $coseKey = $coseKey->getNormalizedData();
+ $ec2Key = new Ec2Key($coseKey + [Ec2Key::TYPE => 2, Ec2Key::DATA_CURVE => Ec2Key::CURVE_P256]);
+
+ return "\x04" . $ec2Key->x() . $ec2Key->y();
+ }
+
+ /**
+ * @param string $publicKey Obvious
+ *
+ * @return void
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ private function checkCertificate(string $publicKey): void
+ {
+ try
+ {
+ $resource = openssl_pkey_get_public($publicKey);
+
+ if (version_compare(PHP_VERSION, '8.0', 'lt'))
+ {
+ Assertion::isResource($resource, 'Unable to read the certificate');
+ }
+ else
+ {
+ /** @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection */
+ Assertion::isInstanceOf($resource, \OpenSSLAsymmetricKey::class, 'Unable to read the certificate');
+ }
+ }
+ catch (\Throwable $throwable)
+ {
+ throw new \InvalidArgumentException('Invalid certificate or certificate chain', 0, $throwable);
+ }
+
+ $details = openssl_pkey_get_details($resource);
+ Assertion::keyExists($details, 'ec', 'Invalid certificate or certificate chain');
+ Assertion::keyExists($details['ec'], 'curve_name', 'Invalid certificate or certificate chain');
+ Assertion::eq($details['ec']['curve_name'], 'prime256v1', 'Invalid certificate or certificate chain');
+ Assertion::keyExists($details['ec'], 'curve_oid', 'Invalid certificate or certificate chain');
+ Assertion::eq($details['ec']['curve_oid'], '1.2.840.10045.3.1.7', 'Invalid certificate or certificate chain');
+ }
+}
diff --git a/plugins/system/webauthn/src/Hotfix/Server.php b/plugins/system/webauthn/src/Hotfix/Server.php
new file mode 100644
index 0000000000000..f44820b29d34b
--- /dev/null
+++ b/plugins/system/webauthn/src/Hotfix/Server.php
@@ -0,0 +1,452 @@
+
+ * @license MIT; see libraries/vendor/web-auth/webauthn-lib/LICENSE
+ */
+
+namespace Joomla\Plugin\System\Webauthn\Hotfix;
+
+// Protect from unauthorized access
+defined('_JEXEC') or die();
+
+use Assert\Assertion;
+use Cose\Algorithm\Algorithm;
+use Cose\Algorithm\ManagerFactory;
+use Cose\Algorithm\Signature\ECDSA;
+use Cose\Algorithm\Signature\EdDSA;
+use Cose\Algorithm\Signature\RSA;
+use Psr\Http\Client\ClientInterface;
+use Psr\Http\Message\RequestFactoryInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Webauthn\AttestationStatement\AndroidSafetyNetAttestationStatementSupport;
+use Webauthn\AttestationStatement\AttestationObjectLoader;
+use Webauthn\AttestationStatement\AttestationStatementSupportManager;
+use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
+use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
+use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
+use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
+use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
+use Webauthn\AuthenticatorAssertionResponse;
+use Webauthn\AuthenticatorAssertionResponseValidator;
+use Webauthn\AuthenticatorAttestationResponse;
+use Webauthn\AuthenticatorAttestationResponseValidator;
+use Webauthn\AuthenticatorSelectionCriteria;
+use Webauthn\MetadataService\MetadataStatementRepository;
+use Webauthn\PublicKeyCredentialCreationOptions;
+use Webauthn\PublicKeyCredentialDescriptor;
+use Webauthn\PublicKeyCredentialLoader;
+use Webauthn\PublicKeyCredentialParameters;
+use Webauthn\PublicKeyCredentialRequestOptions;
+use Webauthn\PublicKeyCredentialRpEntity;
+use Webauthn\PublicKeyCredentialSource;
+use Webauthn\PublicKeyCredentialSourceRepository;
+use Webauthn\PublicKeyCredentialUserEntity;
+use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
+
+/**
+ * Customised WebAuthn server object.
+ *
+ * We had to fork the server object from the WebAuthn server package to address an issue with PHP 8.
+ *
+ * We are currently using an older version of the WebAuthn library (2.x) which was written before
+ * PHP 8 was developed. We cannot upgrade the WebAuthn library to a newer major version because of
+ * Joomla's Semantic Versioning promise.
+ *
+ * The FidoU2FAttestationStatementSupport and AndroidKeyAttestationStatementSupport classes force
+ * an assertion on the result of the openssl_pkey_get_public() function, assuming it will return a
+ * resource. However, starting with PHP 8.0 this function returns an OpenSSLAsymmetricKey object
+ * and the assertion fails. As a result, you cannot use Android or FIDO U2F keys with WebAuthn.
+ *
+ * The assertion check is in a private method, therefore we have to fork both attestation support
+ * classes to change the assertion. The assertion takes place through a third party library we
+ * cannot (and should not!) modify.
+ *
+ * The assertions objects, however, are injected to the attestation support manager in a private
+ * method of the Server object. Because literally everything in this class is private we have no
+ * option than to fork the entire class to apply our two forked attestation support classes.
+ *
+ * This is marked as deprecated because we'll be able to upgrade the WebAuthn library on Joomla 5.
+ *
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated 5.0 We will upgrade the WebAuthn library to version 3 or later and this will go away.
+ */
+final class Server extends \Webauthn\Server
+{
+ /**
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ public $timeout = 60000;
+
+ /**
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ public $challengeSize = 32;
+
+ /**
+ * @var PublicKeyCredentialRpEntity
+ * @since __DEPLOY_VERSION__
+ */
+ private $rpEntity;
+
+ /**
+ * @var ManagerFactory
+ * @since __DEPLOY_VERSION__
+ */
+ private $coseAlgorithmManagerFactory;
+
+ /**
+ * @var PublicKeyCredentialSourceRepository
+ * @since __DEPLOY_VERSION__
+ */
+ private $publicKeyCredentialSourceRepository;
+
+ /**
+ * @var TokenBindingNotSupportedHandler
+ * @since __DEPLOY_VERSION__
+ */
+ private $tokenBindingHandler;
+
+ /**
+ * @var ExtensionOutputCheckerHandler
+ * @since __DEPLOY_VERSION__
+ */
+ private $extensionOutputCheckerHandler;
+
+ /**
+ * @var string[]
+ * @since __DEPLOY_VERSION__
+ */
+ private $selectedAlgorithms;
+
+ /**
+ * @var MetadataStatementRepository|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $metadataStatementRepository;
+
+ /**
+ * @var ClientInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private $httpClient;
+
+ /**
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $googleApiKey;
+
+ /**
+ * @var RequestFactoryInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private $requestFactory;
+
+ /**
+ * Overridden constructor.
+ *
+ * @param PublicKeyCredentialRpEntity $relayingParty Obvious
+ * @param PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository Obvious
+ * @param MetadataStatementRepository|null $metadataStatementRepository Obvious
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(
+ PublicKeyCredentialRpEntity $relayingParty,
+ PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository,
+ ?MetadataStatementRepository $metadataStatementRepository
+ )
+ {
+ $this->rpEntity = $relayingParty;
+
+ $this->coseAlgorithmManagerFactory = new ManagerFactory;
+ $this->coseAlgorithmManagerFactory->add('RS1', new RSA\RS1);
+ $this->coseAlgorithmManagerFactory->add('RS256', new RSA\RS256);
+ $this->coseAlgorithmManagerFactory->add('RS384', new RSA\RS384);
+ $this->coseAlgorithmManagerFactory->add('RS512', new RSA\RS512);
+ $this->coseAlgorithmManagerFactory->add('PS256', new RSA\PS256);
+ $this->coseAlgorithmManagerFactory->add('PS384', new RSA\PS384);
+ $this->coseAlgorithmManagerFactory->add('PS512', new RSA\PS512);
+ $this->coseAlgorithmManagerFactory->add('ES256', new ECDSA\ES256);
+ $this->coseAlgorithmManagerFactory->add('ES256K', new ECDSA\ES256K);
+ $this->coseAlgorithmManagerFactory->add('ES384', new ECDSA\ES384);
+ $this->coseAlgorithmManagerFactory->add('ES512', new ECDSA\ES512);
+ $this->coseAlgorithmManagerFactory->add('Ed25519', new EdDSA\Ed25519);
+
+ $this->selectedAlgorithms = ['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512', 'Ed25519'];
+ $this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository;
+ $this->tokenBindingHandler = new TokenBindingNotSupportedHandler;
+ $this->extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler;
+ $this->metadataStatementRepository = $metadataStatementRepository;
+ }
+
+ /**
+ * @param string[] $selectedAlgorithms Obvious
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function setSelectedAlgorithms(array $selectedAlgorithms): void
+ {
+ $this->selectedAlgorithms = $selectedAlgorithms;
+ }
+
+ /**
+ * @param TokenBindingNotSupportedHandler $tokenBindingHandler Obvious
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function setTokenBindingHandler(TokenBindingNotSupportedHandler $tokenBindingHandler): void
+ {
+ $this->tokenBindingHandler = $tokenBindingHandler;
+ }
+
+ /**
+ * @param string $alias Obvious
+ * @param Algorithm $algorithm Obvious
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function addAlgorithm(string $alias, Algorithm $algorithm): void
+ {
+ $this->coseAlgorithmManagerFactory->add($alias, $algorithm);
+ $this->selectedAlgorithms[] = $alias;
+ $this->selectedAlgorithms = array_unique($this->selectedAlgorithms);
+ }
+
+ /**
+ * @param ExtensionOutputCheckerHandler $extensionOutputCheckerHandler Obvious
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function setExtensionOutputCheckerHandler(ExtensionOutputCheckerHandler $extensionOutputCheckerHandler): void
+ {
+ $this->extensionOutputCheckerHandler = $extensionOutputCheckerHandler;
+ }
+
+ /**
+ * @param string|null $userVerification Obvious
+ * @param PublicKeyCredentialDescriptor[] $allowedPublicKeyDescriptors Obvious
+ * @param AuthenticationExtensionsClientInputs|null $extensions Obvious
+ *
+ * @return PublicKeyCredentialRequestOptions
+ * @throws \Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function generatePublicKeyCredentialRequestOptions(
+ ?string $userVerification = PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
+ array $allowedPublicKeyDescriptors = [],
+ ?AuthenticationExtensionsClientInputs $extensions = null
+ ): PublicKeyCredentialRequestOptions
+ {
+ return new PublicKeyCredentialRequestOptions(
+ random_bytes($this->challengeSize),
+ $this->timeout,
+ $this->rpEntity->getId(),
+ $allowedPublicKeyDescriptors,
+ $userVerification,
+ $extensions ?? new AuthenticationExtensionsClientInputs
+ );
+ }
+
+ /**
+ * @param PublicKeyCredentialUserEntity $userEntity Obvious
+ * @param string|null $attestationMode Obvious
+ * @param PublicKeyCredentialDescriptor[] $excludedPublicKeyDescriptors Obvious
+ * @param AuthenticatorSelectionCriteria|null $criteria Obvious
+ * @param AuthenticationExtensionsClientInputs|null $extensions Obvious
+ *
+ * @return PublicKeyCredentialCreationOptions
+ * @throws \Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function generatePublicKeyCredentialCreationOptions(
+ PublicKeyCredentialUserEntity $userEntity,
+ ?string $attestationMode = PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
+ array $excludedPublicKeyDescriptors = [],
+ ?AuthenticatorSelectionCriteria $criteria = null,
+ ?AuthenticationExtensionsClientInputs $extensions = null
+ ): PublicKeyCredentialCreationOptions
+ {
+ $coseAlgorithmManager = $this->coseAlgorithmManagerFactory->create($this->selectedAlgorithms);
+ $publicKeyCredentialParametersList = [];
+
+ foreach ($coseAlgorithmManager->all() as $algorithm)
+ {
+ $publicKeyCredentialParametersList[] = new PublicKeyCredentialParameters(
+ PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
+ $algorithm::identifier()
+ );
+ }
+
+ $criteria = $criteria ?? new AuthenticatorSelectionCriteria;
+ $extensions = $extensions ?? new AuthenticationExtensionsClientInputs;
+ $challenge = random_bytes($this->challengeSize);
+
+ return new PublicKeyCredentialCreationOptions(
+ $this->rpEntity,
+ $userEntity,
+ $challenge,
+ $publicKeyCredentialParametersList,
+ $this->timeout,
+ $excludedPublicKeyDescriptors,
+ $criteria,
+ $attestationMode,
+ $extensions
+ );
+ }
+
+ /**
+ * @param string $data Obvious
+ * @param PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions Obvious
+ * @param ServerRequestInterface $serverRequest Obvious
+ *
+ * @return PublicKeyCredentialSource
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ public function loadAndCheckAttestationResponse(
+ string $data,
+ PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
+ ServerRequestInterface $serverRequest
+ ): PublicKeyCredentialSource
+ {
+ $attestationStatementSupportManager = $this->getAttestationStatementSupportManager();
+ $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
+ $publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);
+
+ $publicKeyCredential = $publicKeyCredentialLoader->load($data);
+ $authenticatorResponse = $publicKeyCredential->getResponse();
+ Assertion::isInstanceOf($authenticatorResponse, AuthenticatorAttestationResponse::class, 'Not an authenticator attestation response');
+
+ $authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
+ $attestationStatementSupportManager,
+ $this->publicKeyCredentialSourceRepository,
+ $this->tokenBindingHandler,
+ $this->extensionOutputCheckerHandler
+ );
+
+ return $authenticatorAttestationResponseValidator->check($authenticatorResponse, $publicKeyCredentialCreationOptions, $serverRequest);
+ }
+
+ /**
+ * @param string $data Obvious
+ * @param PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions Obvious
+ * @param PublicKeyCredentialUserEntity|null $userEntity Obvious
+ * @param ServerRequestInterface $serverRequest Obvious
+ *
+ * @return PublicKeyCredentialSource
+ * @throws \Assert\AssertionFailedException
+ * @since __DEPLOY_VERSION__
+ */
+ public function loadAndCheckAssertionResponse(
+ string $data,
+ PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
+ ?PublicKeyCredentialUserEntity $userEntity,
+ ServerRequestInterface $serverRequest
+ ): PublicKeyCredentialSource
+ {
+ $attestationStatementSupportManager = $this->getAttestationStatementSupportManager();
+ $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
+ $publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);
+
+ $publicKeyCredential = $publicKeyCredentialLoader->load($data);
+ $authenticatorResponse = $publicKeyCredential->getResponse();
+ Assertion::isInstanceOf($authenticatorResponse, AuthenticatorAssertionResponse::class, 'Not an authenticator assertion response');
+
+ $authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
+ $this->publicKeyCredentialSourceRepository,
+ null,
+ $this->tokenBindingHandler,
+ $this->extensionOutputCheckerHandler,
+ $this->coseAlgorithmManagerFactory->create($this->selectedAlgorithms)
+ );
+
+ return $authenticatorAssertionResponseValidator->check(
+ $publicKeyCredential->getRawId(),
+ $authenticatorResponse,
+ $publicKeyCredentialRequestOptions,
+ $serverRequest,
+ null !== $userEntity ? $userEntity->getId() : null
+ );
+ }
+
+ /**
+ * @param ClientInterface $client Obvious
+ * @param string $apiKey Obvious
+ * @param RequestFactoryInterface $requestFactory Obvious
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function enforceAndroidSafetyNetVerification(
+ ClientInterface $client,
+ string $apiKey,
+ RequestFactoryInterface $requestFactory
+ ): void
+ {
+ $this->httpClient = $client;
+ $this->googleApiKey = $apiKey;
+ $this->requestFactory = $requestFactory;
+ }
+
+ /**
+ * @return AttestationStatementSupportManager
+ * @since __DEPLOY_VERSION__
+ */
+ private function getAttestationStatementSupportManager(): AttestationStatementSupportManager
+ {
+ $attestationStatementSupportManager = new AttestationStatementSupportManager;
+ $attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
+
+ if ($this->metadataStatementRepository !== null)
+ {
+ $coseAlgorithmManager = $this->coseAlgorithmManagerFactory->create($this->selectedAlgorithms);
+ $attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport(null, $this->metadataStatementRepository));
+
+ /**
+ * Work around a third party library (web-token/jwt-signature-algorithm-eddsa) bug.
+ *
+ * On PHP 8 libsodium is compiled into PHP, it is not an extension. However, the third party library does
+ * not check if the libsodium function are available; it checks if the "sodium" extension is loaded. This of
+ * course causes an immediate failure with a Runtime exception EVEN IF the attested data isn't attested by
+ * Android Safety Net. Therefore we have to not even load the AndroidSafetyNetAttestationStatementSupport
+ * class in this case...
+ */
+ if (function_exists('sodium_crypto_sign_seed_keypair') && function_exists('extension_loaded') && extension_loaded('sodium'))
+ {
+ $attestationStatementSupportManager->add(
+ new AndroidSafetyNetAttestationStatementSupport(
+ $this->httpClient,
+ $this->googleApiKey,
+ $this->requestFactory,
+ 2000,
+ 60000,
+ $this->metadataStatementRepository
+ )
+ );
+ }
+
+ $attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport(null, $this->metadataStatementRepository));
+ $attestationStatementSupportManager->add(new TPMAttestationStatementSupport($this->metadataStatementRepository));
+ $attestationStatementSupportManager->add(
+ new PackedAttestationStatementSupport(
+ null,
+ $coseAlgorithmManager,
+ $this->metadataStatementRepository
+ )
+ );
+ }
+
+ return $attestationStatementSupportManager;
+ }
+}
diff --git a/plugins/system/webauthn/src/MetadataRepository.php b/plugins/system/webauthn/src/MetadataRepository.php
new file mode 100644
index 0000000000000..65e5ae190726e
--- /dev/null
+++ b/plugins/system/webauthn/src/MetadataRepository.php
@@ -0,0 +1,246 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Webauthn;
+
+// Protect from unauthorized access
+defined('_JEXEC') or die();
+
+use Exception;
+use Joomla\CMS\Date\Date;
+use Joomla\CMS\Http\HttpFactory;
+use Lcobucci\JWT\Configuration;
+use Lcobucci\JWT\Token\Plain;
+use Webauthn\MetadataService\MetadataStatement;
+use Webauthn\MetadataService\MetadataStatementRepository;
+use function defined;
+
+/**
+ * Authenticator metadata repository.
+ *
+ * This repository contains the metadata of all FIDO authenticators as published by the FIDO
+ * Alliance in their MDS version 3.0.
+ *
+ * @see https://fidoalliance.org/metadata/
+ * @since __DEPLOY_VERSION__
+ */
+final class MetadataRepository implements MetadataStatementRepository
+{
+ /**
+ * Cache of authenticator metadata statements
+ *
+ * @var MetadataStatement[]
+ * @since __DEPLOY_VERSION__
+ */
+ private $mdsCache = [];
+
+ /**
+ * Map of AAGUID to $mdsCache index
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ private $mdsMap = [];
+
+ /**
+ * Public constructor.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct()
+ {
+ $this->load();
+ }
+
+ /**
+ * Find an authenticator metadata statement given an AAGUID
+ *
+ * @param string $aaguid The AAGUID to find
+ *
+ * @return MetadataStatement|null The metadata statement; null if the AAGUID is unknown
+ * @since __DEPLOY_VERSION__
+ */
+ public function findOneByAAGUID(string $aaguid): ?MetadataStatement
+ {
+ $idx = $this->mdsMap[$aaguid] ?? null;
+
+ return $idx ? $this->mdsCache[$idx] : null;
+ }
+
+ /**
+ * Get basic information of the known FIDO authenticators by AAGUID
+ *
+ * @return object[]
+ * @since __DEPLOY_VERSION__
+ */
+ public function getKnownAuthenticators(): array
+ {
+ $mapKeys = function (MetadataStatement $meta)
+ {
+ return $meta->getAaguid();
+ };
+ $mapvalues = function (MetadataStatement $meta)
+ {
+ return $meta->getAaguid() ? (object) [
+ 'description' => $meta->getDescription(),
+ 'icon' => $meta->getIcon(),
+ ] : null;
+ };
+ $keys = array_map($mapKeys, $this->mdsCache);
+ $values = array_map($mapvalues, $this->mdsCache);
+ $return = array_combine($keys, $values) ?: [];
+
+ $filter = function ($x)
+ {
+ return !empty($x);
+ };
+
+ return array_filter($return, $filter);
+ }
+
+ /**
+ * Load the authenticator metadata cache
+ *
+ * @param bool $force Force reload from the web service
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ private function load(bool $force = false): void
+ {
+ $this->mdsCache = [];
+ $this->mdsMap = [];
+ $jwtFilename = JPATH_CACHE . '/fido.jwt';
+
+ // If the file exists and it's over one month old do retry loading it.
+ if (file_exists($jwtFilename) && filemtime($jwtFilename) < (time() - 2592000))
+ {
+ $force = true;
+ }
+
+ /**
+ * Try to load the MDS source from the FIDO Alliance and cache it.
+ *
+ * We use a short timeout limit to avoid delaying the page load for way too long. If we fail
+ * to download the file in a reasonable amount of time we write an empty string in the
+ * file which causes this method to not proceed any further.
+ */
+ if (!file_exists($jwtFilename) || $force)
+ {
+ // Only try to download anything if we can actually cache it!
+ if ((file_exists($jwtFilename) && is_writable($jwtFilename)) || (!file_exists($jwtFilename) && is_writable(JPATH_CACHE)))
+ {
+ $http = HttpFactory::getHttp();
+ $response = $http->get('https://mds.fidoalliance.org/', [], 5);
+ $content = ($response->code < 200 || $response->code > 299) ? '' : $response->body;
+ }
+
+ /**
+ * If we could not download anything BUT a non-empty file already exists we must NOT
+ * overwrite it.
+ *
+ * This allows, for example, the site owner to manually place the FIDO MDS cache file
+ * in administrator/cache/fido.jwt. This would be useful for high security sites which
+ * require attestation BUT are behind a firewall (or disconnected from the Internet),
+ * therefore cannot download the MDS cache!
+ */
+ if (!empty($content) || !file_exists($jwtFilename) || filesize($jwtFilename) <= 1024)
+ {
+ file_put_contents($jwtFilename, $content);
+ }
+ }
+
+ $rawJwt = file_get_contents($jwtFilename);
+
+ if (!is_string($rawJwt) || strlen($rawJwt) < 1024)
+ {
+ return;
+ }
+
+ try
+ {
+ $jwtConfig = Configuration::forUnsecuredSigner();
+ $token = $jwtConfig->parser()->parse($rawJwt);
+ }
+ catch (Exception $e)
+ {
+ return;
+ }
+
+ if (!($token instanceof Plain))
+ {
+ return;
+ }
+
+ unset($rawJwt);
+
+ // Do I need to forcibly update the cache? The JWT has the nextUpdate claim to tell us when to do that.
+ try
+ {
+ $nextUpdate = new Date($token->claims()->get('nextUpdate', '2020-01-01'));
+
+ if (!$force && !$nextUpdate->diff(new Date)->invert)
+ {
+ $this->load(true);
+
+ return;
+ }
+ }
+ catch (Exception $e)
+ {
+ // OK, don't worry if don't know when the next update is.
+ }
+
+ $entriesMapper = function (object $entry)
+ {
+ try
+ {
+ $array = json_decode(json_encode($entry->metadataStatement), true);
+
+ /**
+ * This prevents an error when we're asking for attestation on authenticators which
+ * don't allow it. We are really not interested in the attestation per se, but
+ * requiring an attestation is the only way we can get the AAGUID of the
+ * authenticator.
+ */
+ if (isset($array['attestationTypes']))
+ {
+ unset($array['attestationTypes']);
+ }
+
+ return MetadataStatement::createFromArray($array);
+ }
+ catch (Exception $e)
+ {
+ return null;
+ }
+ };
+ $entries = array_map($entriesMapper, $token->claims()->get('entries', []));
+
+ unset($token);
+
+ $entriesFilter = function ($x)
+ {
+ return !empty($x);
+ };
+ $this->mdsCache = array_filter($entries, $entriesFilter);
+
+ foreach ($this->mdsCache as $idx => $meta)
+ {
+ $aaguid = $meta->getAaguid();
+
+ if (empty($aaguid))
+ {
+ continue;
+ }
+
+ $this->mdsMap[$aaguid] = $idx;
+ }
+ }
+}
diff --git a/plugins/system/webauthn/src/PluginTraits/AdditionalLoginButtons.php b/plugins/system/webauthn/src/PluginTraits/AdditionalLoginButtons.php
index 42efc2565fd21..f3775a555ab76 100644
--- a/plugins/system/webauthn/src/PluginTraits/AdditionalLoginButtons.php
+++ b/plugins/system/webauthn/src/PluginTraits/AdditionalLoginButtons.php
@@ -1,10 +1,10 @@
- * @license GNU General Public License version 2 or later; see LICENSE.txt
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
@@ -13,13 +13,14 @@
\defined('_JEXEC') or die();
use Exception;
-use Joomla\CMS\Factory;
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Document\HtmlDocument;
use Joomla\CMS\Helper\AuthenticationHelper;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserHelper;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Event\Event;
/**
* Inserts Webauthn buttons into login modules
@@ -29,7 +30,8 @@
trait AdditionalLoginButtons
{
/**
- * Do I need to I inject buttons? Automatically detected (i.e. disabled if I'm already logged in).
+ * Do I need to inject buttons? Automatically detected (i.e. disabled if I'm already logged
+ * in).
*
* @var boolean|null
* @since 4.0.0
@@ -44,6 +46,57 @@ trait AdditionalLoginButtons
*/
private $injectedCSSandJS = false;
+ /**
+ * Creates additional login buttons
+ *
+ * @param Event $event The event we are handling
+ *
+ * @return void
+ *
+ * @see AuthenticationHelper::getLoginButtons()
+ *
+ * @since 4.0.0
+ */
+ public function onUserLoginButtons(Event $event): void
+ {
+ /** @var string $form The HTML ID of the form we are enclosed in */
+ [$form] = $event->getArguments();
+
+ // If we determined we should not inject a button return early
+ if (!$this->mustDisplayButton())
+ {
+ return;
+ }
+
+ // Load necessary CSS and Javascript files
+ $this->addLoginCSSAndJavascript();
+
+ // Unique ID for this button (allows display of multiple modules on the page)
+ $randomId = 'plg_system_webauthn-' .
+ UserHelper::genRandomPassword(12) . '-' . UserHelper::genRandomPassword(8);
+
+ // Get local path to image
+ $image = HTMLHelper::_('image', 'plg_system_webauthn/webauthn.svg', '', '', true, true);
+
+ // If you can't find the image then skip it
+ $image = $image ? JPATH_ROOT . substr($image, \strlen(Uri::root(true))) : '';
+
+ // Extract image if it exists
+ $image = file_exists($image) ? file_get_contents($image) : '';
+
+ $this->returnFromEvent($event, [
+ [
+ 'label' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL',
+ 'tooltip' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_DESC',
+ 'id' => $randomId,
+ 'data-webauthn-form' => $form,
+ 'svg' => $image,
+ 'class' => 'plg_system_webauthn_login_button',
+ ],
+ ]
+ );
+ }
+
/**
* Should I allow this plugin to add a WebAuthn login button?
*
@@ -53,6 +106,24 @@ trait AdditionalLoginButtons
*/
private function mustDisplayButton(): bool
{
+ // We must have a valid application
+ if (!($this->getApplication() instanceof CMSApplication))
+ {
+ return false;
+ }
+
+ // This plugin only applies to the frontend and administrator applications
+ if (!$this->getApplication()->isClient('site') && !$this->getApplication()->isClient('administrator'))
+ {
+ return false;
+ }
+
+ // We must have a valid user
+ if (empty($this->getApplication()->getIdentity()))
+ {
+ return false;
+ }
+
if (\is_null($this->allowButtonDisplay))
{
$this->allowButtonDisplay = false;
@@ -60,35 +131,24 @@ private function mustDisplayButton(): bool
/**
* Do not add a WebAuthn login button if we are already logged in
*/
- try
- {
- if (!Factory::getApplication()->getIdentity()->guest)
- {
- return false;
- }
- }
- catch (Exception $e)
+ if (!$this->getApplication()->getIdentity()->guest)
{
return false;
}
/**
- * Don't try to show a button if we can't figure out if this is a front- or backend page (it's probably a
- * CLI or custom application).
+ * Only display a button on HTML output
*/
try
{
- Joomla::isAdminPage();
+ $document = $this->getApplication()->getDocument();
}
catch (Exception $e)
{
- return false;
+ $document = null;
}
- /**
- * Only display a button on HTML output
- */
- if (Joomla::getDocumentType() != 'html')
+ if (!($document instanceof HtmlDocument))
{
return false;
}
@@ -109,65 +169,6 @@ private function mustDisplayButton(): bool
return $this->allowButtonDisplay;
}
- /**
- * Creates additional login buttons
- *
- * @param string $form The HTML ID of the form we are enclosed in
- *
- * @return array
- *
- * @throws Exception
- *
- * @see AuthenticationHelper::getLoginButtons()
- *
- * @since 4.0.0
- */
- public function onUserLoginButtons(string $form): array
- {
- // If we determined we should not inject a button return early
- if (!$this->mustDisplayButton())
- {
- return [];
- }
-
- // Load the language files
- $this->loadLanguage();
-
- // Load necessary CSS and Javascript files
- $this->addLoginCSSAndJavascript();
-
- // Return URL
- $uri = new Uri(Uri::base() . 'index.php');
- $uri->setVar(Joomla::getToken(), '1');
-
- // Unique ID for this button (allows display of multiple modules on the page)
- $randomId = 'plg_system_webauthn-' . UserHelper::genRandomPassword(12) . '-' . UserHelper::genRandomPassword(8);
-
- // Set up the JavaScript callback
- $url = $uri->toString();
-
- // Get local path to image
- $image = HTMLHelper::_('image', 'plg_system_webauthn/webauthn.svg', '', '', true, true);
-
- // If you can't find the image then skip it
- $image = $image ? JPATH_ROOT . substr($image, \strlen(Uri::root(true))) : '';
-
- // Extract image if it exists
- $image = file_exists($image) ? file_get_contents($image) : '';
-
- return [
- [
- 'label' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL',
- 'tooltip' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_DESC',
- 'id' => $randomId,
- 'data-webauthn-form' => $form,
- 'data-webauthn-url' => $url,
- 'svg' => $image,
- 'class' => 'plg_system_webauthn_login_button',
- ],
- ];
- }
-
/**
* Injects the WebAuthn CSS and Javascript for frontend logins, but only once per page load.
*
@@ -186,7 +187,7 @@ private function addLoginCSSAndJavascript(): void
$this->injectedCSSandJS = true;
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
- $wa = Factory::getApplication()->getDocument()->getWebAssetManager();
+ $wa = $this->getApplication()->getDocument()->getWebAssetManager();
if (!$wa->assetExists('style', 'plg_system_webauthn.button'))
{
@@ -207,7 +208,7 @@ private function addLoginCSSAndJavascript(): void
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_INVALID_USERNAME');
// Store the current URL as the default return URL after login (or failure)
- Joomla::setSessionVar('returnUrl', Uri::current(), 'plg_system_webauthn');
+ $this->getApplication()->getSession()->set('plg_system_webauthn.returnUrl', Uri::current());
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandler.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandler.php
index 5b81697b24aee..2a171f95e26c6 100644
--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandler.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandler.php
@@ -1,10 +1,10 @@
- * @license GNU General Public License version 2 or later; see LICENSE.txt
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
@@ -14,17 +14,26 @@
use Exception;
use Joomla\CMS\Application\CMSApplication;
-use Joomla\CMS\Factory;
+use Joomla\CMS\Event\AbstractEvent;
+use Joomla\CMS\Event\GenericEvent;
+use Joomla\CMS\Event\Plugin\System\Webauthn\Ajax;
+use Joomla\CMS\Event\Plugin\System\Webauthn\Ajax as PlgSystemWebauthnAjax;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxChallenge as PlgSystemWebauthnAjaxChallenge;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxCreate as PlgSystemWebauthnAjaxCreate;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxDelete as PlgSystemWebauthnAjaxDelete;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxInitCreate as PlgSystemWebauthnAjaxInitCreate;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxLogin as PlgSystemWebauthnAjaxLogin;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxSaveLabel as PlgSystemWebauthnAjaxSaveLabel;
+use Joomla\CMS\Event\Result\ResultAwareInterface;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
-use Joomla\Plugin\System\Webauthn\Exception\AjaxNonCmsAppException;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Event\Event;
use RuntimeException;
/**
- * Allows the plugin to handle AJAX requests in the backend of the site, where com_ajax is not available when we are not
- * logged in.
+ * Allows the plugin to handle AJAX requests in the backend of the site, where com_ajax is not
+ * available when we are not logged in.
*
* @since 4.0.0
*/
@@ -33,41 +42,39 @@ trait AjaxHandler
/**
* Processes the callbacks from the passwordless login views.
*
- * Note: this method is called from Joomla's com_ajax or, in the case of backend logins, through the special
- * onAfterInitialize handler we have created to work around com_ajax usage limitations in the backend.
+ * Note: this method is called from Joomla's com_ajax or, in the case of backend logins,
+ * through the special onAfterInitialize handler we have created to work around com_ajax usage
+ * limitations in the backend.
+ *
+ * @param Event $event The event we are handling
*
* @return void
*
* @throws Exception
- *
* @since 4.0.0
*/
- public function onAjaxWebauthn(): void
+ public function onAjaxWebauthn(Ajax $event): void
{
- // Load the language files
- $this->loadLanguage();
-
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $input = $app->input;
+ $input = $this->getApplication()->input;
// Get the return URL from the session
- $returnURL = Joomla::getSessionVar('returnUrl', Uri::base(), 'plg_system_webauthn');
- $result = null;
+ $returnURL = $this->getApplication()->getSession()->get('plg_system_webauthn.returnUrl', Uri::base());
+ $result = null;
try
{
- Joomla::log('system', "Received AJAX callback.");
+ Log::add("Received AJAX callback.", Log::DEBUG, 'webauthn.system');
- if (!($app instanceof CMSApplication))
+ if (!($this->getApplication() instanceof CMSApplication))
{
- throw new AjaxNonCmsAppException;
+ Log::add("This is not a CMS application", Log::NOTICE, 'webauthn.system');
+
+ return;
}
$akaction = $input->getCmd('akaction');
- $token = Joomla::getToken();
- if ($input->getInt($token, 0) != 1)
+ if (!$this->getApplication()->checkToken('request'))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'));
}
@@ -79,32 +86,62 @@ public function onAjaxWebauthn(): void
}
// Call the plugin event onAjaxWebauthnSomething where Something is the akaction param.
- $eventName = 'onAjaxWebauthn' . ucfirst($akaction);
+ /** @var AbstractEvent|ResultAwareInterface $triggerEvent */
+ $eventName = 'onAjaxWebauthn' . ucfirst($akaction);
- $results = $app->triggerEvent($eventName, []);
-
- foreach ($results as $r)
+ switch ($eventName)
{
- if (\is_null($r))
- {
- continue;
- }
+ case 'onAjaxWebauthn':
+ $eventClass = PlgSystemWebauthnAjax::class;
+ break;
- $result = $r;
+ case 'onAjaxWebauthnChallenge':
+ $eventClass = PlgSystemWebauthnAjaxChallenge::class;
+ break;
+
+ case 'onAjaxWebauthnCreate':
+ $eventClass = PlgSystemWebauthnAjaxCreate::class;
+ break;
+
+ case 'onAjaxWebauthnDelete':
+ $eventClass = PlgSystemWebauthnAjaxDelete::class;
+ break;
+
+ case 'onAjaxWebauthnInitcreate':
+ $eventClass = PlgSystemWebauthnAjaxInitCreate::class;
+ break;
- break;
+ case 'onAjaxWebauthnLogin':
+ $eventClass = PlgSystemWebauthnAjaxLogin::class;
+ break;
+
+ case 'onAjaxWebauthnSavelabel':
+ $eventClass = PlgSystemWebauthnAjaxSaveLabel::class;
+ break;
+
+ default:
+ $eventClass = GenericEvent::class;
+ break;
}
- }
- catch (AjaxNonCmsAppException $e)
- {
- Joomla::log('system', "This is not a CMS application", Log::NOTICE);
+
+ $triggerEvent = new $eventClass($eventName, []);
+ $result = $this->getApplication()->getDispatcher()->dispatch($eventName, $triggerEvent);
+ $results = ($result instanceof ResultAwareInterface) ? ($result['result'] ?? []) : [];
+ $result = array_reduce(
+ $results,
+ function ($carry, $result)
+ {
+ return $carry ?? $result;
+ },
+ null
+ );
}
catch (Exception $e)
{
- Joomla::log('system', "Callback failure, redirecting to $returnURL.");
- Joomla::setSessionVar('returnUrl', null, 'plg_system_webauthn');
- $app->enqueueMessage($e->getMessage(), 'error');
- $app->redirect($returnURL);
+ Log::add("Callback failure, redirecting to $returnURL.", Log::DEBUG, 'webauthn.system');
+ $this->getApplication()->getSession()->set('plg_system_webauthn.returnUrl', null);
+ $this->getApplication()->enqueueMessage($e->getMessage(), 'error');
+ $this->getApplication()->redirect($returnURL);
return;
}
@@ -113,14 +150,8 @@ public function onAjaxWebauthn(): void
{
switch ($input->getCmd('encoding', 'json'))
{
- case 'jsonhash':
- Joomla::log('system', "Callback complete, returning JSON inside ### markers.");
- echo '###' . json_encode($result) . '###';
-
- break;
-
case 'raw':
- Joomla::log('system', "Callback complete, returning raw response.");
+ Log::add("Callback complete, returning raw response.", Log::DEBUG, 'webauthn.system');
echo $result;
break;
@@ -131,35 +162,35 @@ public function onAjaxWebauthn(): void
if (isset($result['message']))
{
$type = $result['type'] ?? 'info';
- $app->enqueueMessage($result['message'], $type);
+ $this->getApplication()->enqueueMessage($result['message'], $type);
$modifiers = " and setting a system message of type $type";
}
if (isset($result['url']))
{
- Joomla::log('system', "Callback complete, performing redirection to {$result['url']}{$modifiers}.");
- $app->redirect($result['url']);
+ Log::add("Callback complete, performing redirection to {$result['url']}{$modifiers}.", Log::DEBUG, 'webauthn.system');
+ $this->getApplication()->redirect($result['url']);
}
- Joomla::log('system', "Callback complete, performing redirection to {$result}{$modifiers}.");
- $app->redirect($result);
+ Log::add("Callback complete, performing redirection to {$result}{$modifiers}.", Log::DEBUG, 'webauthn.system');
+ $this->getApplication()->redirect($result);
return;
default:
- Joomla::log('system', "Callback complete, returning JSON.");
+ Log::add("Callback complete, returning JSON.", Log::DEBUG, 'webauthn.system');
echo json_encode($result);
break;
}
- $app->close(200);
+ $this->getApplication()->close(200);
}
- Joomla::log('system', "Null response from AJAX callback, redirecting to $returnURL");
- Joomla::setSessionVar('returnUrl', null, 'plg_system_webauthn');
+ Log::add("Null response from AJAX callback, redirecting to $returnURL", Log::DEBUG, 'webauthn.system');
+ $this->getApplication()->getSession()->set('plg_system_webauthn.returnUrl', null);
- $app->redirect($returnURL);
+ $this->getApplication()->redirect($returnURL);
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php
index 7123ee46a0d61..47182532a4471 100644
--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php
@@ -13,17 +13,13 @@
\defined('_JEXEC') or die();
use Exception;
-use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxChallenge;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
+use Joomla\CMS\User\UserFactoryInterface;
use Joomla\CMS\User\UserHelper;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
-use Throwable;
-use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
-use Webauthn\PublicKeyCredentialRequestOptions;
-use Webauthn\PublicKeyCredentialSource;
-use Webauthn\PublicKeyCredentialUserEntity;
+use Joomla\Event\Event;
/**
* Ajax handler for akaction=challenge
@@ -39,27 +35,23 @@ trait AjaxHandlerChallenge
* Returns the public key set for the user and a unique challenge in a Public Key Credential Request encoded as
* JSON.
*
- * @return string A JSON-encoded object or JSON-encoded false if the username is invalid or no credentials stored
+ * @param AjaxChallenge $event The event we are handling
*
- * @throws Exception
+ * @return void
*
+ * @throws Exception
* @since 4.0.0
*/
- public function onAjaxWebauthnChallenge()
+ public function onAjaxWebauthnChallenge(AjaxChallenge $event): void
{
- // Load the language files
- $this->loadLanguage();
-
// Initialize objects
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $input = $app->input;
- $repository = new CredentialRepository;
+ $session = $this->getApplication()->getSession();
+ $input = $this->getApplication()->input;
// Retrieve data from the request
$username = $input->getUsername('username', '');
$returnUrl = base64_encode(
- Joomla::getSessionVar('returnUrl', Uri::current(), 'plg_system_webauthn')
+ $session->get('plg_system_webauthn.returnUrl', Uri::current())
);
$returnUrl = $input->getBase64('returnUrl', $returnUrl);
$returnUrl = base64_decode($returnUrl);
@@ -71,12 +63,14 @@ public function onAjaxWebauthnChallenge()
$returnUrl = Uri::base();
}
- Joomla::setSessionVar('returnUrl', $returnUrl, 'plg_system_webauthn');
+ $session->set('plg_system_webauthn.returnUrl', $returnUrl);
// Do I have a username?
if (empty($username))
{
- return json_encode(false);
+ $event->addResult(false);
+
+ return;
}
// Is the username valid?
@@ -91,73 +85,32 @@ public function onAjaxWebauthnChallenge()
if ($userId <= 0)
{
- return json_encode(false);
+ $event->addResult(false);
+
+ return;
}
- // Load the saved credentials into an array of PublicKeyCredentialDescriptor objects
try
{
- $userEntity = new PublicKeyCredentialUserEntity(
- '', $repository->getHandleFromUserId($userId), ''
- );
- $credentials = $repository->findAllForUserEntity($userEntity);
+ $myUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
}
catch (Exception $e)
{
- return json_encode(false);
+ $myUser = new User;
}
- // No stored credentials?
- if (empty($credentials))
+ if ($myUser->id != $userId || $myUser->guest)
{
- return json_encode(false);
- }
+ $event->addResult(false);
- $registeredPublicKeyCredentialDescriptors = [];
-
- /** @var PublicKeyCredentialSource $record */
- foreach ($credentials as $record)
- {
- try
- {
- $registeredPublicKeyCredentialDescriptors[] = $record->getPublicKeyCredentialDescriptor();
- }
- catch (Throwable $e)
- {
- continue;
- }
+ return;
}
- // Extensions
- $extensions = new AuthenticationExtensionsClientInputs;
-
- // Public Key Credential Request Options
- $publicKeyCredentialRequestOptions = new PublicKeyCredentialRequestOptions(
- random_bytes(32),
- 60000,
- Uri::getInstance()->toString(['host']),
- $registeredPublicKeyCredentialDescriptors,
- PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
- $extensions
- );
+ $publicKeyCredentialRequestOptions = $this->authenticationHelper->getPubkeyRequestOptions($myUser);
- // Save in session. This is used during the verification stage to prevent replay attacks.
- Joomla::setSessionVar(
- 'publicKeyCredentialRequestOptions',
- base64_encode(serialize($publicKeyCredentialRequestOptions)),
- 'plg_system_webauthn'
- );
- Joomla::setSessionVar(
- 'userHandle',
- $repository->getHandleFromUserId($userId),
- 'plg_system_webauthn'
- );
- Joomla::setSessionVar('userId', $userId, 'plg_system_webauthn');
+ $session->set('plg_system_webauthn.userId', $userId);
// Return the JSON encoded data to the caller
- return json_encode(
- $publicKeyCredentialRequestOptions,
- JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
- );
+ $event->addResult(json_encode($publicKeyCredentialRequestOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerCreate.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerCreate.php
index 06934e2561ba0..990639bc8702d 100644
--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerCreate.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerCreate.php
@@ -13,13 +13,12 @@
\defined('_JEXEC') or die();
use Exception;
-use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxCreate;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
+use Joomla\CMS\Layout\FileLayout;
use Joomla\CMS\User\UserFactoryInterface;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
-use Joomla\Plugin\System\Webauthn\Helper\CredentialsCreation;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Event\Event;
use RuntimeException;
use Webauthn\PublicKeyCredentialSource;
@@ -35,17 +34,15 @@ trait AjaxHandlerCreate
/**
* Handle the callback to add a new WebAuthn authenticator
*
- * @return string
+ * @param AjaxCreate $event The event we are handling
*
- * @throws Exception
+ * @return void
*
+ * @throws Exception
* @since 4.0.0
*/
- public function onAjaxWebauthnCreate(): string
+ public function onAjaxWebauthnCreate(AjaxCreate $event): void
{
- // Load the language files
- $this->loadLanguage();
-
/**
* Fundamental sanity check: this callback is only allowed after a Public Key has been created server-side and
* the user it was created for matches the current user.
@@ -55,7 +52,8 @@ public function onAjaxWebauthnCreate(): string
* someone else's Webauthn configuration thus mitigating a major privacy and security risk. So, please, DO NOT
* remove this sanity check!
*/
- $storedUserId = Joomla::getSessionVar('registration_user_id', 0, 'plg_system_webauthn');
+ $session = $this->getApplication()->getSession();
+ $storedUserId = $session->get('plg_system_webauthn.registration_user_id', 0);
$thatUser = empty($storedUserId) ?
Factory::getApplication()->getIdentity() :
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($storedUserId);
@@ -64,27 +62,25 @@ public function onAjaxWebauthnCreate(): string
if ($thatUser->guest || ($thatUser->id != $myUser->id))
{
// Unset the session variables used for registering authenticators (security precaution).
- Joomla::unsetSessionVar('registration_user_id', 'plg_system_webauthn');
- Joomla::unsetSessionVar('publicKeyCredentialCreationOptions', 'plg_system_webauthn');
+ $session->set('plg_system_webauthn.registration_user_id', null);
+ $session->set('plg_system_webauthn.publicKeyCredentialCreationOptions', null);
// Politely tell the presumed hacker trying to abuse this callback to go away.
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
}
// Get the credentials repository object. It's outside the try-catch because I also need it to display the GUI.
- $credentialRepository = new CredentialRepository;
+ $credentialRepository = $this->authenticationHelper->getCredentialsRepository();
// Try to validate the browser data. If there's an error I won't save anything and pass the message to the GUI.
try
{
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $input = $app->input;
+ $input = $this->getApplication()->input;
// Retrieve the data sent by the device
$data = $input->get('data', '', 'raw');
- $publicKeyCredentialSource = CredentialsCreation::validateAuthenticationData($data);
+ $publicKeyCredentialSource = $this->authenticationHelper->validateAttestationResponse($data);
if (!\is_object($publicKeyCredentialSource) || !($publicKeyCredentialSource instanceof PublicKeyCredentialSource))
{
@@ -100,14 +96,16 @@ public function onAjaxWebauthnCreate(): string
}
// Unset the session variables used for registering authenticators (security precaution).
- Joomla::unsetSessionVar('registration_user_id', 'plg_system_webauthn');
- Joomla::unsetSessionVar('publicKeyCredentialCreationOptions', 'plg_system_webauthn');
+ $session->set('plg_system_webauthn.registration_user_id', null);
+ $session->set('plg_system_webauthn.publicKeyCredentialCreationOptions', null);
// Render the GUI and return it
$layoutParameters = [
- 'user' => $thatUser,
- 'allow_add' => $thatUser->id == $myUser->id,
- 'credentials' => $credentialRepository->getAll($thatUser->id),
+ 'user' => $thatUser,
+ 'allow_add' => $thatUser->id == $myUser->id,
+ 'credentials' => $credentialRepository->getAll($thatUser->id),
+ 'knownAuthenticators' => $this->authenticationHelper->getKnownAuthenticators(),
+ 'attestationSupport' => $this->authenticationHelper->hasAttestationSupport(),
];
if (isset($error) && !empty($error))
@@ -115,6 +113,8 @@ public function onAjaxWebauthnCreate(): string
$layoutParameters['error'] = $error;
}
- return Joomla::renderLayout('plugins.system.webauthn.manage', $layoutParameters);
+ $layout = new FileLayout('plugins.system.webauthn.manage', JPATH_SITE . '/plugins/system/webauthn/layout');
+
+ $event->addResult($layout->render($layoutParameters));
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerDelete.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerDelete.php
index 6bf7264be4d9f..0246c8a14e831 100644
--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerDelete.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerDelete.php
@@ -13,9 +13,9 @@
\defined('_JEXEC') or die();
use Exception;
-use Joomla\CMS\Application\CMSApplication;
-use Joomla\CMS\Factory;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxDelete;
+use Joomla\CMS\User\User;
+use Joomla\Event\Event;
/**
* Ajax handler for akaction=savelabel
@@ -29,21 +29,16 @@ trait AjaxHandlerDelete
/**
* Handle the callback to remove an authenticator
*
- * @return boolean
- * @throws Exception
+ * @param AjaxDelete $event The event we are handling
*
+ * @return void
* @since 4.0.0
*/
- public function onAjaxWebauthnDelete(): bool
+ public function onAjaxWebauthnDelete(AjaxDelete $event): void
{
- // Load the language files
- $this->loadLanguage();
-
// Initialize objects
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $input = $app->input;
- $repository = new CredentialRepository;
+ $input = $this->getApplication()->input;
+ $repository = $this->authenticationHelper->getCredentialsRepository();
// Retrieve data from the request
$credentialId = $input->getBase64('credential_id', '');
@@ -51,30 +46,39 @@ public function onAjaxWebauthnDelete(): bool
// Is this a valid credential?
if (empty($credentialId))
{
- return false;
+ $event->addResult(false);
+
+ return;
}
$credentialId = base64_decode($credentialId);
if (empty($credentialId) || !$repository->has($credentialId))
{
- return false;
+ $event->addResult(false);
+
+ return;
}
// Make sure I am editing my own key
try
{
+ $user = $this->getApplication()->getIdentity() ?? new User;
$credentialHandle = $repository->getUserHandleFor($credentialId);
- $myHandle = $repository->getHandleFromUserId($app->getIdentity()->id);
+ $myHandle = $repository->getHandleFromUserId($user->id);
}
catch (Exception $e)
{
- return false;
+ $event->addResult(false);
+
+ return;
}
if ($credentialHandle !== $myHandle)
{
- return false;
+ $event->addResult(false);
+
+ return;
}
// Delete the record
@@ -84,9 +88,11 @@ public function onAjaxWebauthnDelete(): bool
}
catch (Exception $e)
{
- return false;
+ $event->addResult(false);
+
+ return;
}
- return true;
+ $event->addResult(true);
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerInitCreate.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerInitCreate.php
new file mode 100644
index 0000000000000..9b27f5f7e947c
--- /dev/null
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerInitCreate.php
@@ -0,0 +1,62 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Webauthn\PluginTraits;
+
+// Protect from unauthorized access
+\defined('_JEXEC') or die();
+
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxInitCreate;
+use Joomla\CMS\Factory;
+use Joomla\CMS\User\User;
+
+/**
+ * Ajax handler for akaction=initcreate
+ *
+ * Returns the Public Key Creation Options to start the attestation ceremony on the browser.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+trait AjaxHandlerInitCreate
+{
+ /**
+ * Returns the Public Key Creation Options to start the attestation ceremony on the browser.
+ *
+ * @param AjaxInitCreate $event The event we are handling
+ *
+ * @return void
+ * @throws \Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onAjaxWebauthnInitcreate(AjaxInitCreate $event): void
+ {
+ // Make sure I have a valid user
+ $user = Factory::getApplication()->getIdentity();
+
+ if (!($user instanceof User) || $user->guest)
+ {
+ $event->addResult(new \stdClass);
+
+ return;
+ }
+
+ // I need the server to have either GMP or BCComp support to attest new authenticators
+ if (function_exists('gmp_intval') === false && function_exists('bccomp') === false)
+ {
+ $event->addResult(new \stdClass);
+
+ return;
+ }
+
+ $session = $this->getApplication()->getSession();
+ $session->set('plg_system_webauthn.registration_user_id', $user->id);
+
+ $event->addResult($this->authenticationHelper->getPubKeyCreationOptions($user));
+ }
+}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
index ec6093af09c85..3afc29fc75cdb 100644
--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
@@ -12,39 +12,19 @@
// Protect from unauthorized access
\defined('_JEXEC') or die();
-use CBOR\Decoder;
-use CBOR\OtherObject\OtherObjectManager;
-use CBOR\Tag\TagObjectManager;
-use Cose\Algorithm\Manager;
-use Cose\Algorithm\Signature\ECDSA;
-use Cose\Algorithm\Signature\EdDSA;
-use Cose\Algorithm\Signature\RSA;
use Exception;
-use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Authentication\Authentication;
+use Joomla\CMS\Authentication\AuthenticationResponse;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxLogin;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
+use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
-use Laminas\Diactoros\ServerRequestFactory;
use RuntimeException;
use Throwable;
-use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
-use Webauthn\AttestationStatement\AttestationObjectLoader;
-use Webauthn\AttestationStatement\AttestationStatementSupportManager;
-use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
-use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
-use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
-use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
-use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
-use Webauthn\AuthenticatorAssertionResponse;
-use Webauthn\AuthenticatorAssertionResponseValidator;
-use Webauthn\PublicKeyCredentialLoader;
-use Webauthn\PublicKeyCredentialRequestOptions;
-use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
/**
* Ajax handler for akaction=login
@@ -59,56 +39,90 @@ trait AjaxHandlerLogin
* Returns the public key set for the user and a unique challenge in a Public Key Credential Request encoded as
* JSON.
*
+ * @param AjaxLogin $event The event we are handling
+ *
* @return void
*
- * @throws Exception
* @since 4.0.0
*/
- public function onAjaxWebauthnLogin(): void
+ public function onAjaxWebauthnLogin(AjaxLogin $event): void
{
- // Load the language files
- $this->loadLanguage();
-
- $returnUrl = Joomla::getSessionVar('returnUrl', Uri::base(), 'plg_system_webauthn');
- $userId = Joomla::getSessionVar('userId', 0, 'plg_system_webauthn');
+ $session = $this->getApplication()->getSession();
+ $returnUrl = $session->get('plg_system_webauthn.returnUrl', Uri::base());
+ $userId = $session->get('plg_system_webauthn.userId', 0);
try
{
- // Sanity check
+ $credentialRepository = $this->authenticationHelper->getCredentialsRepository();
+
+ // No user ID: no username was provided and the resident credential refers to an unknown user handle. DIE!
if (empty($userId))
{
+ Log::add('Cannot determine the user ID', Log::NOTICE, 'webauthn.system');
+
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
- // Make sure the user exists
+ // Do I have a valid user?
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
if ($user->id != $userId)
{
+ $message = sprintf('User #%d does not exist', $userId);
+ Log::add($message, Log::NOTICE, 'webauthn.system');
+
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
- // Validate the authenticator response
- $this->validateResponse();
+ // Validate the authenticator response and get the user handle
+ $userHandle = $this->getUserHandleFromResponse($user);
+
+ if (is_null($userHandle))
+ {
+ Log::add('Cannot retrieve the user handle from the request; the browser did not assert our request.', Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ // Does the user handle match the user ID? This should never trigger by definition of the login check.
+ $validUserHandle = $credentialRepository->getHandleFromUserId($userId);
+
+ if ($userHandle != $validUserHandle)
+ {
+ $message = sprintf('Invalid user handle; expected %s, got %s', $validUserHandle, $userHandle);
+ Log::add($message, Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ // Make sure the user exists
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
+
+ if ($user->id != $userId)
+ {
+ $message = sprintf('Invalid user ID; expected %d, got %d', $userId, $user->id);
+ Log::add($message, Log::NOTICE, 'webauthn.system');
+
+ throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
// Login the user
- Joomla::log('system', "Logging in the user", Log::INFO);
- Joomla::loginUser((int) $userId);
+ Log::add("Logging in the user", Log::INFO, 'webauthn.system');
+ $this->loginUser((int) $userId);
}
catch (Throwable $e)
{
- Joomla::setSessionVar('publicKeyCredentialRequestOptions', null, 'plg_system_webauthn');
- Joomla::setSessionVar('userHandle', null, 'plg_system_webauthn');
+ $session->set('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
- $response = Joomla::getAuthenticationResponseObject();
+ $response = $this->getAuthenticationResponseObject();
$response->status = Authentication::STATUS_UNKNOWN;
// phpcs:ignore
$response->error_message = $e->getMessage();
- Joomla::log('system', sprintf("Received login failure. Message: %s", $e->getMessage()), Log::ERROR);
+ Log::add(sprintf("Received login failure. Message: %s", $e->getMessage()), Log::ERROR, 'webauthn.system');
// This also enqueues the login failure message for display after redirection. Look for JLog in that method.
- Joomla::processLoginFailure($response, null, 'system');
+ $this->processLoginFailure($response, null, 'system');
}
finally
{
@@ -118,153 +132,199 @@ public function onAjaxWebauthnLogin(): void
*/
// Remove temporary information for security reasons
- Joomla::setSessionVar('publicKeyCredentialRequestOptions', null, 'plg_system_webauthn');
- Joomla::setSessionVar('userHandle', null, 'plg_system_webauthn');
- Joomla::setSessionVar('returnUrl', null, 'plg_system_webauthn');
- Joomla::setSessionVar('userId', null, 'plg_system_webauthn');
+ $session->set('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
+ $session->set('plg_system_webauthn.returnUrl', null);
+ $session->set('plg_system_webauthn.userId', null);
// Redirect back to the page we were before.
- Factory::getApplication()->redirect($returnUrl);
+ $this->getApplication()->redirect($returnUrl);
}
}
/**
- * Validate the authenticator response sent to us by the browser.
+ * Logs in a user to the site, bypassing the authentication plugins.
*
- * @return void
+ * @param int $userId The user ID to log in
*
+ * @return void
* @throws Exception
- *
- * @since 4.0.0
+ * @since __DEPLOY_VERSION__
*/
- private function validateResponse(): void
+ private function loginUser(int $userId): void
{
- // Initialize objects
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $input = $app->input;
- $credentialRepository = new CredentialRepository;
+ // Trick the class auto-loader into loading the necessary classes
+ class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
- // Retrieve data from the request and session
- $data = $input->getBase64('data', '');
- $data = base64_decode($data);
+ // Fake a successful login message
+ $isAdmin = $this->getApplication()->isClient('administrator');
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
- if (empty($data))
+ // Does the user account have a pending activation?
+ if (!empty($user->activation))
{
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ throw new RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
}
- $publicKeyCredentialRequestOptions = $this->getPKCredentialRequestOptions();
-
- // Cose Algorithm Manager
- $coseAlgorithmManager = new Manager;
- $coseAlgorithmManager->add(new ECDSA\ES256);
- $coseAlgorithmManager->add(new ECDSA\ES512);
- $coseAlgorithmManager->add(new EdDSA\EdDSA);
- $coseAlgorithmManager->add(new RSA\RS1);
- $coseAlgorithmManager->add(new RSA\RS256);
- $coseAlgorithmManager->add(new RSA\RS512);
-
- // Create a CBOR Decoder object
- $otherObjectManager = new OtherObjectManager;
- $tagObjectManager = new TagObjectManager;
- $decoder = new Decoder($tagObjectManager, $otherObjectManager);
-
- // Attestation Statement Support Manager
- $attestationStatementSupportManager = new AttestationStatementSupportManager;
- $attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
- $attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport($decoder));
-
- /*
- $attestationStatementSupportManager->add(
- new AndroidSafetyNetAttestationStatementSupport(
- HttpFactory::getHttp(), 'GOOGLE_SAFETYNET_API_KEY', new RequestFactory
- )
- );
- */
- $attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport($decoder));
- $attestationStatementSupportManager->add(new TPMAttestationStatementSupport);
- $attestationStatementSupportManager->add(new PackedAttestationStatementSupport($decoder, $coseAlgorithmManager));
-
- // Attestation Object Loader
- $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager, $decoder);
-
- // Public Key Credential Loader
- $publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader, $decoder);
-
- // The token binding handler
- $tokenBindingHandler = new TokenBindingNotSupportedHandler;
-
- // Extension Output Checker Handler
- $extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler;
-
- // Authenticator Assertion Response Validator
- $authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
- $credentialRepository,
- $decoder,
- $tokenBindingHandler,
- $extensionOutputCheckerHandler,
- $coseAlgorithmManager
- );
+ // Is the user account blocked?
+ if ($user->block)
+ {
+ throw new RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
+ }
- // We init the Symfony Request object
- $request = ServerRequestFactory::fromGlobals();
+ $statusSuccess = Authentication::STATUS_SUCCESS;
- // Load the data
- $publicKeyCredential = $publicKeyCredentialLoader->load($data);
- $response = $publicKeyCredential->getResponse();
+ $response = $this->getAuthenticationResponseObject();
+ $response->status = $statusSuccess;
+ $response->username = $user->username;
+ $response->fullname = $user->name;
+ // phpcs:ignore
+ $response->error_message = '';
+ $response->language = $user->getParam('language');
+ $response->type = 'Passwordless';
- // Check if the response is an Authenticator Assertion Response
- if (!$response instanceof AuthenticatorAssertionResponse)
+ if ($isAdmin)
{
- throw new RuntimeException('Not an authenticator assertion response');
+ $response->language = $user->getParam('admin_language');
}
- // Check the response against the attestation request
- $userHandle = Joomla::getSessionVar('userHandle', null, 'plg_system_webauthn');
- /** @var AuthenticatorAssertionResponse $authenticatorAssertionResponse */
- $authenticatorAssertionResponse = $publicKeyCredential->getResponse();
- $authenticatorAssertionResponseValidator->check(
- $publicKeyCredential->getRawId(),
- $authenticatorAssertionResponse,
- $publicKeyCredentialRequestOptions,
- $request,
- $userHandle
- );
+ /**
+ * Set up the login options.
+ *
+ * The 'remember' element forces the use of the Remember Me feature when logging in with Webauthn, as the
+ * users would expect.
+ *
+ * The 'action' element is actually required by plg_user_joomla. It is the core ACL action the logged in user
+ * must be allowed for the login to succeed. Please note that front-end and back-end logins use a different
+ * action. This allows us to provide the WebAuthn button on both front- and back-end and be sure that if a
+ * used with no backend access tries to use it to log in Joomla! will just slap him with an error message about
+ * insufficient privileges - the same thing that'd happen if you tried to use your front-end only username and
+ * password in a back-end login form.
+ */
+ $options = [
+ 'remember' => true,
+ 'action' => 'core.login.site',
+ ];
+
+ if ($isAdmin)
+ {
+ $options['action'] = 'core.login.admin';
+ }
+
+ // Run the user plugins. They CAN block login by returning boolean false and setting $response->error_message.
+ PluginHelper::importPlugin('user');
+ $eventClassName = self::getEventClassByEventName('onUserLogin');
+ $event = new $eventClassName('onUserLogin', [(array) $response, $options]);
+ $result = $this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
+ $results = !isset($result['result']) || \is_null($result['result']) ? [] : $result['result'];
+
+ // If there is no boolean FALSE result from any plugin the login is successful.
+ if (in_array(false, $results, true) === false)
+ {
+ // Set the user in the session, letting Joomla! know that we are logged in.
+ $this->getApplication()->getSession()->set('user', $user);
+
+ // Trigger the onUserAfterLogin event
+ $options['user'] = $user;
+ $options['responseType'] = $response->type;
+
+ // The user is successfully logged in. Run the after login events
+ $eventClassName = self::getEventClassByEventName('onUserAfterLogin');
+ $event = new $eventClassName('onUserAfterLogin', [$options]);
+ $this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
+
+ return;
+ }
+
+ // If we are here the plugins marked a login failure. Trigger the onUserLoginFailure Event.
+ $eventClassName = self::getEventClassByEventName('onUserLoginFailure');
+ $event = new $eventClassName('onUserLoginFailure', [(array) $response]);
+ $this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
+
+ // Log the failure
+ // phpcs:ignore
+ Log::add($response->error_message, Log::WARNING, 'jerror');
+
+ // Throw an exception to let the caller know that the login failed
+ // phpcs:ignore
+ throw new RuntimeException($response->error_message);
}
/**
- * Retrieve the public key credential request options saved in the session. If they do not exist or are corrupt it
- * is a hacking attempt and we politely tell the hacker to go away.
+ * Returns a (blank) Joomla! authentication response
*
- * @return PublicKeyCredentialRequestOptions
+ * @return AuthenticationResponse
*
- * @since 4.0.0
+ * @since __DEPLOY_VERSION__
*/
- private function getPKCredentialRequestOptions(): PublicKeyCredentialRequestOptions
+ private function getAuthenticationResponseObject(): AuthenticationResponse
{
- $encodedOptions = Joomla::getSessionVar('publicKeyCredentialRequestOptions', null, 'plg_system_webauthn');
+ // Force the class auto-loader to load the JAuthentication class
+ class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
- if (empty($encodedOptions))
- {
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
- }
+ return new AuthenticationResponse;
+ }
- try
+ /**
+ * Have Joomla! process a login failure
+ *
+ * @param AuthenticationResponse $response The Joomla! auth response object
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function processLoginFailure(AuthenticationResponse $response): bool
+ {
+ // Import the user plugin group.
+ PluginHelper::importPlugin('user');
+
+ // Trigger onUserLoginFailure Event.
+ Log::add('Calling onUserLoginFailure plugin event', Log::INFO, 'plg_system_webauthn');
+
+ $eventClassName = self::getEventClassByEventName('onUserLoginFailure');
+ $event = new $eventClassName('onUserLoginFailure', [(array) $response]);
+ $this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
+
+ // If status is success, any error will have been raised by the user plugin
+ $expectedStatus = Authentication::STATUS_SUCCESS;
+
+ if ($response->status !== $expectedStatus)
{
- $publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
+ Log::add('The login failure has been logged in Joomla\'s error log', Log::INFO, 'webauthn.system');
+
+ // Everything logged in the 'jerror' category ends up being enqueued in the application message queue.
+ // phpcs:ignore
+ Log::add($response->error_message, Log::WARNING, 'jerror');
}
- catch (Exception $e)
+ else
{
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ $message = 'A login failure was caused by a third party user plugin but it did not return any' .
+ 'further information.';
+ Log::add($message, Log::WARNING, 'webauthn.system');
}
- if (!\is_object($publicKeyCredentialCreationOptions)
- || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialRequestOptions))
- {
- throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
- }
+ return false;
+ }
+
+ /**
+ * Validate the authenticator response sent to us by the browser.
+ *
+ * @param User $user The user we are trying to log in.
+ *
+ * @return string|null The user handle or null
+ *
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ private function getUserHandleFromResponse(User $user): ?string
+ {
+ // Retrieve data from the request and session
+ $pubKeyCredentialSource = $this->authenticationHelper->validateAssertionResponse(
+ $this->getApplication()->input->getBase64('data', ''),
+ $user
+ );
- return $publicKeyCredentialCreationOptions;
+ return $pubKeyCredentialSource ? $pubKeyCredentialSource->getUserHandle() : null;
}
+
}
diff --git a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerSaveLabel.php b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerSaveLabel.php
index b2fae9ac359f4..49b152945fdcb 100644
--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerSaveLabel.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerSaveLabel.php
@@ -13,9 +13,8 @@
\defined('_JEXEC') or die();
use Exception;
-use Joomla\CMS\Application\CMSApplication;
-use Joomla\CMS\Factory;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
+use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxSaveLabel;
+use Joomla\CMS\User\User;
/**
* Ajax handler for akaction=savelabel
@@ -29,19 +28,17 @@ trait AjaxHandlerSaveLabel
/**
* Handle the callback to rename an authenticator
*
- * @return boolean
+ * @param AjaxSaveLabel $event The event we are handling
*
- * @throws Exception
+ * @return void
*
* @since 4.0.0
*/
- public function onAjaxWebauthnSavelabel(): bool
+ public function onAjaxWebauthnSavelabel(AjaxSaveLabel $event): void
{
// Initialize objects
- /** @var CMSApplication $app */
- $app = Factory::getApplication();
- $input = $app->input;
- $repository = new CredentialRepository;
+ $input = $this->getApplication()->input;
+ $repository = $this->authenticationHelper->getCredentialsRepository();
// Retrieve data from the request
$credentialId = $input->getBase64('credential_id', '');
@@ -50,36 +47,47 @@ public function onAjaxWebauthnSavelabel(): bool
// Is this a valid credential?
if (empty($credentialId))
{
- return false;
+ $event->addResult(false);
+
+ return;
}
$credentialId = base64_decode($credentialId);
if (empty($credentialId) || !$repository->has($credentialId))
{
- return false;
+ $event->addResult(false);
+
+ return;
}
// Make sure I am editing my own key
try
{
$credentialHandle = $repository->getUserHandleFor($credentialId);
- $myHandle = $repository->getHandleFromUserId($app->getIdentity()->id);
+ $user = $this->getApplication()->getIdentity() ?? new User;
+ $myHandle = $repository->getHandleFromUserId($user->id);
}
catch (Exception $e)
{
- return false;
+ $event->addResult(false);
+
+ return;
}
if ($credentialHandle !== $myHandle)
{
- return false;
+ $event->addResult(false);
+
+ return;
}
// Make sure the new label is not empty
if (empty($newLabel))
{
- return false;
+ $event->addResult(false);
+
+ return;
}
// Save the new label
@@ -89,9 +97,11 @@ public function onAjaxWebauthnSavelabel(): bool
}
catch (Exception $e)
{
- return false;
+ $event->addResult(false);
+
+ return;
}
- return true;
+ $event->addResult(true);
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/EventReturnAware.php b/plugins/system/webauthn/src/PluginTraits/EventReturnAware.php
new file mode 100644
index 0000000000000..7327f698e21ac
--- /dev/null
+++ b/plugins/system/webauthn/src/PluginTraits/EventReturnAware.php
@@ -0,0 +1,45 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Webauthn\PluginTraits;
+
+defined('_JEXEC') or die();
+
+use Joomla\Event\Event;
+
+/**
+ * Utility trait to facilitate returning data from event handlers.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+trait EventReturnAware
+{
+ /**
+ * Adds a result value to an event
+ *
+ * @param Event $event The event we were processing
+ * @param mixed $value The value to append to the event's results
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ private function returnFromEvent(Event $event, $value = null): void
+ {
+ $result = $event->getArgument('result') ?: [];
+
+ if (!is_array($result))
+ {
+ $result = [$result];
+ }
+
+ $result[] = $value;
+
+ $event->setArgument('result', $result);
+ }
+}
diff --git a/plugins/system/webauthn/src/PluginTraits/UserDeletion.php b/plugins/system/webauthn/src/PluginTraits/UserDeletion.php
index 24708deafa3c8..ae36c7dd38388 100644
--- a/plugins/system/webauthn/src/PluginTraits/UserDeletion.php
+++ b/plugins/system/webauthn/src/PluginTraits/UserDeletion.php
@@ -14,8 +14,9 @@
use Exception;
use Joomla\CMS\Factory;
+use Joomla\CMS\Log\Log;
use Joomla\Database\DatabaseDriver;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Event\Event;
use Joomla\Utilities\ArrayHelper;
/**
@@ -30,28 +31,31 @@ trait UserDeletion
*
* This method is called after user data is deleted from the database.
*
- * @param array $user Holds the user data
- * @param bool $success True if user was successfully stored in the database
- * @param string $msg Message
+ * @param Event $event The event we are handling
*
* @return void
*
- * @throws Exception
- *
* @since 4.0.0
*/
- public function onUserAfterDelete(array $user, bool $success, ?string $msg): void
+ public function onUserAfterDelete(Event $event): void
{
+ /**
+ * @var array $user Holds the user data
+ * @var bool $success True if user was successfully stored in the database
+ * @var string|null $msg Message
+ */
+ [$user, $success, $msg] = $event->getArguments();
+
if (!$success)
{
- return;
+ $this->returnFromEvent($event, true);
}
$userId = ArrayHelper::getValue($user, 'id', 0, 'int');
if ($userId)
{
- Joomla::log('system', "Removing WebAuthn Passwordless Login information for deleted user #{$userId}");
+ Log::add("Removing WebAuthn Passwordless Login information for deleted user #{$userId}", Log::DEBUG, 'webauthn.system');
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
@@ -61,7 +65,16 @@ public function onUserAfterDelete(array $user, bool $success, ?string $msg): voi
->where($db->qn('user_id') . ' = :userId')
->bind(':userId', $userId);
- $db->setQuery($query)->execute();
+ try
+ {
+ $db->setQuery($query)->execute();
+ }
+ catch (Exception $e)
+ {
+ // Don't worry if this fails
+ }
+
+ $this->returnFromEvent($event, true);
}
}
}
diff --git a/plugins/system/webauthn/src/PluginTraits/UserProfileFields.php b/plugins/system/webauthn/src/PluginTraits/UserProfileFields.php
index 3b7d192751db3..72a4fbe3235e0 100644
--- a/plugins/system/webauthn/src/PluginTraits/UserProfileFields.php
+++ b/plugins/system/webauthn/src/PluginTraits/UserProfileFields.php
@@ -1,10 +1,10 @@
- * @license GNU General Public License version 2 or later; see LICENSE.txt
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
@@ -17,11 +17,12 @@
use Joomla\CMS\Form\Form;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
+use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
-use Joomla\Plugin\System\Webauthn\CredentialRepository;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
+use Joomla\Event\Event;
+use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
use Joomla\Registry\Registry;
/**
@@ -59,6 +60,7 @@ trait UserProfileFields
* stored value. We only use it as a proxy to render a sub-form.
*
* @return string
+ * @since 4.0.0
*/
public static function renderWebauthnProfileField($value): string
{
@@ -67,10 +69,13 @@ public static function renderWebauthnProfileField($value): string
return '';
}
- $credentialRepository = new CredentialRepository;
+ /** @var Webauthn $plugin */
+ $plugin = Factory::getApplication()->bootPlugin('webauthn', 'system');
+ $credentialRepository = $plugin->getAuthenticationHelper()->getCredentialsRepository();
$credentials = $credentialRepository->getAll(self::$userFromFormData->id);
$authenticators = array_map(
- function (array $credential) {
+ function (array $credential)
+ {
return $credential['label'];
},
$credentials
@@ -82,32 +87,36 @@ function (array $credential) {
/**
* Adds additional fields to the user editing form
*
- * @param Form $form The form to be altered.
- * @param mixed $data The associated data for the form.
+ * @param Event $event The event we are handling
*
- * @return boolean
+ * @return void
*
* @throws Exception
- *
* @since 4.0.0
*/
- public function onContentPrepareForm(Form $form, $data)
+ public function onContentPrepareForm(Event $event)
{
+ /**
+ * @var Form $form The form to be altered.
+ * @var mixed $data The associated data for the form.
+ */
+ [$form, $data] = $event->getArguments();
+
// This feature only applies to HTTPS sites.
if (!Uri::getInstance()->isSsl())
{
- return true;
+ return;
}
$name = $form->getName();
$allowedForms = [
- 'com_users.user', 'com_users.profile', 'com_users.registration',
+ 'com_admin.profile', 'com_users.user', 'com_users.profile', 'com_users.registration',
];
if (!\in_array($name, $allowedForms))
{
- return true;
+ return;
}
// Get the user object
@@ -116,25 +125,49 @@ public function onContentPrepareForm(Form $form, $data)
// Make sure the loaded user is the correct one
if (\is_null($user))
{
- return true;
+ return;
}
// Make sure I am either editing myself OR I am a Super User
- if (!Joomla::canEditUser($user))
+ if (!$this->canEditUser($user))
{
- return true;
+ return;
}
// Add the fields to the form.
- Joomla::log(
- 'system',
- 'Injecting WebAuthn Passwordless Login fields in user profile edit page'
- );
+ Log::add('Injecting WebAuthn Passwordless Login fields in user profile edit page', Log::DEBUG, 'webauthn.system');
+
Form::addFormPath(JPATH_PLUGINS . '/' . $this->_type . '/' . $this->_name . '/forms');
- $this->loadLanguage();
$form->loadFile('webauthn', false);
+ }
+
+ /**
+ * @param Event $event The event we are handling
+ *
+ * @return void
+ *
+ * @throws Exception
+ * @since 4.0.0
+ */
+ public function onContentPrepareData(Event $event): void
+ {
+ /**
+ * @var string|null $context The context for the data
+ * @var array|object|null $data An object or array containing the data for the form.
+ */
+ [$context, $data] = $event->getArguments();
+
+ if (!\in_array($context, ['com_users.profile', 'com_users.user']))
+ {
+ return;
+ }
+
+ self::$userFromFormData = $this->getUserFromData($data);
- return true;
+ if (!HTMLHelper::isRegistered('users.webauthnWebauthn'))
+ {
+ HTMLHelper::register('users.webauthn', [__CLASS__, 'renderWebauthnProfileField']);
+ }
}
/**
@@ -178,27 +211,28 @@ private function getUserFromData($data): ?User
}
/**
- * @param string|null $context The context for the data
- * @param array|object|null $data An object or array containing the data for the form.
+ * Is the current user allowed to edit the WebAuthn configuration of $user?
*
- * @return bool
+ * To do so I must either be editing my own account OR I have to be a Super User.
*
- * @since 4.0.0
+ * @param ?User $user The user you want to know if we're allowed to edit
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
*/
- public function onContentPrepareData(?string $context, $data): bool
+ private function canEditUser(?User $user = null): bool
{
- if (!\in_array($context, ['com_users.profile', 'com_users.user']))
+ // I can edit myself, but Guests can't have passwordless logins associated
+ if (empty($user) || $user->guest)
{
return true;
}
- self::$userFromFormData = $this->getUserFromData($data);
-
- if (!HTMLHelper::isRegistered('users.webauthnWebauthn'))
- {
- HTMLHelper::register('users.webauthn', [__CLASS__, 'renderWebauthnProfileField']);
- }
+ // Get the currently logged in used
+ $myUser = $this->getApplication()->getIdentity() ?? new User;
- return true;
+ // I can edit myself. If I'm a Super user I can edit other users too.
+ return ($myUser->id == $user->id) || $myUser->authorise('core.admin');
}
}
diff --git a/plugins/system/webauthn/webauthn.php b/plugins/system/webauthn/webauthn.php
deleted file mode 100644
index 758c923ab5924..0000000000000
--- a/plugins/system/webauthn/webauthn.php
+++ /dev/null
@@ -1,78 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-// Protect from unauthorized access
-defined('_JEXEC') or die();
-
-use Joomla\CMS\Plugin\CMSPlugin;
-use Joomla\Event\DispatcherInterface;
-use Joomla\Plugin\System\Webauthn\Helper\Joomla;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AdditionalLoginButtons;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandler;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerChallenge;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerCreate;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerDelete;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerLogin;
-use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerSaveLabel;
-use Joomla\Plugin\System\Webauthn\PluginTraits\UserDeletion;
-use Joomla\Plugin\System\Webauthn\PluginTraits\UserProfileFields;
-
-/**
- * WebAuthn Passwordless Login plugin
- *
- * The plugin features are broken down into Traits for the sole purpose of making an otherwise supermassive class
- * somewhat manageable. You can find the Traits inside the Webauthn/PluginTraits folder.
- *
- * @since 4.0.0
- */
-class PlgSystemWebauthn extends CMSPlugin
-{
- // AJAX request handlers
- use AjaxHandler;
- use AjaxHandlerCreate;
- use AjaxHandlerSaveLabel;
- use AjaxHandlerDelete;
- use AjaxHandlerChallenge;
- use AjaxHandlerLogin;
-
- // Custom user profile fields
- use UserProfileFields;
-
- // Handle user profile deletion
- use UserDeletion;
-
- // Add WebAuthn buttons
- use AdditionalLoginButtons;
-
- /**
- * Constructor. Loads the language files as well.
- *
- * @param DispatcherInterface $subject The object to observe
- * @param array $config An optional associative array of configuration
- * settings. Recognized key values include 'name',
- * 'group', 'params', 'language (this list is not meant
- * to be comprehensive).
- *
- * @since 4.0.0
- */
- public function __construct(&$subject, array $config = [])
- {
- parent::__construct($subject, $config);
-
- /**
- * Note: Do NOT try to load the language in the constructor. This is called before Joomla initializes the
- * application language. Therefore the temporary Joomla language object and all loaded strings in it will be
- * destroyed on application initialization. As a result we need to call loadLanguage() in each method
- * individually, even though all methods make use of language strings.
- */
-
- // Register a debug log file writer
- Joomla::addLogger('system');
- }
-}
diff --git a/plugins/system/webauthn/webauthn.xml b/plugins/system/webauthn/webauthn.xml
index 36aac9222ffdc..d36d8b89e7581 100644
--- a/plugins/system/webauthn/webauthn.xml
+++ b/plugins/system/webauthn/webauthn.xml
@@ -11,12 +11,31 @@
PLG_SYSTEM_WEBAUTHN_DESCRIPTION
Joomla\Plugin\System\Webauthn
- webauthn.php
forms
+ services
src
language/en-GB/plg_system_webauthn.ini
language/en-GB/plg_system_webauthn.sys.ini
+
+
+
+
+ JDISABLED
+ JENABLED
+
+
+
+
+
diff --git a/plugins/task/demotasks/demotasks.xml b/plugins/task/demotasks/demotasks.xml
index 158fb8042ad07..d8d11512f983a 100644
--- a/plugins/task/demotasks/demotasks.xml
+++ b/plugins/task/demotasks/demotasks.xml
@@ -9,9 +9,10 @@
www.joomla.org
4.1
PLG_TASK_DEMO_TASKS_XML_DESCRIPTION
+
Joomla\Plugin\Task\DemoTasks
- demotasks.php
- language
+ services
+ src
forms
diff --git a/plugins/task/demotasks/services/provider.php b/plugins/task/demotasks/services/provider.php
new file mode 100644
index 0000000000000..f7a6f92cc6c3f
--- /dev/null
+++ b/plugins/task/demotasks/services/provider.php
@@ -0,0 +1,49 @@
+
+ * @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\Task\DemoTasks\Extension\DemoTasks;
+
+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)
+ {
+ $dispatcher = $container->get(DispatcherInterface::class);
+
+ $plugin = new DemoTasks(
+ $dispatcher,
+ (array) PluginHelper::getPlugin('task', 'demotasks')
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/src/Extension/DemoTasks.php
similarity index 97%
rename from plugins/task/demotasks/demotasks.php
rename to plugins/task/demotasks/src/Extension/DemoTasks.php
index 3275578d07d3c..5165c860e103a 100644
--- a/plugins/task/demotasks/demotasks.php
+++ b/plugins/task/demotasks/src/Extension/DemoTasks.php
@@ -7,6 +7,8 @@
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
+namespace Joomla\Plugin\Task\DemoTasks\Extension;
+
// Restrict direct access
defined('_JEXEC') or die;
@@ -23,7 +25,7 @@
*
* @since 4.1.0
*/
-class PlgTaskDemotasks extends CMSPlugin implements SubscriberInterface
+final class DemoTasks extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
diff --git a/plugins/task/requests/services/provider.php b/plugins/task/requests/services/provider.php
index b7320429f62bc..cb864458ded72 100644
--- a/plugins/task/requests/services/provider.php
+++ b/plugins/task/requests/services/provider.php
@@ -27,7 +27,7 @@
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function register(Container $container)
{
diff --git a/plugins/task/requests/src/Extension/Requests.php b/plugins/task/requests/src/Extension/Requests.php
index 96cac4e98a7a4..f44da578ad0c8 100644
--- a/plugins/task/requests/src/Extension/Requests.php
+++ b/plugins/task/requests/src/Extension/Requests.php
@@ -71,7 +71,7 @@ public static function getSubscribedEvents(): array
* The http factory
*
* @var HttpFactory
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private $httpFactory;
@@ -79,7 +79,7 @@ public static function getSubscribedEvents(): array
* The root directory
*
* @var string
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
private $rootDirectory;
@@ -91,7 +91,7 @@ public static function getSubscribedEvents(): array
* @param HttpFactory $httpFactory The http factory
* @param string $rootDirectory The root directory to store the output file in
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function __construct(DispatcherInterface $dispatcher, array $config, HttpFactory $httpFactory, string $rootDirectory)
{
diff --git a/plugins/task/sitestatus/services/provider.php b/plugins/task/sitestatus/services/provider.php
new file mode 100644
index 0000000000000..686a40b5d6f98
--- /dev/null
+++ b/plugins/task/sitestatus/services/provider.php
@@ -0,0 +1,50 @@
+
+ * @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\Task\SiteStatus\Extension\SiteStatus;
+use Joomla\Utilities\ArrayHelper;
+
+return new class implements ServiceProviderInterface
+{
+ /**
+ * Registers the service provider with a DI container.
+ *
+ * @param Container $container The DI container.
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function register(Container $container)
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container)
+ {
+ $plugin = new SiteStatus(
+ $container->get(DispatcherInterface::class),
+ (array) PluginHelper::getPlugin('task', 'sitestatus'),
+ ArrayHelper::fromObject(new JConfig),
+ JPATH_CONFIGURATION . '/configuration.php'
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/plugins/task/sitestatus/sitestatus.xml b/plugins/task/sitestatus/sitestatus.xml
index ba54f3a508c85..ccae445cbb324 100644
--- a/plugins/task/sitestatus/sitestatus.xml
+++ b/plugins/task/sitestatus/sitestatus.xml
@@ -9,10 +9,10 @@
www.joomla.org
4.1
PLG_TASK_SITE_STATUS_XML_DESCRIPTION
+ Joomla\Plugin\Task\SiteStatus
- sitestatus.php
- language
- forms
+ services
+ src
language/en-GB/plg_task_sitestatus.ini
diff --git a/plugins/task/sitestatus/sitestatus.php b/plugins/task/sitestatus/src/Extension/SiteStatus.php
similarity index 62%
rename from plugins/task/sitestatus/sitestatus.php
rename to plugins/task/sitestatus/src/Extension/SiteStatus.php
index ecb7cbb9817a6..88c429ae76047 100644
--- a/plugins/task/sitestatus/sitestatus.php
+++ b/plugins/task/sitestatus/src/Extension/SiteStatus.php
@@ -7,20 +7,21 @@
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
+namespace Joomla\Plugin\Task\SiteStatus\Extension;
+
// Restrict direct access
defined('_JEXEC') or die;
-use Joomla\CMS\Application\CMSApplication;
-use Joomla\CMS\Filesystem\File;
-use Joomla\CMS\Filesystem\Path;
-use Joomla\CMS\Language\Text;
+use Exception;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
+use Joomla\Event\DispatcherInterface;
use Joomla\Event\SubscriberInterface;
+use Joomla\Filesystem\File;
+use Joomla\Filesystem\Path;
use Joomla\Registry\Registry;
-use Joomla\Utilities\ArrayHelper;
/**
* Task plugin with routines to change the offline status of the site. These routines can be used to control planned
@@ -28,7 +29,7 @@
*
* @since 4.1.0
*/
-class PlgTaskSitestatus extends CMSPlugin implements SubscriberInterface
+final class SiteStatus extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
@@ -54,14 +55,6 @@ class PlgTaskSitestatus extends CMSPlugin implements SubscriberInterface
];
- /**
- * The application object.
- *
- * @var CMSApplication
- * @since 4.1.0
- */
- protected $app;
-
/**
* Autoload the language file.
*
@@ -85,6 +78,40 @@ public static function getSubscribedEvents(): array
];
}
+ /**
+ * The old config
+ *
+ * @var array
+ * @since 4.2.0
+ */
+ private $oldConfig;
+
+ /**
+ * The config file
+ *
+ * @var string
+ * @since 4.2.0
+ */
+ private $configFile;
+
+ /**
+ * Constructor.
+ *
+ * @param DispatcherInterface $dispatcher The dispatcher
+ * @param array $config An optional associative array of configuration settings
+ * @param array $oldConfig The old config
+ * @param string $configFile The config
+ *
+ * @since 4.2.0
+ */
+ public function __construct(DispatcherInterface $dispatcher, array $config, array $oldConfig, string $configFile)
+ {
+ parent::__construct($dispatcher, $config);
+
+ $this->oldConfig = $oldConfig;
+ $this->configFile = $configFile;
+ }
+
/**
* @param ExecuteTaskEvent $event The onExecuteTask event
*
@@ -102,7 +129,7 @@ public function alterSiteStatus(ExecuteTaskEvent $event): void
$this->startRoutine($event);
- $config = ArrayHelper::fromObject(new JConfig);
+ $config = $this->oldConfig;
$toggle = self::TASKS_MAP[$event->getRoutineId()]['toggle'];
$oldStatus = $config['offline'] ? 'offline' : 'online';
@@ -113,13 +140,12 @@ public function alterSiteStatus(ExecuteTaskEvent $event): void
}
else
{
- $offline = self::TASKS_MAP[$event->getRoutineId()]['offline'];
- $config['offline'] = $offline;
+ $config['offline'] = self::TASKS_MAP[$event->getRoutineId()]['offline'];
}
$newStatus = $config['offline'] ? 'offline' : 'online';
$exit = $this->writeConfigFile(new Registry($config));
- $this->logTask(Text::sprintf('PLG_TASK_SITE_STATUS_TASK_LOG_SITE_STATUS', $oldStatus, $newStatus));
+ $this->logTask($this->translate('PLG_TASK_SITE_STATUS_TASK_LOG_SITE_STATUS', $oldStatus, $newStatus));
$this->endRoutine($event, $exit);
}
@@ -137,20 +163,23 @@ public function alterSiteStatus(ExecuteTaskEvent $event): void
private function writeConfigFile(Registry $config): int
{
// Set the configuration file path.
- $file = JPATH_CONFIGURATION . '/configuration.php';
+ $file = $this->configFile;
// Attempt to make the file writeable.
- if (Path::isOwner($file) && !Path::setPermissions($file))
+ if (file_exists($file) && Path::isOwner($file) && !Path::setPermissions($file))
{
- $this->logTask(Text::_('PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTWRITABLE'), 'notice');
+ $this->logTask($this->translate('PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTWRITABLE'), 'notice');
}
- // Attempt to write the configuration file as a PHP class named JConfig.
- $configuration = $config->toString('PHP', array('class' => 'JConfig', 'closingtag' => false));
-
- if (!File::write($file, $configuration))
+ try
+ {
+ // Attempt to write the configuration file as a PHP class named JConfig.
+ $configuration = $config->toString('PHP', array('class' => 'JConfig', 'closingtag' => false));
+ File::write($file, $configuration);
+ }
+ catch (Exception $e)
{
- $this->logTask(Text::_('PLG_TASK_SITE_STATUS_ERROR_WRITE_FAILED'), 'error');
+ $this->logTask($this->translate('PLG_TASK_SITE_STATUS_ERROR_WRITE_FAILED'), 'error');
return Status::KNOCKOUT;
}
@@ -164,7 +193,7 @@ private function writeConfigFile(Registry $config): int
// Attempt to make the file un-writeable.
if (Path::isOwner($file) && !Path::setPermissions($file, '0444'))
{
- $this->logTask(Text::_('PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTUNWRITABLE'), 'notice');
+ $this->logTask($this->translate('PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTUNWRITABLE'), 'notice');
}
return Status::OK;
diff --git a/tests/Unit/Libraries/Cms/Cache/CacheControllerFactoryAwareTraitTest.php b/tests/Unit/Libraries/Cms/Cache/CacheControllerFactoryAwareTraitTest.php
index b4242233371c8..22b09b3c869a6 100644
--- a/tests/Unit/Libraries/Cms/Cache/CacheControllerFactoryAwareTraitTest.php
+++ b/tests/Unit/Libraries/Cms/Cache/CacheControllerFactoryAwareTraitTest.php
@@ -21,7 +21,7 @@
*
* @testdoc The CacheControllerFactoryAwareTrait
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
class CacheControllerFactoryAwareTraitTest extends UnitTestCase
{
@@ -30,7 +30,7 @@ class CacheControllerFactoryAwareTraitTest extends UnitTestCase
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testGetCacheControllerFactory()
{
diff --git a/tests/Unit/Libraries/Cms/Feed/FeedFactoryTest.php b/tests/Unit/Libraries/Cms/Feed/FeedFactoryTest.php
index 65dfb5bc051a8..216653e5c7102 100644
--- a/tests/Unit/Libraries/Cms/Feed/FeedFactoryTest.php
+++ b/tests/Unit/Libraries/Cms/Feed/FeedFactoryTest.php
@@ -142,7 +142,6 @@ public function testRegisterParserWithInvalidTag()
public function testFetchFeedParser()
{
$tagName = 'parser-mock';
- $xmlReaderMock = $this->createMock(XMLReader::class);
$parseMock = $this->createMock(FeedParser::class);
$this->feedFactory->registerParser($tagName, get_class($parseMock));
@@ -150,7 +149,7 @@ public function testFetchFeedParser()
$reflectionClass = new ReflectionClass($this->feedFactory);
$method = $reflectionClass->getMethod('_fetchFeedParser');
$method->setAccessible(true);
- $parser = $method->invoke($this->feedFactory, $tagName, $xmlReaderMock);
+ $parser = $method->invoke($this->feedFactory, $tagName, new \XMLReader);
$this->assertInstanceOf(FeedParser::class, $parser);
$this->assertSame(get_class($parseMock), get_class($parser));
@@ -166,12 +165,11 @@ public function testFetchFeedParser()
public function testFetchFeedParserWithInvalidTag()
{
$this->expectException(\LogicException::class);
- $xmlReaderMock = $this->createMock(XMLReader::class);
// Use reflection to test private method
$reflectionClass = new ReflectionClass($this->feedFactory);
$method = $reflectionClass->getMethod('_fetchFeedParser');
$method->setAccessible(true);
- $method->invoke($this->feedFactory, 'not-existing', $xmlReaderMock);
+ $method->invoke($this->feedFactory, 'not-existing', new \XMLReader);
}
}
diff --git a/tests/Unit/Libraries/Cms/Feed/FeedParserTest.php b/tests/Unit/Libraries/Cms/Feed/FeedParserTest.php
index 2caa40346b0c5..243255585f60e 100644
--- a/tests/Unit/Libraries/Cms/Feed/FeedParserTest.php
+++ b/tests/Unit/Libraries/Cms/Feed/FeedParserTest.php
@@ -203,9 +203,8 @@ public function testRegisterNamespace()
{
$prefix = 'my-namespace';
$namespaceMock = $this->createMock(NamespaceParserInterface::class);
- $readerMock = $this->createMock(XMLReader::class);
- $parser = new FeedParserStub($readerMock);
+ $parser = new FeedParserStub(new \XMLReader);
$returnedParser = $parser->registerNamespace($prefix, $namespaceMock);
$this->assertInstanceOf(FeedParserStub::class, $returnedParser);
diff --git a/tests/Unit/Libraries/Cms/Feed/Parser/AtomParserTest.php b/tests/Unit/Libraries/Cms/Feed/Parser/AtomParserTest.php
index b82199943d9c0..0d3deaedb6cb0 100644
--- a/tests/Unit/Libraries/Cms/Feed/Parser/AtomParserTest.php
+++ b/tests/Unit/Libraries/Cms/Feed/Parser/AtomParserTest.php
@@ -57,7 +57,7 @@ public function testHandleAuthor()
->with($author['name'], $author['email'], $author['uri']);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleAuthor');
$method->setAccessible(true);
@@ -94,7 +94,7 @@ public function testHandleContributor()
->with($contributor['name'], $contributor['email'], $contributor['uri']);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleContributor');
$method->setAccessible(true);
@@ -124,7 +124,7 @@ public function testHandleGenerator()
->with('generator', $generator);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleGenerator');
$method->setAccessible(true);
@@ -154,7 +154,7 @@ public function testHandleId()
->with('uri', $id);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleId');
$method->setAccessible(true);
@@ -191,7 +191,7 @@ function ($param) use ($href)
);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleLink');
$method->setAccessible(true);
@@ -221,7 +221,7 @@ public function testHandleRights()
->with('copyright', $copyright);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleRights');
$method->setAccessible(true);
@@ -251,7 +251,7 @@ public function testHandleSubtitle()
->with('description', $subtitle);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleSubtitle');
$method->setAccessible(true);
@@ -281,7 +281,7 @@ public function testHandleTitle()
->with('title', $title);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleTitle');
$method->setAccessible(true);
@@ -311,7 +311,7 @@ public function testHandleUpdated()
->with('updatedDate', $date);
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('handleUpdated');
$method->setAccessible(true);
@@ -319,7 +319,30 @@ public function testHandleUpdated()
}
/**
- * Tests AtomParser::initialise()
+ * Tests AtomParser::parse()
+ *
+ * @return void
+ * @since 3.1.4
+ * @throws \ReflectionException
+ */
+ public function testInitialiseSetsDefaultVersionWithXmlDocType()
+ {
+ $dummyXml = '
+ ';
+ $reader = \XMLReader::XML($dummyXml);
+ $atomParser = new AtomParser($reader);
+ $atomParser->parse();
+
+ // Use reflection to check the value
+ $reflectionClass = new ReflectionClass($atomParser);
+ $attribute = $reflectionClass->getProperty('version');
+ $attribute->setAccessible(true);
+
+ $this->assertEquals('1.0', $attribute->getValue($atomParser));
+ }
+
+ /**
+ * Tests AtomParser::parse()
*
* @return void
* @since 3.1.4
@@ -327,19 +350,13 @@ public function testHandleUpdated()
*/
public function testInitialiseSetsDefaultVersion()
{
- $readerMock = $this->createMock(XMLReader::class);
- $readerMock
- ->expects($this->once())
- ->method('getAttribute')
- ->with('version')
- ->willReturn('Some Version');
+ $dummyXml = ' ';
+ $reader = \XMLReader::XML($dummyXml);
+ $atomParser = new AtomParser($reader);
+ $atomParser->parse();
- // Use reflection to test protected method
- $atomParser = new AtomParser($readerMock);
+ // Use reflection to check the value
$reflectionClass = new ReflectionClass($atomParser);
- $method = $reflectionClass->getMethod('initialise');
- $method->setAccessible(true);
- $method->invoke($atomParser);
$attribute = $reflectionClass->getProperty('version');
$attribute->setAccessible(true);
@@ -347,7 +364,7 @@ public function testInitialiseSetsDefaultVersion()
}
/**
- * Tests AtomParser::initialise()
+ * Tests AtomParser::parse()
*
* @return void
* @since 3.1.4
@@ -355,19 +372,13 @@ public function testInitialiseSetsDefaultVersion()
*/
public function testInitialiseSetsOldVersion()
{
- $readerMock = $this->createMock(XMLReader::class);
- $readerMock
- ->expects($this->once())
- ->method('getAttribute')
- ->with('version')
- ->willReturn('0.3');
+ $dummyXml = ' ';
+ $reader = \XMLReader::XML($dummyXml);
+ $atomParser = new AtomParser($reader);
+ $atomParser->parse();
- // Use reflection to test protected method
- $atomParser = new AtomParser($readerMock);
+ // Use reflection to check the value
$reflectionClass = new ReflectionClass($atomParser);
- $method = $reflectionClass->getMethod('initialise');
- $method->setAccessible(true);
- $method->invoke($atomParser);
$attribute = $reflectionClass->getProperty('version');
$attribute->setAccessible(true);
@@ -415,7 +426,7 @@ public function testProcessFeedEntry()
->will($this->returnValueMap($map));
// Use reflection to test protected method
- $atomParser = new AtomParser($this->createMock(XMLReader::class));
+ $atomParser = new AtomParser(new \XMLReader);
$reflectionClass = new ReflectionClass($atomParser);
$method = $reflectionClass->getMethod('processFeedEntry');
$method->setAccessible(true);
diff --git a/tests/Unit/Libraries/Cms/Feed/Parser/RssParserTest.php b/tests/Unit/Libraries/Cms/Feed/Parser/RssParserTest.php
index 7aed2bf0da2e6..aa343cb714337 100644
--- a/tests/Unit/Libraries/Cms/Feed/Parser/RssParserTest.php
+++ b/tests/Unit/Libraries/Cms/Feed/Parser/RssParserTest.php
@@ -52,7 +52,7 @@ public function testHandleCategory()
->with($category, '');
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleCategory');
$method->setAccessible(true);
@@ -103,7 +103,7 @@ function ($value) use ($cloud)
);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleCloud');
$method->setAccessible(true);
@@ -133,7 +133,7 @@ public function testHandleCopyright()
->with('copyright', $copyright);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleCopyright');
$method->setAccessible(true);
@@ -163,7 +163,7 @@ public function testHandleDescription()
->with('description', $subtitle);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleDescription');
$method->setAccessible(true);
@@ -193,7 +193,7 @@ public function testHandleGenerator()
->with('generator', $generator);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleGenerator');
$method->setAccessible(true);
@@ -246,7 +246,7 @@ function ($value) use ($image)
);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleImage');
$method->setAccessible(true);
@@ -276,7 +276,7 @@ public function testHandleLanguage()
->with('language', $language);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleLanguage');
$method->setAccessible(true);
@@ -306,7 +306,7 @@ public function testHandleLastBuildDate()
->with('updatedDate', $buildDate);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleLastBuildDate');
$method->setAccessible(true);
@@ -343,7 +343,7 @@ function ($value) use ($link)
);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleLink');
$method->setAccessible(true);
@@ -385,7 +385,7 @@ function ($value) use ($editor)
);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleManagingEditor');
$method->setAccessible(true);
@@ -415,7 +415,7 @@ public function testHandlePubDate()
->with('publishedDate', $pubDate);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handlePubDate');
$method->setAccessible(true);
@@ -445,7 +445,7 @@ public function testHandleSkipDays()
->with('skipDays', $skipDays);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleSkipDays');
$method->setAccessible(true);
@@ -475,7 +475,7 @@ public function testHandleSkipHours()
->with('skipHours', $skipHours);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleSkipHours');
$method->setAccessible(true);
@@ -505,7 +505,7 @@ public function testHandleTitle()
->with('title', $title);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleTitle');
$method->setAccessible(true);
@@ -535,7 +535,7 @@ public function testHandleTtl()
->with('ttl', (int) $ttl);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleTtl');
$method->setAccessible(true);
@@ -568,7 +568,7 @@ public function testHandleWebmaster()
->with($webmaster['name'], $webmaster['email'], null, 'webmaster');
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('handleWebmaster');
$method->setAccessible(true);
@@ -576,39 +576,30 @@ public function testHandleWebmaster()
}
/**
- * Tests RssParser::initialise()
+ * Tests RssParser::parse()
*
* @return void
* @since 3.1.4
* @throws \ReflectionException
*/
- public function testInitialiseSetsVersion()
+ public function testParseSetsVersion()
{
- $version = '2.0';
-
- $readerMock = $this->createMock(XMLReader::class);
-
- $readerMock
- ->expects($this->once())
- ->method('getAttribute')
- ->with('version')
- ->willReturn($version);
-
- $readerMock
- ->expects($this->any())
- ->method('read')
- ->willReturn(false);
-
- // Use reflection to test protected method
- $rssParser = new RssParser($readerMock);
+ $dummyXml = '
+
+
+ Test Channel
+
+ ';
+ $reader = \XMLReader::XML($dummyXml);
+ $rssParser = new RssParser($reader);
+ $rssParser->parse();
+
+ // Use reflection to check the value
$reflectionClass = new ReflectionClass($rssParser);
- $method = $reflectionClass->getMethod('initialise');
- $method->setAccessible(true);
- $method->invoke($rssParser);
$attribute = $reflectionClass->getProperty('version');
$attribute->setAccessible(true);
- $this->assertEquals($version, $attribute->getValue($rssParser));
+ $this->assertEquals('2.0', $attribute->getValue($rssParser));
}
/**
@@ -695,7 +686,7 @@ function ($value) use ($entry)
);
// Use reflection to test protected method
- $rssParser = new RssParser($this->createMock(XMLReader::class));
+ $rssParser = new RssParser(new \XMLReader);
$reflectionClass = new ReflectionClass($rssParser);
$method = $reflectionClass->getMethod('processFeedEntry');
$method->setAccessible(true);
diff --git a/tests/Unit/Libraries/Cms/Plugin/CMSPluginTest.php b/tests/Unit/Libraries/Cms/Plugin/CMSPluginTest.php
index 29bc6f3cfb76e..8ba3b53ac8511 100644
--- a/tests/Unit/Libraries/Cms/Plugin/CMSPluginTest.php
+++ b/tests/Unit/Libraries/Cms/Plugin/CMSPluginTest.php
@@ -28,7 +28,7 @@
*
* @testdox The CMSPlugin
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
class CMSPluginTest extends UnitTestCase
{
@@ -37,7 +37,7 @@ class CMSPluginTest extends UnitTestCase
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInjectedDispatcher()
{
@@ -54,7 +54,7 @@ public function testInjectedDispatcher()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInjectedApplication()
{
@@ -78,7 +78,7 @@ public function getApplication(): CMSApplicationInterface
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testEmptyParams()
{
@@ -95,7 +95,7 @@ public function testEmptyParams()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInjectedRegistryParams()
{
@@ -113,7 +113,7 @@ public function testInjectedRegistryParams()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInjectedArrayParams()
{
@@ -130,7 +130,7 @@ public function testInjectedArrayParams()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInjectedName()
{
@@ -152,7 +152,7 @@ public function getName()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInjectedType()
{
@@ -174,7 +174,7 @@ public function getType()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testLoadLanguage()
{
@@ -196,7 +196,7 @@ public function testLoadLanguage()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testLoadLanguageWithExtensionAndPath()
{
@@ -218,7 +218,7 @@ public function testLoadLanguageWithExtensionAndPath()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testNotLoadLanguageWhenExists()
{
@@ -241,7 +241,7 @@ public function testNotLoadLanguageWhenExists()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testTranslateWithoutArguments()
{
@@ -269,7 +269,7 @@ public function test(): string
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testTranslateWithArguments()
{
@@ -297,7 +297,7 @@ public function test(): string
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersAsSubscriber()
{
@@ -323,7 +323,7 @@ public function unit()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersAsLegacy()
{
@@ -344,7 +344,7 @@ public function onTest()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersForEventInterface()
{
@@ -365,7 +365,7 @@ public function onTest(EventInterface $event)
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersWithForcedEventInterface()
{
@@ -388,7 +388,7 @@ public function onTest(EventInterface $event)
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersForNoEventInterface()
{
@@ -409,7 +409,7 @@ public function onTest(string $context)
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersNotTyped()
{
@@ -430,7 +430,7 @@ public function onTest($event)
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRegisterListenersNullable()
{
@@ -451,7 +451,7 @@ public function onTest(stdClass $event = null)
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testDispatchLegacyListener()
{
@@ -480,7 +480,7 @@ public function onTest()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testDispatchLegacyListenerWhenNullIsReturned()
{
@@ -507,7 +507,7 @@ public function onTest()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testDispatchLegacyListenerWhenEventHasResult()
{
diff --git a/tests/Unit/Libraries/Cms/Session/SessionManagerTest.php b/tests/Unit/Libraries/Cms/Session/SessionManagerTest.php
index 54d0d38034191..c86e45b097eb4 100644
--- a/tests/Unit/Libraries/Cms/Session/SessionManagerTest.php
+++ b/tests/Unit/Libraries/Cms/Session/SessionManagerTest.php
@@ -46,6 +46,16 @@ class SessionManagerTest extends UnitTestCase
*/
protected function setUp(): void
{
+ // @todo remove this after upgrading phpunit to 9+ see https://github.com/sebastianbergmann/phpunit/issues/4879
+ if (version_compare(phpversion(), '8.1.0', '>='))
+ {
+ /**
+ * See https://github.com/sebastianbergmann/phpunit/issues/4879 - we'll need a higher phpunit version for 8.1 and
+ * higher for this to work
+ */
+ $this->markTestSkipped('PHPUnit 8 cannot mock SessionHandlerInterface in PHP 8.1 and higher');
+ }
+
$this->sessionHandler = $this->createMock(\SessionHandlerInterface::class);
$this->manager = new SessionManager($this->sessionHandler);
diff --git a/tests/Unit/Plugin/Task/Requests/Extension/RequestsPluginTest.php b/tests/Unit/Plugin/Task/Requests/Extension/RequestsPluginTest.php
index 53ee9017309de..4a876d0688b2c 100644
--- a/tests/Unit/Plugin/Task/Requests/Extension/RequestsPluginTest.php
+++ b/tests/Unit/Plugin/Task/Requests/Extension/RequestsPluginTest.php
@@ -32,7 +32,7 @@
*
* @testdox The Requests plugin
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
class RequestsPluginTest extends UnitTestCase
{
@@ -41,7 +41,7 @@ class RequestsPluginTest extends UnitTestCase
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function setUp(): void
{
@@ -56,7 +56,7 @@ public function setUp(): void
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function tearDown(): void
{
@@ -71,7 +71,7 @@ public function tearDown(): void
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testRequest()
{
@@ -125,7 +125,7 @@ public static function isSupported()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInvalidRequest()
{
@@ -179,7 +179,7 @@ public static function isSupported()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testAuthRequest()
{
@@ -230,7 +230,7 @@ public static function isSupported()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testExceptionInRequest()
{
@@ -279,7 +279,7 @@ public static function isSupported()
*
* @return void
*
- * @since __DEPLOY_VERSION__
+ * @since 4.2.0
*/
public function testInvalidFileToWrite()
{
diff --git a/tests/Unit/Plugin/Task/SiteStatus/Extension/SiteStatusPluginTest.php b/tests/Unit/Plugin/Task/SiteStatus/Extension/SiteStatusPluginTest.php
new file mode 100644
index 0000000000000..a7dfda3d3f99b
--- /dev/null
+++ b/tests/Unit/Plugin/Task/SiteStatus/Extension/SiteStatusPluginTest.php
@@ -0,0 +1,243 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Plugin\Task\SiteStatus\Extension;
+
+use Joomla\CMS\Application\CMSApplicationInterface;
+use Joomla\CMS\Language\Language;
+use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
+use Joomla\Component\Scheduler\Administrator\Task\Status;
+use Joomla\Component\Scheduler\Administrator\Task\Task;
+use Joomla\Event\Dispatcher;
+use Joomla\Filesystem\Folder;
+use Joomla\Plugin\Task\SiteStatus\Extension\SiteStatus;
+use Joomla\Tests\Unit\UnitTestCase;
+
+/**
+ * Test class for SiteStatus plugin
+ *
+ * @package Joomla.UnitTest
+ * @subpackage SiteStatus
+ *
+ * @testdox The SiteStatus plugin
+ *
+ * @since 4.2.0
+ */
+class SiteStatusPluginTest extends UnitTestCase
+{
+ /**
+ * Setup
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function setUp(): void
+ {
+ if (!is_dir(__DIR__ . '/tmp'))
+ {
+ mkdir(__DIR__ . '/tmp');
+ }
+
+ touch(__DIR__ . '/tmp/config.php');
+ }
+
+ /**
+ * Cleanup
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function tearDown(): void
+ {
+ if (is_dir(__DIR__ . '/tmp'))
+ {
+ Folder::delete(__DIR__ . '/tmp');
+ }
+ }
+
+ /**
+ * @testdox can set the config from online to offline
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testSetOnlineWhenOffline()
+ {
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($this->createStub(Language::class));
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => true], __DIR__ . '/tmp/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline_set_online']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+ $this->assertStringContainsString('$offline = false;', file_get_contents(__DIR__ . '/tmp/config.php'));
+ }
+
+ /**
+ * @testdox can keep the config online
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testSetOnlineWhenOnline()
+ {
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($this->createStub(Language::class));
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => false], __DIR__ . '/tmp/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline_set_online']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+ $this->assertStringContainsString('$offline = false;', file_get_contents(__DIR__ . '/tmp/config.php'));
+ }
+
+ /**
+ * @testdox can set the config from offline to online
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testSetOfflineWhenOnline()
+ {
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($this->createStub(Language::class));
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => false], __DIR__ . '/tmp/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline_set_offline']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+ $this->assertStringContainsString('$offline = true;', file_get_contents(__DIR__ . '/tmp/config.php'));
+ }
+
+ /**
+ * @testdox can keep the config offline
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testSetOfflineWhenOffline()
+ {
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($this->createStub(Language::class));
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => true], __DIR__ . '/tmp/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline_set_offline']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+ $this->assertStringContainsString('$offline = true;', file_get_contents(__DIR__ . '/tmp/config.php'));
+ }
+
+ /**
+ * @testdox can toggle the config from online to offline
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testToggleOffline()
+ {
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($this->createStub(Language::class));
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => false], __DIR__ . '/tmp/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+ $this->assertStringContainsString('$offline = true;', file_get_contents(__DIR__ . '/tmp/config.php'));
+ }
+
+ /**
+ * @testdox can toggle the config from offline to online
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testToggleOnline()
+ {
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($this->createStub(Language::class));
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => true], __DIR__ . '/tmp/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+ $this->assertStringContainsString('$offline = false;', file_get_contents(__DIR__ . '/tmp/config.php'));
+ }
+
+ /**
+ * @testdox can't set the config file'
+ *
+ * @return void
+ *
+ * @since 4.2.0
+ */
+ public function testInvalidConfigFile()
+ {
+ $language = $this->createStub(Language::class);
+ $language->method('_')->willReturn('test');
+
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($language);
+
+ $plugin = new SiteStatus(new Dispatcher, [], ['offline' => true], '/invalid/config.php');
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'plg_task_toggle_offline']]);
+
+ $event = new ExecuteTaskEvent('test', ['subject' => $task]);
+ $plugin->alterSiteStatus($event);
+
+ $this->assertEquals(Status::KNOCKOUT, $event->getResultSnapshot()['status']);
+ $this->assertFileNotExists('/invalid/config.php');
+ }
+}