+ getAaguid() : '';
+ $authMetadata = $knownAuthenticators[$aaguid->toString()] ?? $knownAuthenticators[''];
+ ?>
+
+
+
+
-
+
-
+
@@ -141,8 +145,7 @@
+ class="btn btn-success w-100">
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/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/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/checkfiles/services/provider.php b/plugins/task/checkfiles/services/provider.php
index dc71dd71a9944..2a518e2f0571e 100644
--- a/plugins/task/checkfiles/services/provider.php
+++ b/plugins/task/checkfiles/services/provider.php
@@ -1,4 +1,5 @@
set(
- PluginInterface::class,
- function (Container $container)
- {
- $plugin = new Checkfiles(
- $container->get(DispatcherInterface::class),
- (array) PluginHelper::getPlugin('task', 'checkfiles'),
- JPATH_ROOT . '/images/'
- );
- $plugin->setApplication(Factory::getApplication());
+ /**
+ * Registers the service provider with a DI container.
+ *
+ * @param Container $container The DI container.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function register(Container $container)
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container) {
+ $plugin = new Checkfiles(
+ $container->get(DispatcherInterface::class),
+ (array) PluginHelper::getPlugin('task', 'checkfiles'),
+ JPATH_ROOT . '/images/'
+ );
+ $plugin->setApplication(Factory::getApplication());
- return $plugin;
- }
- );
- }
+ return $plugin;
+ }
+ );
+ }
};
diff --git a/plugins/task/checkfiles/src/Extension/Checkfiles.php b/plugins/task/checkfiles/src/Extension/Checkfiles.php
index e09f25527fd55..1b7de9ee983a3 100644
--- a/plugins/task/checkfiles/src/Extension/Checkfiles.php
+++ b/plugins/task/checkfiles/src/Extension/Checkfiles.php
@@ -1,4 +1,5 @@
[
- 'langConstPrefix' => 'PLG_TASK_CHECK_FILES_TASK_IMAGE_SIZE',
- 'form' => 'image_size',
- 'method' => 'checkImages',
- ],
- ];
-
- /**
- * @inheritDoc
- *
- * @return string[]
- *
- * @since 4.1.0
- */
- public static function getSubscribedEvents(): array
- {
- return [
- 'onTaskOptionsList' => 'advertiseRoutines',
- 'onExecuteTask' => 'standardRoutineHandler',
- 'onContentPrepareForm' => 'enhanceTaskItemForm',
- ];
- }
-
- /**
- * @var boolean
- * @since 4.1.0
- */
- protected $autoloadLanguage = true;
-
- /**
- * The root directory path
- *
- * @var string
- * @since __DEPLOY_VERSION__
- */
- private $rootDirectory;
-
- /**
- * Constructor.
- *
- * @param DispatcherInterface $dispatcher The dispatcher
- * @param array $config An optional associative array of configuration settings
- * @param string $rootDirectory The root directory to look for images
- *
- * @since __DEPLOY_VERSION__
- */
- public function __construct(DispatcherInterface $dispatcher, array $config, string $rootDirectory)
- {
- parent::__construct($dispatcher, $config);
-
- $this->rootDirectory = $rootDirectory;
- }
-
- /**
- * @param ExecuteTaskEvent $event The onExecuteTask event
- *
- * @return integer The exit code
- *
- * @since 4.1.0
- * @throws RuntimeException
- * @throws LogicException
- */
- protected function checkImages(ExecuteTaskEvent $event): int
- {
- $params = $event->getArgument('params');
- $path = Path::check($this->rootDirectory . $params->path);
- $dimension = $params->dimension;
- $limit = $params->limit;
- $numImages = max(1, (int) $params->numImages ?? 1);
-
- if (!is_dir($path))
- {
- $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_IMAGE_PATH_NA'), 'warning');
-
- return TaskStatus::NO_RUN;
- }
-
- foreach (Folder::files($path, '^.*\.(jpg|jpeg|png|gif|webp)', 2, true) as $imageFilename)
- {
- $properties = Image::getImageFileProperties($imageFilename);
- $resize = $properties->$dimension > $limit;
-
- if (!$resize)
- {
- continue;
- }
-
- $height = $properties->height;
- $width = $properties->width;
-
- $newHeight = $dimension === 'height' ? $limit : $height * $limit / $width;
- $newWidth = $dimension === 'width' ? $limit : $width * $limit / $height;
-
- $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_RESIZING_IMAGE', $width, $height, $newWidth, $newHeight, $imageFilename));
-
- $image = new Image($imageFilename);
-
- try
- {
- $image->resize($newWidth, $newHeight, false);
- }
- catch (LogicException $e)
- {
- $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_RESIZE_FAIL'), 'error');
-
- return TaskStatus::KNOCKOUT;
- }
-
- if (!$image->toFile($imageFilename, $properties->type))
- {
- $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_IMAGE_SAVE_FAIL'), 'error');
-
- return TaskStatus::KNOCKOUT;
- }
-
- --$numImages;
-
- // We do a limited number of resize per execution
- if ($numImages == 0)
- {
- break;
- }
- }
-
- return TaskStatus::OK;
- }
+ use TaskPluginTrait;
+
+ /**
+ * @var string[]
+ *
+ * @since 4.1.0
+ */
+ protected const TASKS_MAP = [
+ 'checkfiles.imagesize' => [
+ 'langConstPrefix' => 'PLG_TASK_CHECK_FILES_TASK_IMAGE_SIZE',
+ 'form' => 'image_size',
+ 'method' => 'checkImages',
+ ],
+ ];
+
+ /**
+ * @inheritDoc
+ *
+ * @return string[]
+ *
+ * @since 4.1.0
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onTaskOptionsList' => 'advertiseRoutines',
+ 'onExecuteTask' => 'standardRoutineHandler',
+ 'onContentPrepareForm' => 'enhanceTaskItemForm',
+ ];
+ }
+
+ /**
+ * @var boolean
+ * @since 4.1.0
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * The root directory path
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $rootDirectory;
+
+ /**
+ * Constructor.
+ *
+ * @param DispatcherInterface $dispatcher The dispatcher
+ * @param array $config An optional associative array of configuration settings
+ * @param string $rootDirectory The root directory to look for images
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DispatcherInterface $dispatcher, array $config, string $rootDirectory)
+ {
+ parent::__construct($dispatcher, $config);
+
+ $this->rootDirectory = $rootDirectory;
+ }
+
+ /**
+ * @param ExecuteTaskEvent $event The onExecuteTask event
+ *
+ * @return integer The exit code
+ *
+ * @since 4.1.0
+ * @throws RuntimeException
+ * @throws LogicException
+ */
+ protected function checkImages(ExecuteTaskEvent $event): int
+ {
+ $params = $event->getArgument('params');
+ $path = Path::check($this->rootDirectory . $params->path);
+ $dimension = $params->dimension;
+ $limit = $params->limit;
+ $numImages = max(1, (int) $params->numImages ?? 1);
+
+ if (!is_dir($path)) {
+ $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_IMAGE_PATH_NA'), 'warning');
+
+ return TaskStatus::NO_RUN;
+ }
+
+ foreach (Folder::files($path, '^.*\.(jpg|jpeg|png|gif|webp)', 2, true) as $imageFilename) {
+ $properties = Image::getImageFileProperties($imageFilename);
+ $resize = $properties->$dimension > $limit;
+
+ if (!$resize) {
+ continue;
+ }
+
+ $height = $properties->height;
+ $width = $properties->width;
+
+ $newHeight = $dimension === 'height' ? $limit : $height * $limit / $width;
+ $newWidth = $dimension === 'width' ? $limit : $width * $limit / $height;
+
+ $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_RESIZING_IMAGE', $width, $height, $newWidth, $newHeight, $imageFilename));
+
+ $image = new Image($imageFilename);
+
+ try {
+ $image->resize($newWidth, $newHeight, false);
+ } catch (LogicException $e) {
+ $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_RESIZE_FAIL'), 'error');
+
+ return TaskStatus::KNOCKOUT;
+ }
+
+ if (!$image->toFile($imageFilename, $properties->type)) {
+ $this->logTask($this->translate('PLG_TASK_CHECK_FILES_LOG_IMAGE_SAVE_FAIL'), 'error');
+
+ return TaskStatus::KNOCKOUT;
+ }
+
+ --$numImages;
+
+ // We do a limited number of resize per execution
+ if ($numImages == 0) {
+ break;
+ }
+ }
+
+ return TaskStatus::OK;
+ }
}
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/tests/Unit/Plugin/Task/Checkfiles/Extension/CheckfilesPluginTest.php b/tests/Unit/Plugin/Task/Checkfiles/Extension/CheckfilesPluginTest.php
index 6bbf37a2e10f0..cd406a7881d2f 100644
--- a/tests/Unit/Plugin/Task/Checkfiles/Extension/CheckfilesPluginTest.php
+++ b/tests/Unit/Plugin/Task/Checkfiles/Extension/CheckfilesPluginTest.php
@@ -1,4 +1,5 @@
createStub(Language::class);
- $language->method('_')->willReturn('test');
-
- $app = $this->createStub(CMSApplicationInterface::class);
- $app->method('getLanguage')->willReturn($language);
-
- $plugin = new Checkfiles(new Dispatcher, [], __DIR__);
- $plugin->setApplication($app);
-
- $task = $this->createStub(Task::class);
- $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
-
- $event = new ExecuteTaskEvent(
- 'test',
- [
- 'subject' => $task,
- 'params' => (object)['path' => '/tmp', 'dimension' => 'width', 'limit' => 20, 'numImages' => 1]
- ]
- );
- $plugin->standardRoutineHandler($event);
-
- $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
-
- list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
- $this->assertEquals(20, $width);
- $this->assertEquals(20, $height);
- }
-
- /**
- * @testdox can resize a subset of images
- *
- * @return void
- *
- * @since __DEPLOY_VERSION__
- */
- public function testResizeWithLimit()
- {
- copy(__DIR__ . '/tmp/test.png',__DIR__ . '/tmp/test1.png');
-
- $language = $this->createStub(Language::class);
- $language->method('_')->willReturn('test');
-
- $app = $this->createStub(CMSApplicationInterface::class);
- $app->method('getLanguage')->willReturn($language);
-
- $plugin = new Checkfiles(new Dispatcher, [], __DIR__);
- $plugin->setApplication($app);
-
- $task = $this->createStub(Task::class);
- $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
-
- $event = new ExecuteTaskEvent(
- 'test',
- [
- 'subject' => $task,
- 'params' => (object)['path' => '/tmp', 'dimension' => 'width', 'limit' => 20, 'numImages' => 1]
- ]
- );
- $plugin->standardRoutineHandler($event);
-
- $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
-
- list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
- $this->assertEquals(20, $width);
- $this->assertEquals(20, $height);
-
- list($width, $height) = getimagesize(__DIR__ . '/tmp/test1.png');
- $this->assertEquals(200, $width);
- $this->assertEquals(200, $height);
- }
-
- /**
- * @testdox can resize an image
- *
- * @return void
- *
- * @since __DEPLOY_VERSION__
- */
- public function testIgnoreResize()
- {
- $language = $this->createStub(Language::class);
- $language->method('_')->willReturn('test');
-
- $app = $this->createStub(CMSApplicationInterface::class);
- $app->method('getLanguage')->willReturn($language);
-
- $plugin = new Checkfiles(new Dispatcher, [], __DIR__);
- $plugin->setApplication($app);
-
- $task = $this->createStub(Task::class);
- $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
-
- $event = new ExecuteTaskEvent(
- 'test',
- [
- 'subject' => $task,
- 'params' => (object)['path' => '/tmp', 'dimension' => 'width', 'limit' => 2000, 'numImages' => 1]
- ]
- );
- $plugin->standardRoutineHandler($event);
-
- $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
-
- list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
- $this->assertEquals(200, $width);
- $this->assertEquals(200, $height);
- }
-
- /**
- * @testdox can not run when invalid folder
- *
- * @return void
- *
- * @since __DEPLOY_VERSION__
- */
- public function testInvalidFolder()
- {
- $language = $this->createStub(Language::class);
- $language->method('_')->willReturn('test');
-
- $app = $this->createStub(CMSApplicationInterface::class);
- $app->method('getLanguage')->willReturn($language);
-
- $plugin = new Checkfiles(new Dispatcher, [], __DIR__);
- $plugin->setApplication($app);
-
- $task = $this->createStub(Task::class);
- $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
-
- $event = new ExecuteTaskEvent(
- 'test',
- [
- 'subject' => $task,
- 'params' => (object)['path' => '/invalid', 'dimension' => 'width', 'limit' => 20, 'numImages' => 1]
- ]
- );
- $plugin->standardRoutineHandler($event);
-
- list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
- $this->assertEquals(Status::NO_RUN, $event->getResultSnapshot()['status']);
- $this->assertEquals(200, $width);
- $this->assertEquals(200, $height);
- }
+ /**
+ * Setup
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function setUp(): void
+ {
+ if (!is_dir(__DIR__ . '/tmp')) {
+ mkdir(__DIR__ . '/tmp');
+ }
+
+ $image = imagecreate(200, 200);
+ imagecolorallocate($image, 255, 255, 0);
+ imagepng($image, __DIR__ . '/tmp/test.png');
+ imagedestroy($image);
+ }
+
+ /**
+ * Cleanup
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function tearDown(): void
+ {
+ if (is_dir(__DIR__ . '/tmp')) {
+ Folder::delete(__DIR__ . '/tmp');
+ }
+ }
+
+ /**
+ * @testdox can resize an image
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function testResize()
+ {
+ $language = $this->createStub(Language::class);
+ $language->method('_')->willReturn('test');
+
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($language);
+
+ $plugin = new Checkfiles(new Dispatcher(), [], __DIR__);
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
+
+ $event = new ExecuteTaskEvent(
+ 'test',
+ [
+ 'subject' => $task,
+ 'params' => (object)['path' => '/tmp', 'dimension' => 'width', 'limit' => 20, 'numImages' => 1]
+ ]
+ );
+ $plugin->standardRoutineHandler($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+
+ list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
+ $this->assertEquals(20, $width);
+ $this->assertEquals(20, $height);
+ }
+
+ /**
+ * @testdox can resize a subset of images
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function testResizeWithLimit()
+ {
+ copy(__DIR__ . '/tmp/test.png', __DIR__ . '/tmp/test1.png');
+
+ $language = $this->createStub(Language::class);
+ $language->method('_')->willReturn('test');
+
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($language);
+
+ $plugin = new Checkfiles(new Dispatcher(), [], __DIR__);
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
+
+ $event = new ExecuteTaskEvent(
+ 'test',
+ [
+ 'subject' => $task,
+ 'params' => (object)['path' => '/tmp', 'dimension' => 'width', 'limit' => 20, 'numImages' => 1]
+ ]
+ );
+ $plugin->standardRoutineHandler($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+
+ list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
+ $this->assertEquals(20, $width);
+ $this->assertEquals(20, $height);
+
+ list($width, $height) = getimagesize(__DIR__ . '/tmp/test1.png');
+ $this->assertEquals(200, $width);
+ $this->assertEquals(200, $height);
+ }
+
+ /**
+ * @testdox can resize an image
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function testIgnoreResize()
+ {
+ $language = $this->createStub(Language::class);
+ $language->method('_')->willReturn('test');
+
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($language);
+
+ $plugin = new Checkfiles(new Dispatcher(), [], __DIR__);
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
+
+ $event = new ExecuteTaskEvent(
+ 'test',
+ [
+ 'subject' => $task,
+ 'params' => (object)['path' => '/tmp', 'dimension' => 'width', 'limit' => 2000, 'numImages' => 1]
+ ]
+ );
+ $plugin->standardRoutineHandler($event);
+
+ $this->assertEquals(Status::OK, $event->getResultSnapshot()['status']);
+
+ list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
+ $this->assertEquals(200, $width);
+ $this->assertEquals(200, $height);
+ }
+
+ /**
+ * @testdox can not run when invalid folder
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function testInvalidFolder()
+ {
+ $language = $this->createStub(Language::class);
+ $language->method('_')->willReturn('test');
+
+ $app = $this->createStub(CMSApplicationInterface::class);
+ $app->method('getLanguage')->willReturn($language);
+
+ $plugin = new Checkfiles(new Dispatcher(), [], __DIR__);
+ $plugin->setApplication($app);
+
+ $task = $this->createStub(Task::class);
+ $task->method('get')->willReturnMap([['id', null, 1], ['type', null, 'checkfiles.imagesize']]);
+
+ $event = new ExecuteTaskEvent(
+ 'test',
+ [
+ 'subject' => $task,
+ 'params' => (object)['path' => '/invalid', 'dimension' => 'width', 'limit' => 20, 'numImages' => 1]
+ ]
+ );
+ $plugin->standardRoutineHandler($event);
+
+ list($width, $height) = getimagesize(__DIR__ . '/tmp/test.png');
+ $this->assertEquals(Status::NO_RUN, $event->getResultSnapshot()['status']);
+ $this->assertEquals(200, $width);
+ $this->assertEquals(200, $height);
+ }
}