diff --git a/plugins/authentication/joomla/joomla.php b/plugins/authentication/joomla/joomla.php
index 4eff44f675703..b0251ae0fdc05 100644
--- a/plugins/authentication/joomla/joomla.php
+++ b/plugins/authentication/joomla/joomla.php
@@ -114,127 +114,5 @@ public function onUserAuthenticate($credentials, $options, &$response)
$response->status = Authentication::STATUS_FAILURE;
$response->error_message = Text::_('JGLOBAL_AUTH_NO_USER');
}
-
- // Check the two factor authentication
- if ($response->status === Authentication::STATUS_SUCCESS)
- {
- $methods = AuthenticationHelper::getTwoFactorMethods();
-
- if (count($methods) <= 1)
- {
- // No two factor authentication method is enabled
- return;
- }
-
- $model = $this->app->bootComponent('com_users')->getMVCFactory()->createModel('User', 'Administrator', ['ignore_request' => true]);
-
- // Load the user's OTP (one time password, a.k.a. two factor auth) configuration
- if (!array_key_exists('otp_config', $options))
- {
- $otpConfig = $model->getOtpConfig($result->id);
- $options['otp_config'] = $otpConfig;
- }
- else
- {
- $otpConfig = $options['otp_config'];
- }
-
- // Check if the user has enabled two factor authentication
- if (empty($otpConfig->method) || ($otpConfig->method === 'none'))
- {
- // Warn the user if they are using a secret code but they have not
- // enabled two factor auth in their account.
- if (!empty($credentials['secretkey']))
- {
- try
- {
- $this->loadLanguage();
-
- $this->app->enqueueMessage(Text::_('PLG_AUTHENTICATION_JOOMLA_ERR_SECRET_CODE_WITHOUT_TFA'), 'warning');
- }
- catch (Exception $exc)
- {
- // This happens when we are in CLI mode. In this case
- // no warning is issued
- return;
- }
- }
-
- return;
- }
-
- // Try to validate the OTP
- PluginHelper::importPlugin('twofactorauth');
-
- $otpAuthReplies = $this->app->triggerEvent('onUserTwofactorAuthenticate', array($credentials, $options));
-
- $check = false;
-
- /*
- * This looks like noob code but DO NOT TOUCH IT and do not convert
- * to in_array(). During testing in_array() inexplicably returned
- * null when the OTEP begins with a zero! o_O
- */
- if (!empty($otpAuthReplies))
- {
- foreach ($otpAuthReplies as $authReply)
- {
- $check = $check || $authReply;
- }
- }
-
- // Fall back to one time emergency passwords
- if (!$check)
- {
- // Did the user use an OTEP instead?
- if (empty($otpConfig->otep))
- {
- if (empty($otpConfig->method) || ($otpConfig->method === 'none'))
- {
- // Two factor authentication is not enabled on this account.
- // Any string is assumed to be a valid OTEP.
-
- return;
- }
- else
- {
- /*
- * Two factor authentication enabled and no OTEPs defined. The
- * user has used them all up. Therefore anything they enter is
- * an invalid OTEP.
- */
- $response->status = Authentication::STATUS_FAILURE;
- $response->error_message = Text::_('JGLOBAL_AUTH_INVALID_SECRETKEY');
-
- return;
- }
- }
-
- // Clean up the OTEP (remove dashes, spaces and other funny stuff
- // our beloved users may have unwittingly stuffed in it)
- $otep = $credentials['secretkey'];
- $otep = filter_var($otep, FILTER_SANITIZE_NUMBER_INT);
- $otep = str_replace('-', '', $otep);
- $check = false;
-
- // Did we find a valid OTEP?
- if (in_array($otep, $otpConfig->otep))
- {
- // Remove the OTEP from the array
- $otpConfig->otep = array_diff($otpConfig->otep, array($otep));
-
- $model->setOtpConfig($result->id, $otpConfig);
-
- // Return true; the OTEP was a valid one
- $check = true;
- }
- }
-
- if (!$check)
- {
- $response->status = Authentication::STATUS_FAILURE;
- $response->error_message = Text::_('JGLOBAL_AUTH_INVALID_SECRETKEY');
- }
- }
}
}
diff --git a/plugins/multifactorauth/email/email.xml b/plugins/multifactorauth/email/email.xml
new file mode 100644
index 0000000000000..a42c23cb4f864
--- /dev/null
+++ b/plugins/multifactorauth/email/email.xml
@@ -0,0 +1,52 @@
+
+
+ plg_multifactorauth_email
+ Joomla! Project
+ 2022-05
+ (C) 2022 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 4.2.0
+ PLG_MULTIFACTORAUTH_EMAIL_XML_DESCRIPTION
+ Joomla\Plugin\Multifactorauth\Email
+
+ services
+ src
+
+
+ language/en-GB/plg_multifactorauth_email.ini
+ language/en-GB/plg_multifactorauth_email.sys.ini
+
+
+
+
+
+
+
diff --git a/plugins/multifactorauth/email/services/provider.php b/plugins/multifactorauth/email/services/provider.php
new file mode 100644
index 0000000000000..1dbe366044213
--- /dev/null
+++ b/plugins/multifactorauth/email/services/provider.php
@@ -0,0 +1,42 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') || die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Multifactorauth\Email\Extension\Email;
+
+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('multifactorauth', 'email');
+ $subject = $container->get(DispatcherInterface::class);
+
+ return new Email($subject, $config);
+ }
+ );
+ }
+};
diff --git a/plugins/multifactorauth/email/src/Extension/Email.php b/plugins/multifactorauth/email/src/Extension/Email.php
new file mode 100644
index 0000000000000..adee59eaf921a
--- /dev/null
+++ b/plugins/multifactorauth/email/src/Extension/Email.php
@@ -0,0 +1,630 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Email\Extension;
+
+use Exception;
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Encrypt\Totp;
+use Joomla\CMS\Event\MultiFactor\BeforeDisplayMethods;
+use Joomla\CMS\Event\MultiFactor\Captive;
+use Joomla\CMS\Event\MultiFactor\GetMethod;
+use Joomla\CMS\Event\MultiFactor\GetSetup;
+use Joomla\CMS\Event\MultiFactor\SaveSetup;
+use Joomla\CMS\Event\MultiFactor\Validate;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Input\Input;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Mail\Exception\MailDisabledException;
+use Joomla\CMS\Mail\MailTemplate;
+use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
+use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
+use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
+use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
+use Joomla\Component\Users\Administrator\Table\MfaTable;
+use Joomla\Event\SubscriberInterface;
+use PHPMailer\PHPMailer\Exception as phpMailerException;
+use RuntimeException;
+use function count;
+
+/**
+ * Joomla! Multi-factor Authentication using a Validation Code sent by Email.
+ *
+ * Requires entering a 6-digit code sent to the user through email. These codes change automatically
+ * on a frequency set in the plugin options (30 seconds to 5 minutes, default 2 minutes).
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class Email extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * Generated OTP length. Constant: 6 numeric digits.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private const CODE_LENGTH = 6;
+
+ /**
+ * Length of the secret key used for generating the OTPs. Constant: 20 characters.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private const SECRET_KEY_LENGTH = 20;
+
+ /**
+ * The CMS application we are running under
+ *
+ * @var CMSApplication
+ * @since __DEPLOY_VERSION__
+ */
+ protected $app;
+
+ /**
+ * Forbid registration of legacy (Joomla 3) event listeners.
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated
+ */
+ protected $allowLegacyListeners = false;
+
+ /**
+ * Autoload this plugin's language files
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * The MFA Method name handled by this plugin
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $mfaMethodName = 'email';
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
+ 'onUserMultifactorCaptive' => 'onUserMultifactorCaptive',
+ 'onUserMultifactorGetSetup' => 'onUserMultifactorGetSetup',
+ 'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
+ 'onUserMultifactorValidate' => 'onUserMultifactorValidate',
+ 'onUserMultifactorBeforeDisplayMethods' => 'onUserMultifactorBeforeDisplayMethods',
+ ];
+ }
+
+ /**
+ * Gets the identity of this MFA Method
+ *
+ * @param GetMethod $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetMethod(GetMethod $event): void
+ {
+ $event->addResult(
+ new MethodDescriptor(
+ [
+ 'name' => $this->mfaMethodName,
+ 'display' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'),
+ 'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_SHORTINFO'),
+ 'image' => 'media/plg_multifactorauth_email/images/email.svg',
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the Captive MFA page. This is the page
+ * which appears right after you log in and asks you to validate your login with MFA.
+ *
+ * @param Captive $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorCaptive(Captive $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $key = $options['key'] ?? '';
+
+ // Send an email message with a new code and ask the user to enter it.
+ // phpcs:ignore
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($record->user_id);
+
+ try
+ {
+ $this->sendCode($key, $user);
+ }
+ catch (Exception $e)
+ {
+ return;
+ }
+
+ $event->addResult(
+ new CaptiveRenderOptions(
+ [
+ // Custom HTML to display above the MFA form
+ 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_PRE_MESSAGE'),
+ // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
+ 'field_type' => 'input',
+ // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
+ 'input_type' => 'text',
+ // The attributes for the HTML input box.
+ 'input_attributes' => [
+ 'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric"
+ ],
+ // Placeholder text for the HTML input box. Leave empty if you don't need it.
+ 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_SETUP_PLACEHOLDER'),
+ // Label to show above the HTML input box. Leave empty if you don't need it.
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_LABEL'),
+ // Custom HTML. Only used when field_type = custom.
+ 'html' => '',
+ // Custom HTML to display below the MFA form
+ 'post_message' => '',
+ // Should I hide the default Submit button?
+ 'hide_submit' => false,
+ // Is this MFA method validating against all configured authenticators of the same type?
+ 'allowEntryBatching' => false,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the MFA setup page. This is the page
+ * which allows the user to add or modify a MFA Method for their user account. If the record
+ * does not correspond to your plugin return an empty array.
+ *
+ * @param GetSetup $event The event we are handling
+ *
+ * @return void
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetSetup(GetSetup $event): void
+ {
+ /** @var MfaTable $record The record currently selected by the user. */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $key = $options['key'] ?? '';
+ $isKeyAlreadySetup = !empty($key);
+
+ // If there's a key in the session use that instead.
+ $session = $this->app->getSession();
+ $session->get('plg_multifactorauth_email.emailcode.key', $key);
+
+ // Initialize objects
+ $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900);
+ $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH);
+
+ // If there's still no key in the options, generate one and save it in the session
+ if (!$isKeyAlreadySetup)
+ {
+ $key = $totp->generateSecret();
+
+ $session->set('plg_multifactorauth_email.emailcode.key', $key);
+ // phpcs:ignore
+ $session->set('plg_multifactorauth_email.emailcode.user_id', $record->user_id);
+
+ // phpcs:ignore
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($record->user_id);
+
+ $this->sendCode($key, $user);
+
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'),
+ 'hidden_data' => [
+ 'key' => $key,
+ ],
+ 'field_type' => 'input',
+ 'input_type' => 'text',
+ 'input_attributes' => [
+ 'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric"
+ ],
+ 'input_value' => '',
+ 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_SETUP_PLACEHOLDER'),
+ 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_PRE_MESSAGE'),
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_LABEL'),
+ ]
+ )
+ );
+ }
+ else
+ {
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'),
+ 'input_type' => 'hidden',
+ 'html' => '',
+ ]
+ )
+ );
+ }
+ }
+
+ /**
+ * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
+ * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
+ * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
+ * an empty array.
+ *
+ * @param SaveSetup $event The event we are handling
+ *
+ * @return void The configuration data to save to the database
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorSaveSetup(SaveSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ * @var Input $input The user input you are going to take into account.
+ */
+ $record = $event['record'];
+ $input = $event['input'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $key = $options['key'] ?? '';
+ $isKeyAlreadySetup = !empty($key);
+ $session = $this->app->getSession();
+
+ // If there is no key in the options fetch one from the session
+ if (empty($key))
+ {
+ $key = $session->get('plg_multifactorauth_email.emailcode.key', null);
+ }
+
+ // If there is still no key in the options throw an error
+ if (empty($key))
+ {
+ throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
+ }
+
+ /**
+ * If the code is empty but the key already existed in $options someone is simply changing the title / default
+ * Method status. We can allow this and stop checking anything else now.
+ */
+ $code = $input->getCmd('code');
+
+ if (empty($code) && $isKeyAlreadySetup)
+ {
+ $event->addResult($options);
+
+ return;
+ }
+
+ // In any other case validate the submitted code
+ $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900);
+ $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH);
+ $isValid = $totp->checkCode((string) $key, (string) $code);
+
+ if (!$isValid)
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_EMAIL_ERR_INVALID_CODE'), 500);
+ }
+
+ // The code is valid. Unset the key from the session.
+ $session->set('plg_multifactorauth_email.emailcode.key', null);
+
+ // Return the configuration to be serialized
+ $event->addResult(['key' => $key]);
+ }
+
+ /**
+ * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
+ * Authentication page. If the record does not correspond to your plugin return FALSE.
+ *
+ * @param Validate $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorValidate(Validate $event): void
+ {
+ /**
+ * @var MfaTable $record The MFA Method's record you're validating against
+ * @var User $user The user record
+ * @var string|null $code The submitted code
+ */
+ $record = $event['record'];
+ $user = $event['user'];
+ $code = $event['code'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Double check the MFA Method is for the correct user
+ // phpcs:ignore
+ if ($user->id != $record->user_id)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $key = $options['key'] ?? '';
+
+ // If there is no key in the options throw an error
+ if (empty($key))
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Check the MFA code for validity
+ $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900);
+ $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH);
+
+ $event->addResult($totp->checkCode($key, (string) $code));
+ }
+
+ /**
+ * Executes before showing the MFA Methods for the user. Used for the Force Enable feature.
+ *
+ * @param BeforeDisplayMethods $event The event we are handling
+ *
+ * @return void
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorBeforeDisplayMethods(BeforeDisplayMethods $event): void
+ {
+ /** @var ?User $user */
+ $user = $event['user'];
+
+ // Is the forced enable feature activated?
+ if ($this->params->get('force_enable', 0) != 1)
+ {
+ return;
+ }
+
+ // Get MFA Methods for this user
+ $userMfaRecords = MfaHelper::getUserMfaRecords($user->id);
+
+ // If there are no Methods go back
+ if (count($userMfaRecords) < 1)
+ {
+ return;
+ }
+
+ // If the only Method is backup codes go back
+ if (count($userMfaRecords) == 1)
+ {
+ /** @var MfaTable $record */
+ $record = reset($userMfaRecords);
+
+ if ($record->method == 'backupcodes')
+ {
+ return;
+ }
+ }
+
+ // If I already have the email Method go back
+ $emailRecords = array_filter(
+ $userMfaRecords,
+ function (MfaTable $record)
+ {
+ return $record->method == 'email';
+ }
+ );
+
+ if (count($emailRecords))
+ {
+ return;
+ }
+
+ // Add the email Method
+ try
+ {
+ /** @var MVCFactoryInterface $factory */
+ $factory = $this->app->bootComponent('com_users')->getMVCFactory();
+ /** @var MfaTable $record */
+ $record = $factory->createTable('Mfa', 'Administrator');
+ $record->reset();
+
+ $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900);
+ $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH);
+
+ $record->save(
+ [
+ 'method' => 'email',
+ 'title' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'),
+ 'options' => [
+ 'key' => ($totp)->generateSecret(),
+ ],
+ 'default' => 0,
+ ]
+ );
+ }
+ catch (Exception $event)
+ {
+ // Fail gracefully
+ }
+ }
+
+ /**
+ * Decodes the options from a record into an options object.
+ *
+ * @param MfaTable $record The record to decode
+ *
+ * @return array
+ * @since __DEPLOY_VERSION__
+ */
+ private function decodeRecordOptions(MfaTable $record): array
+ {
+ $options = [
+ 'key' => '',
+ ];
+
+ if (!empty($record->options))
+ {
+ $recordOptions = $record->options;
+
+ $options = array_merge($options, $recordOptions);
+ }
+
+ return $options;
+ }
+
+ /**
+ * Creates a new TOTP code based on secret key $key and sends it to the user via email.
+ *
+ * @param string $key The TOTP secret key
+ * @param User|null $user The Joomla! user to use
+ *
+ * @return void
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ private function sendCode(string $key, ?User $user = null)
+ {
+ static $alreadySent = false;
+
+ // Make sure we have a user
+ if (!is_object($user) || !($user instanceof User))
+ {
+ $user = $this->app->getIdentity()
+ ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
+ }
+
+ if ($alreadySent)
+ {
+ return;
+ }
+
+ $alreadySent = true;
+
+ // Get the API objects
+ $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900);
+ $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH);
+
+ // Create the list of variable replacements
+ $code = $totp->getCode($key);
+
+ $replacements = [
+ 'code' => $code,
+ 'sitename' => $this->app->get('sitename'),
+ 'siteurl' => Uri::base(),
+ 'username' => $user->username,
+ 'email' => $user->email,
+ 'fullname' => $user->name,
+ ];
+
+ try
+ {
+ $jLanguage = $this->app->getLanguage();
+ $mailer = new MailTemplate('plg_multifactorauth_email.mail', $jLanguage->getTag());
+ $mailer->addRecipient($user->email, $user->name);
+ $mailer->addTemplateData($replacements);
+
+ $didSend = $mailer->send();
+ }
+ catch (MailDisabledException | phpMailerException $exception)
+ {
+ try
+ {
+ Log::add(Text::_($exception->getMessage()), Log::WARNING, 'jerror');
+ }
+ catch (RuntimeException $exception)
+ {
+ $this->app->enqueueMessage(Text::_($exception->errorMessage()), 'warning');
+ }
+ }
+
+ try
+ {
+ // The user somehow managed to not install the mail template. I'll send the email the traditional way.
+ if (isset($didSend) && !$didSend)
+ {
+ $subject = Text::_('PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT');
+ $body = Text::_('PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY');
+
+ foreach ($replacements as $key => $value)
+ {
+ $subject = str_replace('{' . strtoupper($key) . '}', $value, $subject);
+ $body = str_replace('{' . strtoupper($key) . '}', $value, $body);
+ }
+
+ $mailer = Factory::getMailer();
+ $mailer->setSubject($subject);
+ $mailer->setBody($body);
+ $mailer->addRecipient($user->email, $user->name);
+
+ $mailer->Send();
+ }
+ }
+ catch (MailDisabledException | phpMailerException $exception)
+ {
+ try
+ {
+ Log::add(Text::_($exception->getMessage()), Log::WARNING, 'jerror');
+ }
+ catch (RuntimeException $exception)
+ {
+ $this->app->enqueueMessage(Text::_($exception->errorMessage()), 'warning');
+ }
+ }
+ }
+}
diff --git a/plugins/multifactorauth/fixed/fixed.xml b/plugins/multifactorauth/fixed/fixed.xml
new file mode 100644
index 0000000000000..c6001ad7dbaad
--- /dev/null
+++ b/plugins/multifactorauth/fixed/fixed.xml
@@ -0,0 +1,21 @@
+
+
+ plg_multifactorauth_fixed
+ Joomla! Project
+ 2022-05
+ (C) 2022 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 4.2.0
+ PLG_MULTIFACTORAUTH_FIXED_XML_DESCRIPTION
+ Joomla\Plugin\Multifactorauth\Fixed
+
+ services
+ src
+
+
+ language/en-GB/plg_multifactorauth_fixed.ini
+ language/en-GB/plg_multifactorauth_fixed.sys.ini
+
+
diff --git a/plugins/multifactorauth/fixed/services/provider.php b/plugins/multifactorauth/fixed/services/provider.php
new file mode 100644
index 0000000000000..dc18b606e5aa0
--- /dev/null
+++ b/plugins/multifactorauth/fixed/services/provider.php
@@ -0,0 +1,42 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') || die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Multifactorauth\Fixed\Extension\Fixed;
+
+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('multifactorauth', 'fixed');
+ $subject = $container->get(DispatcherInterface::class);
+
+ return new Fixed($subject, $config);
+ }
+ );
+ }
+};
diff --git a/plugins/multifactorauth/fixed/src/Extension/Fixed.php b/plugins/multifactorauth/fixed/src/Extension/Fixed.php
new file mode 100644
index 0000000000000..07f43de8a9c28
--- /dev/null
+++ b/plugins/multifactorauth/fixed/src/Extension/Fixed.php
@@ -0,0 +1,323 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Fixed\Extension;
+
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Event\MultiFactor\Captive;
+use Joomla\CMS\Event\MultiFactor\GetMethod;
+use Joomla\CMS\Event\MultiFactor\GetSetup;
+use Joomla\CMS\Event\MultiFactor\SaveSetup;
+use Joomla\CMS\Event\MultiFactor\Validate;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\User\User;
+use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
+use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
+use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
+use Joomla\Component\Users\Administrator\Table\MfaTable;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Input\Input;
+use RuntimeException;
+
+/**
+ * TJoomla! Multi-factor Authentication using a fixed code.
+ *
+ * Requires a static string (password), different for each user. It effectively works as a second
+ * password. The fixed code is stored hashed, like a regular password.
+ *
+ * This is NOT to be used on production sites. It serves as a demonstration plugin and as a template
+ * for developers to create their own custom Multi-factor Authentication plugins.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class Fixed extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * The application we are running under.
+ *
+ * @var CMSApplication
+ * @since __DEPLOY_VERSION__
+ */
+ protected $app;
+
+ /**
+ * Affects constructor behavior. If true, language files will be loaded automatically.
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * The MFA Method name handled by this plugin
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $mfaMethodName = 'fixed';
+
+ /**
+ * Should I try to detect and register legacy event listeners?
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated
+ */
+ protected $allowLegacyListeners = false;
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
+ 'onUserMultifactorCaptive' => 'onUserMultifactorCaptive',
+ 'onUserMultifactorGetSetup' => 'onUserMultifactorGetSetup',
+ 'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
+ 'onUserMultifactorValidate' => 'onUserMultifactorValidate',
+ ];
+ }
+
+ /**
+ * Gets the identity of this MFA Method
+ *
+ * @param GetMethod $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetMethod(GetMethod $event): void
+ {
+ $event->addResult(
+ new MethodDescriptor(
+ [
+ 'name' => $this->mfaMethodName,
+ 'display' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_DISPLAYEDAS'),
+ 'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_SHORTINFO'),
+ 'image' => 'media/plg_multifactorauth_fixed/images/fixed.svg',
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the Captive MFA page. This is the page
+ * which appears right after you log in and asks you to validate your login with MFA.
+ *
+ * @param Captive $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorCaptive(Captive $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ $event->addResult(
+ new CaptiveRenderOptions(
+ [
+ // Custom HTML to display above the MFA form
+ 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_PREMESSAGE'),
+ // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
+ 'field_type' => 'input',
+ // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
+ 'input_type' => 'password',
+ // Placeholder text for the HTML input box. Leave empty if you don't need it.
+ 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_PLACEHOLDER'),
+ // Label to show above the HTML input box. Leave empty if you don't need it.
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_LABEL'),
+ // Custom HTML. Only used when field_type = custom.
+ 'html' => '',
+ // Custom HTML to display below the MFA form
+ 'post_message' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_POSTMESSAGE'),
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the MFA setup page. This is the page
+ * which allows the user to add or modify a MFA Method for their user account. If the record
+ * does not correspond to your plugin return an empty array.
+ *
+ * @param GetSetup $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetSetup(GetSetup $event): void
+ {
+ /** @var MfaTable $record The record currently selected by the user. */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+
+ /**
+ * Return the parameters used to render the GUI.
+ *
+ * Some MFA Methods need to display a different interface before and after the setup. For example, when setting
+ * up Google Authenticator or a hardware OTP dongle you need the user to enter a MFA code to verify they are in
+ * possession of a correctly configured device. After the setup is complete you don't want them to see that
+ * field again. In the first state you could use the tabular_data to display the setup values, pre_message to
+ * display the QR code and field_type=input to let the user enter the MFA code. In the second state do the same
+ * BUT set field_type=custom, set html='' and show_submit=false to effectively hide the setup form from the
+ * user.
+ */
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_DEFAULTTITLE'),
+ 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_PREMESSAGE'),
+ 'field_type' => 'input',
+ 'input_type' => 'password',
+ // phpcs:ignore
+ 'input_value' => $options->fixed_code,
+ 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_PLACEHOLDER'),
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_LABEL'),
+ 'post_message' => Text::_('PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_POSTMESSAGE'),
+ ]
+ )
+ );
+ }
+
+ /**
+ * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
+ * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
+ * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
+ * an empty array.
+ *
+ * @param SaveSetup $event The event we are handling
+ *
+ * @return void The configuration data to save to the database
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorSaveSetup(SaveSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ * @var Input $input The user input you are going to take into account.
+ */
+ $record = $event['record'];
+ $input = $event['input'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+
+ // Merge with the submitted form data
+ // phpcs:ignore
+ $code = $input->get('code', $options->fixed_code, 'raw');
+
+ // Make sure the code is not empty
+ if (empty($code))
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_FIXED_ERR_EMPTYCODE'));
+ }
+
+ // Return the configuration to be serialized
+ $event->addResult(['fixed_code' => $code]);
+ }
+
+ /**
+ * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
+ * Authentication. If the record does not correspond to your plugin return FALSE.
+ *
+ * @param Validate $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorValidate(Validate $event): void
+ {
+ /**
+ * @var MfaTable $record The MFA Method's record you're validating against
+ * @var User $user The user record
+ * @var string|null $code The submitted code
+ */
+ $record = $event['record'];
+ $user = $event['user'];
+ $code = $event['code'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+
+ // Double check the MFA Method is for the correct user
+ // phpcs:ignore
+ if ($user->id != $record->user_id)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Check the MFA code for validity
+ // phpcs:ignore
+ $event->addResult(hash_equals($options->fixed_code, $code ?? ''));
+ }
+
+ /**
+ * Decodes the options from a record into an options object.
+ *
+ * @param MfaTable $record The record to decode options for
+ *
+ * @return object
+ * @since __DEPLOY_VERSION__
+ */
+ private function decodeRecordOptions(MfaTable $record): object
+ {
+ $options = [
+ 'fixed_code' => '',
+ ];
+
+ if (!empty($record->options))
+ {
+ $recordOptions = $record->options;
+
+ $options = array_merge($options, $recordOptions);
+ }
+
+ return (object) $options;
+ }
+}
diff --git a/plugins/multifactorauth/totp/services/provider.php b/plugins/multifactorauth/totp/services/provider.php
new file mode 100644
index 0000000000000..622f3338463c7
--- /dev/null
+++ b/plugins/multifactorauth/totp/services/provider.php
@@ -0,0 +1,42 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') || die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Multifactorauth\Totp\Extension\Totp;
+
+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('multifactorauth', 'totp');
+ $subject = $container->get(DispatcherInterface::class);
+
+ return new Totp($subject, $config);
+ }
+ );
+ }
+};
diff --git a/plugins/multifactorauth/totp/src/Extension/Totp.php b/plugins/multifactorauth/totp/src/Extension/Totp.php
new file mode 100644
index 0000000000000..53f09862ce243
--- /dev/null
+++ b/plugins/multifactorauth/totp/src/Extension/Totp.php
@@ -0,0 +1,403 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Totp\Extension;
+
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Encrypt\Totp as TotpHelper;
+use Joomla\CMS\Event\MultiFactor\Captive;
+use Joomla\CMS\Event\MultiFactor\GetMethod;
+use Joomla\CMS\Event\MultiFactor\GetSetup;
+use Joomla\CMS\Event\MultiFactor\SaveSetup;
+use Joomla\CMS\Event\MultiFactor\Validate;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
+use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
+use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
+use Joomla\Component\Users\Administrator\Table\MfaTable;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Input\Input;
+use RuntimeException;
+
+/**
+ * Joomla! Multi-factor Authentication using Google Authenticator TOTP Plugin
+ *
+ * @since 3.2
+ */
+class Totp extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * The application we are running under.
+ *
+ * @var CMSApplication
+ * @since __DEPLOY_VERSION__
+ */
+ protected $app;
+
+ /**
+ * Affects constructor behavior. If true, language files will be loaded automatically.
+ *
+ * @var boolean
+ * @since 3.2
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * The MFA Method name handled by this plugin
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $mfaMethodName = 'totp';
+
+ /**
+ * Should I try to detect and register legacy event listeners?
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated
+ */
+ protected $allowLegacyListeners = false;
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
+ 'onUserMultifactorCaptive' => 'onUserMultifactorCaptive',
+ 'onUserMultifactorGetSetup' => 'onUserMultifactorGetSetup',
+ 'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
+ 'onUserMultifactorValidate' => 'onUserMultifactorValidate',
+ ];
+ }
+
+ /**
+ * Gets the identity of this MFA Method
+ *
+ * @param GetMethod $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetMethod(GetMethod $event): void
+ {
+ $event->addResult(
+ new MethodDescriptor(
+ [
+ 'name' => $this->mfaMethodName,
+ 'display' => Text::_('PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE'),
+ 'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_TOTP_SHORTINFO'),
+ 'image' => 'media/plg_multifactorauth_totp/images/totp.svg',
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the Captive MFA page. This is the page
+ * which appears right after you log in and asks you to validate your login with MFA.
+ *
+ * @param Captive $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorCaptive(Captive $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method !== $this->mfaMethodName)
+ {
+ return;
+ }
+
+ $event->addResult(
+ new CaptiveRenderOptions(
+ [
+ // Custom HTML to display above the MFA form
+ 'pre_message' => '',
+ // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
+ 'field_type' => 'input',
+ // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
+ 'input_type' => 'text',
+ // The attributes for the HTML input box.
+ 'input_attributes' => [
+ 'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric"
+ ],
+ // Placeholder text for the HTML input box. Leave empty if you don't need it.
+ 'placeholder' => '',
+ // Label to show above the HTML input box. Leave empty if you don't need it.
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_LABEL'),
+ // Custom HTML. Only used when field_type = custom.
+ 'html' => '',
+ // Custom HTML to display below the MFA form
+ 'post_message' => '',
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the MFA setup page. This is the page
+ * which allows the user to add or modify a MFA Method for their user account. If the record
+ * does not correspond to your plugin return an empty array.
+ *
+ * @param GetSetup $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetSetup(GetSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method !== $this->mfaMethodName)
+ {
+ return;
+ }
+
+ $totp = new TotpHelper;
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $key = $options['key'] ?? '';
+ $session = $this->app->getSession();
+ $isConfigured = !empty($key);
+
+ // If there's a key in the session use that instead.
+ $sessionKey = $session->get('com_users.totp.key', null);
+
+ if (!empty($sessionKey))
+ {
+ $key = $sessionKey;
+ }
+
+ // If there's still no key in the options, generate one and save it in the session
+ if (empty($key))
+ {
+ $key = $totp->generateSecret();
+ $session->set('com_users.totp.key', $key);
+ }
+
+ // Generate a QR code for the key
+ // phpcs:ignore
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($record->user_id);
+ $hostname = Uri::getInstance()->toString(['host']);
+ $otpURL = sprintf("otpauth://totp/%s@%s?secret=%s", $user->username, $hostname, $key);
+ $document = $this->app->getDocument();
+ $wam = $document->getWebAssetManager();
+
+ $document->addScriptOptions('plg_multifactorauth_totp.totp.qr', $otpURL);
+
+ $wam->getRegistry()->addExtensionRegistryFile('plg_multifactorauth_totp');
+ $wam->useScript('plg_multifactorauth_totp.setup');
+
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE'),
+ 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_INSTRUCTIONS'),
+ 'table_heading' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_HEADING'),
+ 'tabular_data' => [
+ '' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_SUBHEAD'),
+ Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_KEY') => $key,
+ Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_QR') => "
",
+ Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK')
+ => Text::sprintf('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_TEXT', $otpURL) .
+ '
' . Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_NOTE') . '',
+ ],
+ 'hidden_data' => [
+ 'key' => $key,
+ ],
+ 'input_type' => $isConfigured ? 'hidden' : 'text',
+ 'input_attributes' => [
+ 'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric"
+ ],
+ 'input_value' => '',
+ 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_PLACEHOLDER'),
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_LABEL'),
+ ]
+ )
+ );
+ }
+
+ /**
+ * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
+ * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
+ * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
+ * an empty array.
+ *
+ * @param SaveSetup $event The event we are handling
+ *
+ * @return void The configuration data to save to the database
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorSaveSetup(SaveSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ * @var Input $input The user input you are going to take into account.
+ */
+ $record = $event['record'];
+ $input = $event['input'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $optionsKey = $options['key'] ?? '';
+ $key = $optionsKey;
+ $session = $this->app->getSession();
+
+ // If there is no key in the options fetch one from the session
+ if (empty($key))
+ {
+ $key = $session->get('com_users.totp.key', null);
+ }
+
+ // If there is still no key in the options throw an error
+ if (empty($key))
+ {
+ throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
+ }
+
+ /**
+ * If the code is empty but the key already existed in $options someone is simply changing the title / default
+ * Method status. We can allow this and stop checking anything else now.
+ */
+ $code = $input->getInt('code');
+
+ if (empty($code) && !empty($optionsKey))
+ {
+ $event->addResult($options);
+
+ return;
+ }
+
+ // In any other case validate the submitted code
+ $totp = new TotpHelper;
+ $isValid = $totp->checkCode($key, $code);
+
+ if (!$isValid)
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_TOTP_ERR_VALIDATIONFAILED'), 500);
+ }
+
+ // The code is valid. Unset the key from the session.
+ $session->set('com_users.totp.key', null);
+
+ // Return the configuration to be serialized
+ $event->addResult(
+ [
+ 'key' => $key,
+ ]
+ );
+ }
+
+ /**
+ * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
+ * Authentication page. If the record does not correspond to your plugin return FALSE.
+ *
+ * @param Validate $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorValidate(Validate $event): void
+ {
+ /**
+ * @var MfaTable $record The MFA Method's record you're validatng against
+ * @var User $user The user record
+ * @var string $code The submitted code
+ */
+ $record = $event['record'];
+ $user = $event['user'];
+ $code = $event['code'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method !== $this->mfaMethodName)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Double check the MFA Method is for the correct user
+ // phpcs:ignore
+ if ($user->id != $record->user_id)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $key = $options['key'] ?? '';
+
+ // If there is no key in the options throw an error
+ if (empty($key))
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Check the MFA code for validity
+ $event->addResult((new TotpHelper)->checkCode($key, $code));
+ }
+
+ /**
+ * Decodes the options from a record into an options object.
+ *
+ * @param MfaTable $record The record to decode options for
+ *
+ * @return array
+ * @since __DEPLOY_VERSION__
+ */
+ private function decodeRecordOptions(MfaTable $record): array
+ {
+ $options = [
+ 'key' => '',
+ ];
+
+ if (!empty($record->options))
+ {
+ $recordOptions = $record->options;
+
+ $options = array_merge($options, $recordOptions);
+ }
+
+ return $options;
+ }
+}
diff --git a/plugins/multifactorauth/totp/totp.xml b/plugins/multifactorauth/totp/totp.xml
new file mode 100644
index 0000000000000..9c8555211d841
--- /dev/null
+++ b/plugins/multifactorauth/totp/totp.xml
@@ -0,0 +1,21 @@
+
+
+ plg_multifactorauth_totp
+ Joomla! Project
+ 2013-08
+ (C) 2013 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 3.2.0
+ PLG_MULTIFACTORAUTH_TOTP_XML_DESCRIPTION
+ Joomla\Plugin\Multifactorauth\Totp
+
+ services
+ src
+
+
+ language/en-GB/plg_multifactorauth_totp.ini
+ language/en-GB/plg_multifactorauth_totp.sys.ini
+
+
diff --git a/plugins/multifactorauth/webauthn/services/provider.php b/plugins/multifactorauth/webauthn/services/provider.php
new file mode 100644
index 0000000000000..07ba7436e0052
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/services/provider.php
@@ -0,0 +1,42 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') || die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Multifactorauth\Webauthn\Extension\Webauthn;
+
+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('multifactorauth', 'webauthn');
+ $subject = $container->get(DispatcherInterface::class);
+
+ return new Webauthn($subject, $config);
+ }
+ );
+ }
+};
diff --git a/plugins/multifactorauth/webauthn/src/CredentialRepository.php b/plugins/multifactorauth/webauthn/src/CredentialRepository.php
new file mode 100644
index 0000000000000..be2dc2ad05299
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/src/CredentialRepository.php
@@ -0,0 +1,273 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Webauthn;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
+use Joomla\Component\Users\Administrator\Table\MfaTable;
+use RuntimeException;
+use Webauthn\AttestationStatement\AttestationStatement;
+use Webauthn\AttestedCredentialData;
+use Webauthn\PublicKeyCredentialDescriptor;
+use Webauthn\PublicKeyCredentialSource;
+use Webauthn\PublicKeyCredentialSourceRepository;
+use Webauthn\PublicKeyCredentialUserEntity;
+use Webauthn\TrustPath\EmptyTrustPath;
+
+/**
+ * Implementation of the credentials repository for the WebAuthn library.
+ *
+ * Important assumption: interaction with Webauthn through the library is only performed for the currently logged in
+ * user. Therefore all Methods which take a credential ID work by checking the Joomla MFA records of the current
+ * user only. This is a necessity. The records are stored encrypted, therefore we cannot do a partial search in the
+ * table. We have to load the records, decrypt them and inspect them. We cannot do that for thousands of records but
+ * we CAN do that for the few records each user has under their account.
+ *
+ * This behavior can be changed by passing a user ID in the constructor of the class.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class CredentialRepository implements PublicKeyCredentialSourceRepository
+{
+ /**
+ * The user ID we will operate with
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ private $userId = 0;
+
+ /**
+ * CredentialRepository constructor.
+ *
+ * @param int $userId The user ID this repository will be working with.
+ *
+ * @throws \Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(int $userId = 0)
+ {
+ if (empty($userId))
+ {
+ $user = Factory::getApplication()->getIdentity()
+ ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
+
+ $userId = $user->id;
+ }
+
+ $this->userId = $userId;
+ }
+
+ /**
+ * Finds a WebAuthn record given a credential ID
+ *
+ * @param string $publicKeyCredentialId The public credential ID to look for
+ *
+ * @return PublicKeyCredentialSource|null
+ * @since __DEPLOY_VERSION__
+ */
+ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
+ {
+ $publicKeyCredentialUserEntity = new PublicKeyCredentialUserEntity('', $this->userId, '', '');
+ $credentials = $this->findAllForUserEntity($publicKeyCredentialUserEntity);
+
+ foreach ($credentials as $record)
+ {
+ if ($record->getAttestedCredentialData()->getCredentialId() != $publicKeyCredentialId)
+ {
+ continue;
+ }
+
+ return $record;
+ }
+
+ return null;
+ }
+
+ /**
+ * Find all WebAuthn entries given a user entity
+ *
+ * @param PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity The user entity to search by
+ *
+ * @return array|PublicKeyCredentialSource[]
+ * @throws \Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
+ {
+ if (empty($publicKeyCredentialUserEntity))
+ {
+ $userId = $this->userId;
+ }
+ else
+ {
+ $userId = $publicKeyCredentialUserEntity->getId();
+ }
+
+ $return = [];
+
+ $results = MfaHelper::getUserMfaRecords($userId);
+
+ if (count($results) < 1)
+ {
+ return $return;
+ }
+
+ /** @var MfaTable $result */
+ foreach ($results as $result)
+ {
+ $options = $result->options;
+
+ if (!is_array($options) || empty($options))
+ {
+ continue;
+ }
+
+ if (!isset($options['attested']) && !isset($options['pubkeysource']))
+ {
+ continue;
+ }
+
+ if (isset($options['attested']) && is_string($options['attested']))
+ {
+ $options['attested'] = json_decode($options['attested'], true);
+
+ $return[$result->id] = $this->attestedCredentialToPublicKeyCredentialSource(
+ AttestedCredentialData::createFromArray($options['attested']), $userId
+ );
+ }
+ elseif (isset($options['pubkeysource']) && is_string($options['pubkeysource']))
+ {
+ $options['pubkeysource'] = json_decode($options['pubkeysource'], true);
+ $return[$result->id] = PublicKeyCredentialSource::createFromArray($options['pubkeysource']);
+ }
+ elseif (isset($options['pubkeysource']) && is_array($options['pubkeysource']))
+ {
+ $return[$result->id] = PublicKeyCredentialSource::createFromArray($options['pubkeysource']);
+ }
+ }
+
+ return $return;
+ }
+
+ /**
+ * Converts a legacy AttestedCredentialData object stored in the database into a PublicKeyCredentialSource object.
+ *
+ * This makes several assumptions which can be problematic and the reason why the WebAuthn library version 2 moved
+ * away from attested credentials to public key credential sources:
+ *
+ * - The credential is always of the public key type (that's safe as the only option supported)
+ * - You can access it with any kind of authenticator transport: USB, NFC, Internal or Bluetooth LE (possibly
+ * dangerous)
+ * - There is no attestations (generally safe since browsers don't seem to support attestation yet)
+ * - There is no trust path (generally safe since browsers don't seem to provide one)
+ * - No counter was stored (dangerous since it can lead to replay attacks).
+ *
+ * @param AttestedCredentialData $record Legacy attested credential data object
+ * @param int $userId User ID we are getting the credential source for
+ *
+ * @return PublicKeyCredentialSource
+ * @since __DEPLOY_VERSION__
+ */
+ private function attestedCredentialToPublicKeyCredentialSource(AttestedCredentialData $record, int $userId): PublicKeyCredentialSource
+ {
+ return new PublicKeyCredentialSource(
+ $record->getCredentialId(),
+ PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
+ [
+ PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_USB,
+ PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_NFC,
+ PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_INTERNAL,
+ PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_BLE,
+ ],
+ AttestationStatement::TYPE_NONE,
+ new EmptyTrustPath,
+ $record->getAaguid(),
+ $record->getCredentialPublicKey(),
+ $userId,
+ 0
+ );
+ }
+
+ /**
+ * Save a WebAuthn record
+ *
+ * @param PublicKeyCredentialSource $publicKeyCredentialSource The record to save
+ *
+ * @return void
+ * @throws \Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
+ {
+ // I can only create or update credentials for the user this class was created for
+ if ($publicKeyCredentialSource->getUserHandle() != $this->userId)
+ {
+ throw new RuntimeException('Cannot create or update WebAuthn credentials for a different user.', 403);
+ }
+
+ // Do I have an existing record for this credential?
+ $recordId = null;
+ $publicKeyCredentialUserEntity = new PublicKeyCredentialUserEntity('', $this->userId, '', '');
+ $credentials = $this->findAllForUserEntity($publicKeyCredentialUserEntity);
+
+ foreach ($credentials as $id => $record)
+ {
+ if ($record->getAttestedCredentialData()->getCredentialId() != $publicKeyCredentialSource->getAttestedCredentialData()->getCredentialId())
+ {
+ continue;
+ }
+
+ $recordId = $id;
+
+ break;
+ }
+
+ // Create or update a record
+ /** @var MVCFactoryInterface $factory */
+ $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
+ /** @var MfaTable $mfaTable */
+ $mfaTable = $factory->createTable('Mfa', 'Administrator');
+
+ if ($recordId)
+ {
+ $mfaTable->load($recordId);
+
+ $options = $mfaTable->options;
+
+ if (isset($options['attested']))
+ {
+ unset($options['attested']);
+ }
+
+ $options['pubkeysource'] = $publicKeyCredentialSource;
+ $mfaTable->save(
+ [
+ 'options' => $options
+ ]
+ );
+ }
+ else
+ {
+ $mfaTable->reset();
+ $mfaTable->save(
+ [
+ 'user_id' => $this->userId,
+ 'title' => 'WebAuthn auto-save',
+ 'method' => 'webauthn',
+ 'default' => 0,
+ 'options' => ['pubkeysource' => $publicKeyCredentialSource],
+ ]
+ );
+ }
+ }
+}
diff --git a/plugins/multifactorauth/webauthn/src/Extension/Webauthn.php b/plugins/multifactorauth/webauthn/src/Extension/Webauthn.php
new file mode 100644
index 0000000000000..a3844c60e684e
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/src/Extension/Webauthn.php
@@ -0,0 +1,477 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Webauthn\Extension;
+
+use Exception;
+use Joomla\CMS\Application\AdministratorApplication;
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Application\SiteApplication;
+use Joomla\CMS\Event\MultiFactor\Captive;
+use Joomla\CMS\Event\MultiFactor\GetMethod;
+use Joomla\CMS\Event\MultiFactor\GetSetup;
+use Joomla\CMS\Event\MultiFactor\SaveSetup;
+use Joomla\CMS\Event\MultiFactor\Validate;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
+use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
+use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
+use Joomla\Component\Users\Administrator\Table\MfaTable;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Input\Input;
+use Joomla\Plugin\Multifactorauth\Webauthn\Helper\Credentials;
+use RuntimeException;
+use Webauthn\PublicKeyCredentialRequestOptions;
+
+/**
+ * Joomla Multi-factor Authentication plugin for WebAuthn
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class Webauthn extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * The application object
+ *
+ * @var CMSApplication|SiteApplication|AdministratorApplication
+ * @since __DEPLOY_VERSION__
+ */
+ protected $app;
+
+ /**
+ * Auto-load the plugin's language files
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * The MFA Method name handled by this plugin
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $mfaMethodName = 'webauthn';
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
+ 'onUserMultifactorCaptive' => 'onUserMultifactorCaptive',
+ 'onUserMultifactorGetSetup' => 'onUserMultifactorGetSetup',
+ 'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
+ 'onUserMultifactorValidate' => 'onUserMultifactorValidate',
+ ];
+ }
+
+ /**
+ * Gets the identity of this MFA Method
+ *
+ * @param GetMethod $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetMethod(GetMethod $event): void
+ {
+ $event->addResult(
+ new MethodDescriptor(
+ [
+ 'name' => $this->mfaMethodName,
+ 'display' => Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_DISPLAYEDAS'),
+ 'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_SHORTINFO'),
+ 'image' => 'media/plg_multifactorauth_webauthn/images/webauthn.svg',
+ 'allowMultiple' => true,
+ 'allowEntryBatching' => true,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the MFA setup page. This is the page
+ * which allows the user to add or modify a MFA Method for their user account. If the record
+ * does not correspond to your plugin return an empty array.
+ *
+ * @param GetSetup $event The event we are handling
+ *
+ * @return void
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetSetup(GetSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Get some values assuming that we are NOT setting up U2F (the key is already registered)
+ $submitClass = '';
+ $submitIcon = 'icon icon-ok';
+ $submitText = 'JSAVE';
+ $preMessage = Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_CONFIGURED');
+ $type = 'input';
+ $html = '';
+ $hiddenData = [];
+
+ /**
+ * If there are no authenticators set up yet I need to show a different message and take a different action when
+ * my user clicks the submit button.
+ */
+ if (!is_array($record->options) || empty($record->options['credentialId'] ?? ''))
+ {
+ $document = $this->app->getDocument();
+ $wam = $document->getWebAssetManager();
+ $wam->getRegistry()->addExtensionRegistryFile('plg_multifactorauth_webauthn');
+
+ $layoutPath = PluginHelper::getLayoutPath('multifactorauth', 'webauthn');
+ ob_start();
+ include $layoutPath;
+ $html = ob_get_clean();
+ $type = 'custom';
+
+ // Load JS translations
+ Text::script('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD');
+
+ $document->addScriptOptions('com_users.pagetype', 'setup', false);
+
+ // Save the WebAuthn request to the session
+ $user = Factory::getApplication()->getIdentity()
+ ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
+ $hiddenData['pkRequest'] = base64_encode(Credentials::requestAttestation($user));
+
+ // Special button handling
+ $submitClass = "multifactorauth_webauthn_setup";
+ $submitIcon = 'icon icon-lock';
+ $submitText = 'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_REGISTERKEY';
+
+ // Message to display
+ $preMessage = Text::sprintf(
+ 'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_INSTRUCTIONS',
+ Text::_($submitText)
+ );
+ }
+
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_DISPLAYEDAS'),
+ 'pre_message' => $preMessage,
+ 'hidden_data' => $hiddenData,
+ 'field_type' => $type,
+ 'input_type' => 'hidden',
+ 'html' => $html,
+ 'show_submit' => true,
+ 'submit_class' => $submitClass,
+ 'submit_icon' => $submitIcon,
+ 'submit_text' => $submitText,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
+ * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
+ * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
+ * an empty array.
+ *
+ * @param SaveSetup $event The event we are handling
+ *
+ * @return void The configuration data to save to the database
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorSaveSetup(SaveSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ * @var Input $input The user input you are going to take into account.
+ */
+ $record = $event['record'];
+ $input = $event['input'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Editing an existing authenticator: only the title is saved
+ if (is_array($record->options) && !empty($record->options['credentialId'] ?? ''))
+ {
+ $event->addResult($record->options);
+
+ return;
+ }
+
+ $code = $input->get('code', null, 'base64');
+ $session = $this->app->getSession();
+ $registrationRequest = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions', null);
+
+ // If there was no registration request BUT there is a registration response throw an error
+ if (empty($registrationRequest) && !empty($code))
+ {
+ throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
+ }
+
+ // If there is no registration request (and there isn't a registration response) we are just saving the title.
+ if (empty($registrationRequest))
+ {
+ $event->addResult($record->options);
+
+ return;
+ }
+
+ // In any other case try to authorize the registration
+ try
+ {
+ $publicKeyCredentialSource = Credentials::verifyAttestation($code);
+ }
+ catch (Exception $err)
+ {
+ throw new RuntimeException($err->getMessage(), 403);
+ }
+ finally
+ {
+ // Unset the request data from the session.
+ $session->set('plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions', null);
+ $session->set('plg_multifactorauth_webauthn.registration_user_id', null);
+ }
+
+ // Return the configuration to be serialized
+ $event->addResult(
+ [
+ 'credentialId' => base64_encode($publicKeyCredentialSource->getAttestedCredentialData()->getCredentialId()),
+ 'pubkeysource' => json_encode($publicKeyCredentialSource),
+ 'counter' => 0,
+ ]
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the Captive MFA page. This is the page
+ * which appears right after you log in and asks you to validate your login with MFA.
+ *
+ * @param Captive $event The event we are handling
+ *
+ * @return void
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorCaptive(Captive $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ /**
+ * The following code looks stupid. An explanation is in order.
+ *
+ * What we normally want to do is save the authentication data returned by getAuthenticateData into the session.
+ * This is what is sent to the authenticator through the Javascript API and signed. The signature is posted back
+ * to the form as the "code" which is read by onUserMultifactorauthValidate. That Method will read the authentication
+ * data from the session and pass it along with the key registration data (from the database) and the
+ * authentication response (the "code" submitted in the form) to the WebAuthn library for validation.
+ *
+ * Validation will work as long as the challenge recorded in the encrypted AUTHENTICATION RESPONSE matches, upon
+ * decryption, the challenge recorded in the AUTHENTICATION DATA.
+ *
+ * I observed that for whatever stupid reason the browser was sometimes sending TWO requests to the server's
+ * Captive login page but only rendered the FIRST. This meant that the authentication data sent to the key had
+ * already been overwritten in the session by the "invisible" second request. As a result the challenge would
+ * not match and we'd get a validation error.
+ *
+ * The code below will attempt to read the authentication data from the session first. If it exists it will NOT
+ * try to replace it (technically it replaces it with a copy of the same data - same difference!). If nothing
+ * exists in the session, however, it WILL store the (random seeded) result of the getAuthenticateData Method.
+ * Therefore the first request to the Captive login page will store a new set of authentication data whereas the
+ * second, "invisible", request will just reuse the same data as the first request, fixing the observed issue in
+ * a way that doesn't compromise security.
+ *
+ * In case you are wondering, yes, the data is removed from the session in the onUserMultifactorauthValidate Method.
+ * In fact it's the first thing we do after reading it, preventing constant reuse of the same set of challenges.
+ *
+ * That was fun to debug - for "poke your eyes with a rusty fork" values of fun.
+ */
+
+ $session = $this->app->getSession();
+ $pkOptionsEncoded = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', null);
+
+ $force = $this->app->input->getInt('force', 0);
+
+ try
+ {
+ if ($force)
+ {
+ throw new RuntimeException('Expected exception (good): force a new key request');
+ }
+
+ if (empty($pkOptionsEncoded))
+ {
+ throw new RuntimeException('Expected exception (good): we do not have a pending key request');
+ }
+
+ $serializedOptions = base64_decode($pkOptionsEncoded);
+ $pkOptions = unserialize($serializedOptions);
+
+ if (!is_object($pkOptions) || empty($pkOptions) || !($pkOptions instanceof PublicKeyCredentialRequestOptions))
+ {
+ throw new RuntimeException('The pending key request is corrupt; a new one will be created');
+ }
+
+ $pkRequest = json_encode($pkOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+ catch (Exception $e)
+ {
+ // phpcs:ignore
+ $pkRequest = Credentials::requestAssertion($record->user_id);
+ }
+
+ $document = $this->app->getDocument();
+ $wam = $document->getWebAssetManager();
+ $wam->getRegistry()->addExtensionRegistryFile('plg_multifactorauth_webauthn');
+
+ try
+ {
+ /** @var CMSApplication $app */
+ $app = Factory::getApplication();
+ $app->getDocument()->addScriptOptions('com_users.authData', base64_encode($pkRequest), false);
+ $layoutPath = PluginHelper::getLayoutPath('multifactorauth', 'webauthn');
+ ob_start();
+ include $layoutPath;
+ $html = ob_get_clean();
+ }
+ catch (Exception $e)
+ {
+ return;
+ }
+
+ // Load JS translations
+ Text::script('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD');
+ Text::script('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NO_STORED_CREDENTIAL');
+
+ $document->addScriptOptions('com_users.pagetype', 'validate', false);
+
+ $event->addResult(
+ new CaptiveRenderOptions(
+ [
+ 'pre_message' => Text::sprintf(
+ 'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_INSTRUCTIONS',
+ Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_VALIDATEKEY')
+ ),
+ 'field_type' => 'custom',
+ 'input_type' => 'hidden',
+ 'placeholder' => '',
+ 'label' => '',
+ 'html' => $html,
+ 'post_message' => '',
+ 'hide_submit' => false,
+ 'submit_icon' => 'icon icon-lock',
+ 'submit_text' => 'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_VALIDATEKEY',
+ 'allowEntryBatching' => true,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
+ * Authentication page. If the record does not correspond to your plugin return FALSE.
+ *
+ * @param Validate $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorValidate(Validate $event): void
+ {
+ // This method is only available on HTTPS
+ if (Uri::getInstance()->getScheme() !== 'https')
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ /**
+ * @var MfaTable $record The MFA Method's record you're validatng against
+ * @var User $user The user record
+ * @var string $code The submitted code
+ */
+ $record = $event['record'];
+ $user = $event['user'];
+ $code = $event['code'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Double check the MFA Method is for the correct user
+ // phpcs:ignore
+ if ($user->id != $record->user_id)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ try
+ {
+ Credentials::verifyAssertion($code);
+ }
+ catch (Exception $e)
+ {
+ try
+ {
+ $this->app->enqueueMessage($e->getMessage(), 'error');
+ }
+ catch (Exception $e)
+ {
+ }
+
+ $event->addResult(false);
+
+ return;
+ }
+
+ $event->addResult(true);
+ }
+}
diff --git a/plugins/multifactorauth/webauthn/src/Helper/Credentials.php b/plugins/multifactorauth/webauthn/src/Helper/Credentials.php
new file mode 100644
index 0000000000000..dfeb870af674b
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/src/Helper/Credentials.php
@@ -0,0 +1,349 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Webauthn\Helper;
+
+use Exception;
+use Joomla\CMS\Application\CMSApplication;
+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\Multifactorauth\Webauthn\CredentialRepository;
+use Joomla\Plugin\Multifactorauth\Webauthn\Hotfix\Server;
+use Joomla\Session\SessionInterface;
+use Laminas\Diactoros\ServerRequestFactory;
+use ReflectionClass;
+use RuntimeException;
+use Webauthn\AttestedCredentialData;
+use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
+use Webauthn\AuthenticatorSelectionCriteria;
+use Webauthn\PublicKeyCredentialCreationOptions;
+use Webauthn\PublicKeyCredentialDescriptor;
+use Webauthn\PublicKeyCredentialRequestOptions;
+use Webauthn\PublicKeyCredentialRpEntity;
+use Webauthn\PublicKeyCredentialSource;
+use Webauthn\PublicKeyCredentialUserEntity;
+
+/**
+ * Helper class to aid in credentials creation (link an authenticator to a user account)
+ *
+ * @since __DEPLOY_VERSION__
+ */
+abstract class Credentials
+{
+ /**
+ * Authenticator registration step 1: create a public key for credentials attestation.
+ *
+ * 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
+ * @throws Exception On error
+ * @since __DEPLOY_VERSION__
+ */
+ public static function requestAttestation(User $user): string
+ {
+ $publicKeyCredentialCreationOptions = self::getWebauthnServer($user->id)
+ ->generatePublicKeyCredentialCreationOptions(
+ self::getUserEntity($user),
+ PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
+ self::getPubKeyDescriptorsForUser($user),
+ new AuthenticatorSelectionCriteria(
+ AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
+ false,
+ AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED
+ ),
+ new AuthenticationExtensionsClientInputs
+ );
+
+ // Save data in the session
+ $session = Factory::getApplication()->getSession();
+
+ $session->set(
+ 'plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions',
+ base64_encode(serialize($publicKeyCredentialCreationOptions))
+ );
+ $session->set('plg_multifactorauth_webauthn.registration_user_id', $user->id);
+
+ return json_encode($publicKeyCredentialCreationOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+
+ /**
+ * Authenticator registration step 2: verify the credentials attestation by the authenticator
+ *
+ * This returns the attested credential data on success.
+ *
+ * An exception will be returned on error. Also, under very rare conditions, you may receive NULL instead of
+ * attested credential data 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 AttestedCredentialData|null
+ * @throws Exception When something does not check out
+ * @since __DEPLOY_VERSION__
+ */
+ public static function verifyAttestation(string $data): ?PublicKeyCredentialSource
+ {
+ $session = Factory::getApplication()->getSession();
+
+ // Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
+ $encodedOptions = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions', null);
+
+ if (empty($encodedOptions))
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_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_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_PK'));
+ }
+
+ // Retrieve the stored user ID and make sure it's the same one in the request.
+ $storedUserId = $session->get('plg_multifactorauth_webauthn.registration_user_id', 0);
+ $myUser = Factory::getApplication()->getIdentity()
+ ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
+ $myUserId = $myUser->id;
+
+ if (($myUser->guest) || ($myUserId != $storedUserId))
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_USER'));
+ }
+
+ return self::getWebauthnServer($myUser->id)->loadAndCheckAttestationResponse(
+ base64_decode($data),
+ $publicKeyCredentialCreationOptions,
+ ServerRequestFactory::fromGlobals()
+ );
+ }
+
+ /**
+ * Authentication step 1: create a challenge for key verification
+ *
+ * @param int $userId The user ID to create a WebAuthn PK for
+ *
+ * @return string
+ * @throws Exception On error
+ * @since __DEPLOY_VERSION__
+ */
+ public static function requestAssertion(int $userId): string
+ {
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
+
+ $publicKeyCredentialRequestOptions = self::getWebauthnServer($userId)
+ ->generatePublicKeyCredentialRequestOptions(
+ PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
+ self::getPubKeyDescriptorsForUser($user)
+ );
+
+ // Save in session. This is used during the verification stage to prevent replay attacks.
+ /** @var SessionInterface $session */
+ $session = Factory::getApplication()->getSession();
+ $session->set('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', base64_encode(serialize($publicKeyCredentialRequestOptions)));
+ $session->set('plg_multifactorauth_webauthn.userHandle', $userId);
+ $session->set('plg_multifactorauth_webauthn.userId', $userId);
+
+ // Return the JSON encoded data to the caller
+ return json_encode($publicKeyCredentialRequestOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+
+ /**
+ * Authentication step 2: Checks if the browser's response to our challenge is valid.
+ *
+ * @param string $response Base64-encoded response
+ *
+ * @return void
+ * @throws Exception When something does not check out.
+ * @since __DEPLOY_VERSION__
+ */
+ public static function verifyAssertion(string $response): void
+ {
+ /** @var SessionInterface $session */
+ $session = Factory::getApplication()->getSession();
+
+ $encodedPkOptions = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', null);
+ $userHandle = $session->get('plg_multifactorauth_webauthn.userHandle', null);
+ $userId = $session->get('plg_multifactorauth_webauthn.userId', null);
+
+ $session->set('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', null);
+ $session->set('plg_multifactorauth_webauthn.userHandle', null);
+ $session->set('plg_multifactorauth_webauthn.userId', null);
+
+ if (empty($userId))
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ // Make sure the user exists
+ $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
+
+ if ($user->id != $userId)
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ // Make sure the user is ourselves (we cannot perform MFA on behalf of another user!)
+ $currentUser = Factory::getApplication()->getIdentity()
+ ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
+
+ if ($currentUser->id != $userId)
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ // Make sure the public key credential request options in the session are valid
+ $serializedOptions = base64_decode($encodedPkOptions);
+ $publicKeyCredentialRequestOptions = unserialize($serializedOptions);
+
+ if (!is_object($publicKeyCredentialRequestOptions)
+ || empty($publicKeyCredentialRequestOptions)
+ || !($publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions))
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
+ }
+
+ // Unserialize the browser response data
+ $data = base64_decode($response);
+
+ self::getWebauthnServer($user->id)->loadAndCheckAssertionResponse(
+ $data,
+ $publicKeyCredentialRequestOptions,
+ self::getUserEntity($user),
+ ServerRequestFactory::fromGlobals()
+ );
+
+ }
+
+ /**
+ * 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 static 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);
+ }
+
+ /**
+ * Get a WebAuthn user entity for a Joomla user
+ *
+ * @param User $user The user to get an entity for
+ *
+ * @return PublicKeyCredentialUserEntity
+ * @since __DEPLOY_VERSION__
+ */
+ private static function getUserEntity(User $user): PublicKeyCredentialUserEntity
+ {
+ return new PublicKeyCredentialUserEntity(
+ $user->username,
+ $user->id,
+ $user->name,
+ self::getAvatar($user, 64)
+ );
+ }
+
+ /**
+ * Get the WebAuthn library server object
+ *
+ * @param int|null $userId The user ID holding the list of valid authenticators
+ *
+ * @return Server
+ * @since __DEPLOY_VERSION__
+ */
+ private static function getWebauthnServer(?int $userId): Server
+ {
+ /** @var CMSApplication $app */
+ try
+ {
+ $app = Factory::getApplication();
+ $siteName = $app->get('sitename');
+ }
+ catch (Exception $e)
+ {
+ $siteName = 'Joomla! Site';
+ }
+
+ // Credentials repository
+ $repository = new CredentialRepository($userId);
+
+ // Relaying Party -- Our site
+ $rpEntity = new PublicKeyCredentialRpEntity(
+ $siteName ?? 'Joomla! Site',
+ Uri::getInstance()->toString(['host']),
+ ''
+ );
+
+ $refClass = new ReflectionClass(Server::class);
+ $refConstructor = $refClass->getConstructor();
+ $params = $refConstructor->getParameters();
+
+ if (count($params) === 3)
+ {
+ // WebAuthn library 2, 3
+ $server = new Server($rpEntity, $repository, null);
+ }
+ else
+ {
+ // WebAuthn library 4 (based on the deprecated comments in library version 3)
+ $server = new Server($rpEntity, $repository);
+ }
+
+ // Ed25519 is only available with libsodium
+ if (!function_exists('sodium_crypto_sign_seed_keypair'))
+ {
+ $server->setSelectedAlgorithms(['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512']);
+ }
+
+ return $server;
+ }
+
+ /**
+ * Returns an array of the PK credential descriptors (registered authenticators) for the given user.
+ *
+ * @param User $user The user to get the descriptors for
+ *
+ * @return PublicKeyCredentialDescriptor[]
+ * @since __DEPLOY_VERSION__
+ */
+ private static function getPubKeyDescriptorsForUser(User $user): array
+ {
+ $userEntity = self::getUserEntity($user);
+ $repository = new CredentialRepository($user->id);
+ $descriptors = [];
+ $records = $repository->findAllForUserEntity($userEntity);
+
+ foreach ($records as $record)
+ {
+ $descriptors[] = $record->getPublicKeyCredentialDescriptor();
+ }
+
+ return $descriptors;
+ }
+
+}
diff --git a/plugins/multifactorauth/webauthn/src/Hotfix/AndroidKeyAttestationStatementSupport.php b/plugins/multifactorauth/webauthn/src/Hotfix/AndroidKeyAttestationStatementSupport.php
new file mode 100644
index 0000000000000..13e919c4e7437
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/src/Hotfix/AndroidKeyAttestationStatementSupport.php
@@ -0,0 +1,267 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Webauthn\Hotfix;
+
+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/multifactorauth/webauthn/src/Hotfix/FidoU2FAttestationStatementSupport.php b/plugins/multifactorauth/webauthn/src/Hotfix/FidoU2FAttestationStatementSupport.php
new file mode 100644
index 0000000000000..17890cc72117a
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/src/Hotfix/FidoU2FAttestationStatementSupport.php
@@ -0,0 +1,227 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Webauthn\Hotfix;
+
+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/multifactorauth/webauthn/src/Hotfix/Server.php b/plugins/multifactorauth/webauthn/src/Hotfix/Server.php
new file mode 100644
index 0000000000000..e8370561b304f
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/src/Hotfix/Server.php
@@ -0,0 +1,449 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Webauthn\Hotfix;
+
+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.
+ */
+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/multifactorauth/webauthn/tmpl/default.php b/plugins/multifactorauth/webauthn/tmpl/default.php
new file mode 100644
index 0000000000000..879dfda4a15e9
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/tmpl/default.php
@@ -0,0 +1,49 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+// Prevent direct access
+defined('_JEXEC') || die;
+
+//phpcs:ignorefile
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Uri\Uri;
+
+// This method is only available on HTTPS
+if (Uri::getInstance()->getScheme() !== 'https'): ?>
+
+app->getDocument()->getWebAssetManager()->useScript('plg_multifactorauth_webauthn.webauthn');
+
+?>
+
diff --git a/plugins/multifactorauth/webauthn/webauthn.xml b/plugins/multifactorauth/webauthn/webauthn.xml
new file mode 100644
index 0000000000000..fa76f33e122da
--- /dev/null
+++ b/plugins/multifactorauth/webauthn/webauthn.xml
@@ -0,0 +1,22 @@
+
+
+ plg_multifactorauth_webauthn
+ Joomla! Project
+ 2022-05
+ (C) 2022 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 4.2.0
+ PLG_MULTIFACTORAUTH_WEBAUTHN_XML_DESCRIPTION
+ Joomla\Plugin\Multifactorauth\Webauthn
+
+ services
+ src
+ tmpl
+
+
+ language/en-GB/plg_multifactorauth_webauthn.ini
+ language/en-GB/plg_multifactorauth_webauthn.sys.ini
+
+
diff --git a/plugins/multifactorauth/yubikey/services/provider.php b/plugins/multifactorauth/yubikey/services/provider.php
new file mode 100644
index 0000000000000..d0bbaa6a855cd
--- /dev/null
+++ b/plugins/multifactorauth/yubikey/services/provider.php
@@ -0,0 +1,42 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') || die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Multifactorauth\Yubikey\Extension\Yubikey;
+
+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('multifactorauth', 'yubikey');
+ $subject = $container->get(DispatcherInterface::class);
+
+ return new Yubikey($subject, $config);
+ }
+ );
+ }
+};
diff --git a/plugins/multifactorauth/yubikey/src/Extension/Yubikey.php b/plugins/multifactorauth/yubikey/src/Extension/Yubikey.php
new file mode 100644
index 0000000000000..cff2270d1f17e
--- /dev/null
+++ b/plugins/multifactorauth/yubikey/src/Extension/Yubikey.php
@@ -0,0 +1,665 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Multifactorauth\Yubikey\Extension;
+
+use Exception;
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Event\MultiFactor\Captive;
+use Joomla\CMS\Event\MultiFactor\GetMethod;
+use Joomla\CMS\Event\MultiFactor\GetSetup;
+use Joomla\CMS\Event\MultiFactor\SaveSetup;
+use Joomla\CMS\Event\MultiFactor\Validate;
+use Joomla\CMS\Http\HttpFactory;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\User;
+use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
+use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
+use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
+use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
+use Joomla\Component\Users\Administrator\Table\MfaTable;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Input\Input;
+use RuntimeException;
+
+/**
+ * Joomla! Multi-factor Authentication using Yubikey Plugin
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class Yubikey extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * The application we are running under.
+ *
+ * @var CMSApplication
+ * @since __DEPLOY_VERSION__
+ */
+ protected $app;
+
+ /**
+ * Affects constructor behavior. If true, language files will be loaded automatically.
+ *
+ * @var boolean
+ * @since 3.2
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * The MFA Method name handled by this plugin
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private $mfaMethodName = 'yubikey';
+
+ /**
+ * Should I try to detect and register legacy event listeners?
+ *
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ *
+ * @deprecated
+ */
+ protected $allowLegacyListeners = false;
+
+ /**
+ * Returns an array of events this subscriber will listen to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
+ 'onUserMultifactorCaptive' => 'onUserMultifactorCaptive',
+ 'onUserMultifactorGetSetup' => 'onUserMultifactorGetSetup',
+ 'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
+ 'onUserMultifactorValidate' => 'onUserMultifactorValidate',
+ ];
+ }
+
+
+ /**
+ * Gets the identity of this MFA Method
+ *
+ * @param GetMethod $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetMethod(GetMethod $event): void
+ {
+ $event->addResult(
+ new MethodDescriptor(
+ [
+ 'name' => $this->mfaMethodName,
+ 'display' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'),
+ 'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_SHORTINFO'),
+ 'image' => 'media/plg_multifactorauth_yubikey/images/yubikey.svg',
+ 'allowEntryBatching' => true,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the Captive MFA page. This is the page
+ * which appears right after you log in and asks you to validate your login with MFA.
+ *
+ * @param Captive $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorCaptive(Captive $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ $event->addResult(
+ new CaptiveRenderOptions(
+ [
+ // Custom HTML to display above the MFA form
+ 'pre_message' => '',
+ // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
+ 'field_type' => 'input',
+ // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
+ 'input_type' => 'text',
+ // Placeholder text for the HTML input box. Leave empty if you don't need it.
+ 'placeholder' => '',
+ // Label to show above the HTML input box. Leave empty if you don't need it.
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_CODE_LABEL'),
+ // Custom HTML. Only used when field_type = custom.
+ 'html' => '',
+ // Custom HTML to display below the MFA form
+ 'post_message' => '',
+ // Allow authentication against all entries of this MFA Method.
+ 'allowEntryBatching' => 1,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Returns the information which allows Joomla to render the MFA setup page. This is the page
+ * which allows the user to add or modify a MFA Method for their user account. If the record
+ * does not correspond to your plugin return an empty array.
+ *
+ * @param GetSetup $event The event we are handling
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorGetSetup(GetSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ */
+ $record = $event['record'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $keyID = $options['id'] ?? '';
+
+ if (empty($keyID))
+ {
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'),
+ 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_INSTRUCTIONS'),
+ 'field_type' => 'input',
+ 'input_type' => 'text',
+ 'input_value' => $keyID,
+ 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_PLACEHOLDER'),
+ 'label' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_LABEL'),
+ ]
+ )
+ );
+ }
+ else
+ {
+ $event->addResult(
+ new SetupRenderOptions(
+ [
+ 'default_title' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'),
+ 'pre_message' => Text::sprintf('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_AFTERSETUP_INSTRUCTIONS', $keyID),
+ 'input_type' => 'hidden',
+ ]
+ )
+ );
+ }
+ }
+
+ /**
+ * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
+ * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
+ * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
+ * an empty array.
+ *
+ * @param SaveSetup $event The event we are handling
+ *
+ * @return void The configuration data to save to the database
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorSaveSetup(SaveSetup $event): void
+ {
+ /**
+ * @var MfaTable $record The record currently selected by the user.
+ * @var Input $input The user input you are going to take into account.
+ */
+ $record = $event['record'];
+ $input = $event['input'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ return;
+ }
+
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $keyID = $options['id'] ?? '';
+ $isKeyAlreadySetup = !empty($keyID);
+
+ /**
+ * If the submitted code is 12 characters and identical to our existing key there is no change, perform no
+ * further checks.
+ */
+ $code = $input->getString('code');
+
+ if ($isKeyAlreadySetup || ((strlen($code) == 12) && ($code == $keyID)))
+ {
+ $event->addResult($options);
+
+ return;
+ }
+
+ // If an empty code or something other than 44 characters was submitted I'm not having any of this!
+ if (empty($code) || (strlen($code) != 44))
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED'), 500);
+ }
+
+ // Validate the code
+ $isValid = $this->validateYubikeyOtp($code);
+
+ if (!$isValid)
+ {
+ throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED'), 500);
+ }
+
+ // The code is valid. Keep the Yubikey ID (first twelve characters)
+ $keyID = substr($code, 0, 12);
+
+ // Return the configuration to be serialized
+ $event->addResult(['id' => $keyID]);
+ }
+
+ /**
+ * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
+ * Authentication page. If the record does not correspond to your plugin return FALSE.
+ *
+ * @param Validate $event The event we are handling
+ *
+ * @return void
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserMultifactorValidate(Validate $event): void
+ {
+ /**
+ * @var MfaTable $record The MFA Method's record you're validatng against
+ * @var User $user The user record
+ * @var string $code The submitted code
+ */
+ $record = $event['record'];
+ $user = $event['user'];
+ $code = $event['code'];
+
+ // Make sure we are actually meant to handle this Method
+ if ($record->method != $this->mfaMethodName)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ // Double check the MFA Method is for the correct user
+ // phpcs:ignore
+ if ($user->id != $record->user_id)
+ {
+ $event->addResult(false);
+
+ return;
+ }
+
+ try
+ {
+ // phpcs:ignore
+ $records = MfaHelper::getUserMfaRecords($record->user_id);
+ $records = array_filter(
+ $records,
+ function ($rec) use ($record) {
+ return $rec->method === $record->method;
+ }
+ );
+ }
+ catch (Exception $e)
+ {
+ $records = [];
+ }
+
+ // Loop all records, stop if at least one matches
+ $result = array_reduce(
+ $records,
+ function (bool $carry, $aRecord) use ($code)
+ {
+ return $carry || $this->validateAgainstRecord($aRecord, $code);
+ },
+ false
+ );
+
+ $event->addResult($result);
+ }
+
+ /**
+ * Validates a Yubikey OTP against the Yubikey servers
+ *
+ * @param string $otp The OTP generated by your Yubikey
+ *
+ * @return boolean True if it's a valid OTP
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ private function validateYubikeyOtp(string $otp): bool
+ {
+ // Let the user define a client ID and a secret key in the plugin's configuration
+ $clientID = $this->params->get('client_id', 1);
+ $secretKey = $this->params->get('secret', '');
+ $serverQueue = trim($this->params->get('servers', ''));
+
+ if (!empty($serverQueue))
+ {
+ $serverQueue = explode("\r", $serverQueue);
+ }
+
+ if (empty($serverQueue))
+ {
+ $serverQueue = [
+ 'https://api.yubico.com/wsapi/2.0/verify',
+ 'https://api2.yubico.com/wsapi/2.0/verify',
+ 'https://api3.yubico.com/wsapi/2.0/verify',
+ 'https://api4.yubico.com/wsapi/2.0/verify',
+ 'https://api5.yubico.com/wsapi/2.0/verify',
+ ];
+ }
+
+ shuffle($serverQueue);
+
+ $gotResponse = false;
+
+ $http = HttpFactory::getHttp();
+ $token = $this->app->getFormToken();
+ $nonce = md5($token . uniqid(random_int(0, mt_getrandmax())));
+ $response = null;
+
+ while (!$gotResponse && !empty($serverQueue))
+ {
+ $server = array_shift($serverQueue);
+ $uri = new Uri($server);
+
+ // The client ID for signing the response
+ $uri->setVar('id', $clientID);
+
+ // The OTP we read from the user
+ $uri->setVar('otp', $otp);
+
+ // This prevents a REPLAYED_OTP status if the token doesn't change after a user submits an invalid OTP
+ $uri->setVar('nonce', $nonce);
+
+ // Minimum service level required: 50% (at least 50% of the YubiCloud servers must reply positively for the
+ // OTP to validate)
+ $uri->setVar('sl', 50);
+
+ // Timeout waiting for YubiCloud servers to reply: 5 seconds.
+ $uri->setVar('timeout', 5);
+
+ // Set up the optional HMAC-SHA1 signature for the request.
+ $this->signRequest($uri, $secretKey);
+
+ if ($uri->hasVar('h'))
+ {
+ $uri->setVar('h', urlencode($uri->getVar('h')));
+ }
+
+ try
+ {
+ $response = $http->get($uri->toString(), [], 6);
+
+ if (!empty($response))
+ {
+ $gotResponse = true;
+ }
+ else
+ {
+ continue;
+ }
+ }
+ catch (Exception $exc)
+ {
+ // No response, continue with the next server
+ continue;
+ }
+ }
+
+ if (empty($response))
+ {
+ $gotResponse = false;
+ }
+
+ // No server replied; we can't validate this OTP
+ if (!$gotResponse)
+ {
+ return false;
+ }
+
+ // Parse response
+ $lines = explode("\n", $response->body);
+ $data = [];
+
+ foreach ($lines as $line)
+ {
+ $line = trim($line);
+ $parts = explode('=', $line, 2);
+
+ if (count($parts) < 2)
+ {
+ continue;
+ }
+
+ $data[$parts[0]] = $parts[1];
+ }
+
+ // Validate the signature
+ $h = $data['h'] ?? null;
+ $fakeUri = Uri::getInstance('http://www.example.com');
+ $fakeUri->setQuery($data);
+ $this->signRequest($fakeUri, $secretKey);
+ $calculatedH = $fakeUri->getVar('h', null);
+
+ if ($calculatedH != $h)
+ {
+ return false;
+ }
+
+ // Validate the response - We need an OK message reply
+ if ($data['status'] !== 'OK')
+ {
+ return false;
+ }
+
+ // Validate the response - We need a confidence level over 50%
+ if ($data['sl'] < 50)
+ {
+ return false;
+ }
+
+ // Validate the response - The OTP must match
+ if ($data['otp'] != $otp)
+ {
+ return false;
+ }
+
+ // Validate the response - The token must match
+ if ($data['nonce'] != $nonce)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Sign the request to YubiCloud.
+ *
+ * @param Uri $uri The request URI to sign
+ * @param string $secret The secret key to sign with
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ *
+ * @see https://developers.yubico.com/yubikey-val/Validation_Protocol_V2.0.html
+ */
+ private function signRequest(Uri $uri, string $secret): void
+ {
+ // Make sure we have an encoding secret
+ $secret = trim($secret);
+
+ if (empty($secret))
+ {
+ return;
+ }
+
+ // I will need base64 encoding and decoding
+ if (!function_exists('base64_encode') || !function_exists('base64_decode'))
+ {
+ return;
+ }
+
+ // I need HMAC-SHA-1 support. Therefore I check for HMAC and SHA1 support in the PHP 'hash' extension.
+ if (!function_exists('hash_hmac') || !function_exists('hash_algos'))
+ {
+ return;
+ }
+
+ $algos = hash_algos();
+
+ if (!in_array('sha1', $algos))
+ {
+ return;
+ }
+
+ // Get the parameters
+ /** @var array $vars I have to explicitly state the type because the Joomla docblock is wrong :( */
+ $vars = $uri->getQuery(true);
+
+ // 'h' is the hash and it doesn't participate in the calculation of itself.
+ if (isset($vars['h']))
+ {
+ unset($vars['h']);
+ }
+
+ // Alphabetically sort the set of key/value pairs by key order.
+ ksort($vars);
+
+ /**
+ * Construct a single line with each ordered key/value pair concatenated using &, and each key and value
+ * concatenated with =. Do not add any line breaks. Do not add whitespace.
+ *
+ * Now, if you thought I can't really write PHP code, a.k.a. why not use http_build_query, read on.
+ *
+ * The way YubiKey expects the query to be built is UTTERLY WRONG. They are doing string concatenation, not
+ * URL query building! Therefore you cannot use http_build_query(). Instead, you need to use dumb string
+ * concatenation. I kid you not. If you want to laugh (or cry) read their Auth_Yubico class. It's 1998 all over
+ * again.
+ */
+ $stringToSign = '';
+
+ foreach ($vars as $k => $v)
+ {
+ $stringToSign .= '&' . $k . '=' . $v;
+ }
+
+ $stringToSign = ltrim($stringToSign, '&');
+
+ /**
+ * Apply the HMAC-SHA-1 algorithm on the line as an octet string using the API key as key (remember to
+ * base64decode the API key obtained from Yubico).
+ */
+ $decodedKey = base64_decode($secret);
+ $hash = hash_hmac('sha1', $stringToSign, $decodedKey, true);
+
+ /**
+ * Base 64 encode the resulting value according to RFC 4648, for example, t2ZMtKeValdA+H0jVpj3LIichn4=
+ */
+ $h = base64_encode($hash);
+
+ /**
+ * Append the value under key h to the message.
+ */
+ $uri->setVar('h', $h);
+ }
+
+ /**
+ * Decodes the options from a record into an options object.
+ *
+ * @param MfaTable $record The record to decode
+ *
+ * @return array
+ * @since __DEPLOY_VERSION__
+ */
+ private function decodeRecordOptions(MfaTable $record): array
+ {
+ $options = [
+ 'id' => '',
+ ];
+
+ if (!empty($record->options))
+ {
+ $recordOptions = $record->options;
+
+ $options = array_merge($options, $recordOptions);
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param MfaTable $record The record to validate against
+ * @param string $code The code given to us by the user
+ *
+ * @return boolean
+ * @throws Exception
+ * @since __DEPLOY_VERSION__
+ */
+ private function validateAgainstRecord(MfaTable $record, string $code): bool
+ {
+ // Load the options from the record (if any)
+ $options = $this->decodeRecordOptions($record);
+ $keyID = $options['id'] ?? '';
+
+ // If there is no key in the options throw an error
+ if (empty($keyID))
+ {
+ return false;
+ }
+
+ // If the submitted code is empty throw an error
+ if (empty($code))
+ {
+ return false;
+ }
+
+ // If the submitted code length is wrong throw an error
+ if (strlen($code) != 44)
+ {
+ return false;
+ }
+
+ // If the submitted code's key ID does not match the stored throw an error
+ if (substr($code, 0, 12) != $keyID)
+ {
+ return false;
+ }
+
+ // Check the OTP code for validity
+ return $this->validateYubikeyOtp($code);
+ }
+}
diff --git a/plugins/multifactorauth/yubikey/yubikey.xml b/plugins/multifactorauth/yubikey/yubikey.xml
new file mode 100644
index 0000000000000..a96afb3405fe3
--- /dev/null
+++ b/plugins/multifactorauth/yubikey/yubikey.xml
@@ -0,0 +1,21 @@
+
+
+ plg_multifactorauth_yubikey
+ Joomla! Project
+ 2013-09
+ (C) 2013 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 3.2.0
+ PLG_MULTIFACTORAUTH_YUBIKEY_XML_DESCRIPTION
+ Joomla\Plugin\Multifactorauth\Yubikey
+
+ services
+ src
+
+
+ language/en-GB/plg_multifactorauth_yubikey.ini
+ language/en-GB/plg_multifactorauth_yubikey.sys.ini
+
+
diff --git a/plugins/system/privacyconsent/privacyconsent.php b/plugins/system/privacyconsent/privacyconsent.php
index 31ae0ac52b20c..719e235b3cad6 100644
--- a/plugins/system/privacyconsent/privacyconsent.php
+++ b/plugins/system/privacyconsent/privacyconsent.php
@@ -307,7 +307,17 @@ public function onAfterRoute()
* If user is already on edit profile screen or view privacy article
* or press update/apply button, or logout, do nothing to avoid infinite redirect
*/
- if ($option == 'com_users' && in_array($task, ['profile.save', 'profile.apply', 'user.logout', 'user.menulogout'])
+ $allowedUserTasks = [
+ 'profile.save', 'profile.apply', 'user.logout', 'user.menulogout',
+ 'method', 'methods', 'captive', 'callback'
+ ];
+ $isAllowedUserTask = in_array($task, $allowedUserTasks)
+ || substr($task, 0, 8) === 'captive.'
+ || substr($task, 0, 8) === 'methods.'
+ || substr($task, 0, 7) === 'method.'
+ || substr($task, 0, 9) === 'callback.';
+
+ if (($option == 'com_users' && $isAllowedUserTask)
|| ($option == 'com_content' && $view == 'article' && $id == $privacyArticleId)
|| ($option == 'com_users' && $view == 'profile' && $layout == 'edit'))
{
diff --git a/plugins/twofactorauth/totp/postinstall/actions.php b/plugins/twofactorauth/totp/postinstall/actions.php
deleted file mode 100644
index 2253fbabef4a1..0000000000000
--- a/plugins/twofactorauth/totp/postinstall/actions.php
+++ /dev/null
@@ -1,68 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- *
- * This file contains the functions used by the com_postinstall code to deliver
- * the necessary post-installation messages concerning the activation of the
- * two-factor authentication code.
- */
-
-use Joomla\CMS\Factory;
-
-/**
- * Checks if the plugin is enabled. If not it returns true, meaning that the
- * message concerning two factor authentication should be displayed.
- *
- * @return integer
- *
- * @since 3.2
- */
-function twofactorauth_postinstall_condition()
-{
- $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
-
- $query = $db->getQuery(true)
- ->select('*')
- ->from($db->quoteName('#__extensions'))
- ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
- ->where($db->quoteName('enabled') . ' = ' . 1)
- ->where($db->quoteName('folder') . ' = ' . $db->quote('twofactorauth'));
- $db->setQuery($query);
- $enabled_plugins = $db->loadObjectList();
-
- return count($enabled_plugins) === 0;
-}
-
-/**
- * Enables the two factor authentication plugin and redirects the user to their
- * user profile page so that they can enable two factor authentication on their
- * account.
- *
- * @return void
- *
- * @since 3.2
- */
-function twofactorauth_postinstall_action()
-{
- // Enable the plugin
- $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
-
- $query = $db->getQuery(true)
- ->update($db->quoteName('#__extensions'))
- ->set($db->quoteName('enabled') . ' = ' . 1)
- ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
- ->where($db->quoteName('folder') . ' = ' . $db->quote('twofactorauth'));
- $db->setQuery($query);
- $db->execute();
-
- // Clean cache.
- Factory::getCache()->clean('com_plugins');
-
- // Redirect the user to their profile editor page
- $url = 'index.php?option=com_users&task=user.edit&id=' . Factory::getUser()->id;
- Factory::getApplication()->redirect($url);
-}
diff --git a/plugins/twofactorauth/totp/tmpl/form.php b/plugins/twofactorauth/totp/tmpl/form.php
deleted file mode 100644
index ea50347d8e553..0000000000000
--- a/plugins/twofactorauth/totp/tmpl/form.php
+++ /dev/null
@@ -1,130 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-defined('_JEXEC') or die;
-
-use Joomla\CMS\Factory;
-use Joomla\CMS\Language\Text;
-
-Factory::getDocument()->getWebAssetManager()->usePreset('qrcode');
-
-$js = "
-(function(document)
-{
- document.addEventListener('DOMContentLoaded', function()
- {
- var totpQrCodeElement = document.getElementById('totp-qrcode');
-
- // There's no QR Code element on the view profile page so ensure we don't get any errors
- if (totpQrCodeElement) {
- var qr = qrcode(0, 'H');
- qr.addData('" . $url . "');
- qr.make();
-
- totpQrCodeElement.innerHTML = qr.createImgTag(4);
- }
- });
-})(document);
-";
-
-Factory::getDocument()->addScriptDeclaration($js);
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
- /
- |
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/plugins/twofactorauth/totp/totp.php b/plugins/twofactorauth/totp/totp.php
deleted file mode 100644
index e945ab307f50a..0000000000000
--- a/plugins/twofactorauth/totp/totp.php
+++ /dev/null
@@ -1,299 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-defined('_JEXEC') or die;
-
-use Joomla\CMS\Encrypt\Totp;
-use Joomla\CMS\Factory;
-use Joomla\CMS\Language\Text;
-use Joomla\CMS\Plugin\CMSPlugin;
-use Joomla\CMS\Plugin\PluginHelper;
-
-/**
- * Joomla! Two Factor Authentication using Google Authenticator TOTP Plugin
- *
- * @since 3.2
- */
-class PlgTwofactorauthTotp extends CMSPlugin
-{
- /**
- * Affects constructor behavior. If true, language files will be loaded automatically.
- *
- * @var boolean
- * @since 3.2
- */
- protected $autoloadLanguage = true;
-
- /**
- * Method name
- *
- * @var string
- * @since 3.2
- */
- protected $methodName = 'totp';
-
- /**
- * This method returns the identification object for this two factor
- * authentication plugin.
- *
- * @return stdClass An object with public properties method and title
- *
- * @since 3.2
- */
- public function onUserTwofactorIdentify()
- {
- $section = (int) $this->params->get('section', 3);
-
- $current_section = 0;
-
- try
- {
- $app = Factory::getApplication();
-
- if ($app->isClient('administrator'))
- {
- $current_section = 2;
- }
- elseif ($app->isClient('site'))
- {
- $current_section = 1;
- }
- }
- catch (Exception $exc)
- {
- $current_section = 0;
- }
-
- if (!($current_section & $section))
- {
- return false;
- }
-
- return (object) array(
- 'method' => $this->methodName,
- 'title' => Text::_('PLG_TWOFACTORAUTH_TOTP_METHOD_TITLE'),
- );
- }
-
- /**
- * Shows the configuration page for this two factor authentication method.
- *
- * @param object $otpConfig The two factor auth configuration object
- * @param integer $userId The numeric user ID of the user whose form we'll display
- *
- * @return boolean|string False if the method is not ours, the HTML of the configuration page otherwise
- *
- * @see UsersModelUser::getOtpConfig
- * @since 3.2
- */
- public function onUserTwofactorShowConfiguration($otpConfig, $userId = null)
- {
- // Create a new TOTP class with Google Authenticator compatible settings
- $totp = new Totp(30, 6, 10);
-
- if ($otpConfig->method === $this->methodName)
- {
- // This method is already activated. Reuse the same secret key.
- $secret = $otpConfig->config['code'];
- }
- else
- {
- // This methods is not activated yet. Create a new secret key.
- $secret = $totp->generateSecret();
- }
-
- // These are used by Google Authenticator to tell accounts apart
- $username = Factory::getUser($userId)->username;
- $sitename = Factory::getApplication()->get('sitename');
-
- // This is the URL to the QR code for Google Authenticator
- $url = sprintf("otpauth://totp/%s/%s?secret=%s&issuer=%s", rawurlencode($sitename), $username, $secret, rawurlencode($sitename));
-
- // Is this a new TOTP setup? If so, we'll have to show the code validation field.
- $new_totp = $otpConfig->method !== 'totp';
-
- // Start output buffering
- @ob_start();
-
- // Include the form.php from a template override. If none is found use the default.
- include_once PluginHelper::getLayoutPath('twofactorauth', 'totp', 'form');
-
- // Stop output buffering and get the form contents
- $html = @ob_get_clean();
-
- // Return the form contents
- return array(
- 'method' => $this->methodName,
- 'form' => $html,
- );
- }
-
- /**
- * The save handler of the two factor configuration method's configuration
- * page.
- *
- * @param string $method The two factor auth method for which we'll show the config page
- *
- * @return boolean|stdClass False if the method doesn't match or we have an error, OTP config object if it succeeds
- *
- * @see UsersModelUser::setOtpConfig
- * @since 3.2
- */
- public function onUserTwofactorApplyConfiguration($method)
- {
- if ($method !== $this->methodName)
- {
- return false;
- }
-
- // Get a reference to the input data object
- $input = Factory::getApplication()->input;
-
- // Load raw data
- $rawData = $input->get('jform', array(), 'array');
-
- if (!isset($rawData['twofactor']['totp']))
- {
- return false;
- }
-
- $data = $rawData['twofactor']['totp'];
-
- // Warn if the securitycode is empty
- if (array_key_exists('securitycode', $data) && empty($data['securitycode']))
- {
- try
- {
- $app = Factory::getApplication();
- $app->enqueueMessage(Text::_('PLG_TWOFACTORAUTH_TOTP_ERR_VALIDATIONFAILED'), 'error');
- }
- catch (Exception $exc)
- {
- // This only happens when we are in a CLI application. We cannot
- // enqueue a message, so just do nothing.
- }
-
- return false;
- }
-
- // Create a new TOTP class with Google Authenticator compatible settings
- $totp = new Totp(30, 6, 10);
-
- // Check the security code entered by the user (exact time slot match)
- $code = $totp->getCode($data['key']);
- $check = $code === $data['securitycode'];
-
- /*
- * If the check fails, test the previous 30 second slot. This allow the
- * user to enter the security code when it's becoming red in Google
- * Authenticator app (reaching the end of its 30 second lifetime)
- */
- if (!$check)
- {
- $time = time() - 30;
- $code = $totp->getCode($data['key'], $time);
- $check = $code === $data['securitycode'];
- }
-
- /*
- * If the check fails, test the next 30 second slot. This allows some
- * time drift between the authentication device and the server
- */
- if (!$check)
- {
- $time = time() + 30;
- $code = $totp->getCode($data['key'], $time);
- $check = $code === $data['securitycode'];
- }
-
- if (!$check)
- {
- // Check failed. Do not change two factor authentication settings.
- return false;
- }
-
- // Check succeeded; return an OTP configuration object
- $otpConfig = (object) array(
- 'method' => 'totp',
- 'config' => array(
- 'code' => $data['key'],
- ),
- 'otep' => array(),
- );
-
- return $otpConfig;
- }
-
- /**
- * This method should handle any two factor authentication and report back
- * to the subject.
- *
- * @param array $credentials Array holding the user credentials
- * @param array $options Array of extra options
- *
- * @return boolean True if the user is authorised with this two-factor authentication method
- *
- * @since 3.2
- */
- public function onUserTwofactorAuthenticate($credentials, $options)
- {
- // Get the OTP configuration object
- $otpConfig = $options['otp_config'];
-
- // Make sure it's an object
- if (empty($otpConfig) || !is_object($otpConfig))
- {
- return false;
- }
-
- // Check if we have the correct method
- if ($otpConfig->method !== $this->methodName)
- {
- return false;
- }
-
- // Check if there is a security code
- if (empty($credentials['secretkey']))
- {
- return false;
- }
-
- // Create a new TOTP class with Google Authenticator compatible settings
- $totp = new Totp(30, 6, 10);
-
- // Check the code
- $code = $totp->getCode($otpConfig->config['code']);
- $check = $code === $credentials['secretkey'];
-
- /*
- * If the check fails, test the previous 30 second slot. This allow the
- * user to enter the security code when it's becoming red in Google
- * Authenticator app (reaching the end of its 30 second lifetime)
- */
- if (!$check)
- {
- $time = time() - 30;
- $code = $totp->getCode($otpConfig->config['code'], $time);
- $check = $code === $credentials['secretkey'];
- }
-
- /*
- * If the check fails, test the next 30 second slot. This allows some
- * time drift between the authentication device and the server
- */
- if (!$check)
- {
- $time = time() + 30;
- $code = $totp->getCode($otpConfig->config['code'], $time);
- $check = $code === $credentials['secretkey'];
- }
-
- return $check;
- }
-}
diff --git a/plugins/twofactorauth/totp/totp.xml b/plugins/twofactorauth/totp/totp.xml
deleted file mode 100644
index 5efdcee4427ac..0000000000000
--- a/plugins/twofactorauth/totp/totp.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
- plg_twofactorauth_totp
- Joomla! Project
- 2013-08
- (C) 2013 Open Source Matters, Inc.
- GNU General Public License version 2 or later; see LICENSE.txt
- admin@joomla.org
- www.joomla.org
- 3.2.0
- PLG_TWOFACTORAUTH_TOTP_XML_DESCRIPTION
-
- totp.php
- postinstall
- tmpl
-
-
- language/en-GB/plg_twofactorauth_totp.ini
- language/en-GB/plg_twofactorauth_totp.sys.ini
-
-
-
-
-
-
-
diff --git a/plugins/twofactorauth/yubikey/tmpl/form.php b/plugins/twofactorauth/yubikey/tmpl/form.php
deleted file mode 100644
index 44ac2ed35d8e2..0000000000000
--- a/plugins/twofactorauth/yubikey/tmpl/form.php
+++ /dev/null
@@ -1,47 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-defined('_JEXEC') or die;
-
-use Joomla\CMS\Language\Text;
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/plugins/twofactorauth/yubikey/yubikey.php b/plugins/twofactorauth/yubikey/yubikey.php
deleted file mode 100644
index 036fbdad62626..0000000000000
--- a/plugins/twofactorauth/yubikey/yubikey.php
+++ /dev/null
@@ -1,367 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-defined('_JEXEC') or die;
-
-use Joomla\CMS\Factory;
-use Joomla\CMS\Http\HttpFactory;
-use Joomla\CMS\Language\Text;
-use Joomla\CMS\Plugin\CMSPlugin;
-use Joomla\CMS\Plugin\PluginHelper;
-use Joomla\CMS\Session\Session;
-use Joomla\CMS\Uri\Uri;
-
-/**
- * Joomla! Two Factor Authentication using Yubikey Plugin
- *
- * @since 3.2
- */
-class PlgTwofactorauthYubikey extends CMSPlugin
-{
- /**
- * Affects constructor behavior. If true, language files will be loaded automatically.
- *
- * @var boolean
- * @since 3.2
- */
- protected $autoloadLanguage = true;
-
- /**
- * Method name
- *
- * @var string
- * @since 3.2
- */
- protected $methodName = 'yubikey';
-
- /**
- * This method returns the identification object for this two factor
- * authentication plugin.
- *
- * @return stdClass An object with public properties method and title
- *
- * @since 3.2
- */
- public function onUserTwofactorIdentify()
- {
- $section = (int) $this->params->get('section', 3);
- $current_section = 0;
-
- try
- {
- $app = Factory::getApplication();
-
- if ($app->isClient('administrator'))
- {
- $current_section = 2;
- }
- elseif ($app->isClient('site'))
- {
- $current_section = 1;
- }
- }
- catch (Exception $exc)
- {
- $current_section = 0;
- }
-
- if (!($current_section & $section))
- {
- return false;
- }
-
- return (object) array(
- 'method' => $this->methodName,
- 'title' => Text::_('PLG_TWOFACTORAUTH_YUBIKEY_METHOD_TITLE'),
- );
- }
-
- /**
- * Shows the configuration page for this two factor authentication method.
- *
- * @param object $otpConfig The two factor auth configuration object
- * @param integer|null $userId The numeric user ID of the user whose form we'll display
- *
- * @return array
- *
- * @see UsersModelUser::getOtpConfig
- * @since 3.2
- */
- public function onUserTwofactorShowConfiguration($otpConfig, $userId = null)
- {
- if ($otpConfig->method === $this->methodName)
- {
- // This method is already activated. Reuse the same Yubikey ID.
- $yubikey = $otpConfig->config['yubikey'];
- }
- else
- {
- // This methods is not activated yet. We'll need a Yubikey TOTP to setup this Yubikey.
- $yubikey = '';
- }
-
- // Is this a new TOTP setup? If so, we'll have to show the code validation field.
- $new_totp = $otpConfig->method !== $this->methodName;
-
- // Start output buffering
- @ob_start();
-
- // Include the form.php from a template override. If none is found use the default.
- include_once PluginHelper::getLayoutPath('twofactorauth', 'yubikey', 'form');
-
- // Stop output buffering and get the form contents
- $html = @ob_get_clean();
-
- // Return the form contents
- return array(
- 'method' => $this->methodName,
- 'form' => $html,
- );
- }
-
- /**
- * The save handler of the two factor configuration method's configuration
- * page.
- *
- * @param string $method The two factor auth method for which we'll show the config page
- *
- * @return boolean|stdClass False if the method doesn't match or we have an error, OTP config object if it succeeds
- *
- * @see UsersModelUser::setOtpConfig
- * @since 3.2
- */
- public function onUserTwofactorApplyConfiguration($method)
- {
- if ($method !== $this->methodName)
- {
- return false;
- }
-
- // Get a reference to the input data object
- $input = Factory::getApplication()->input;
-
- // Load raw data
- $rawData = $input->get('jform', array(), 'array');
-
- if (!isset($rawData['twofactor']['yubikey']))
- {
- return false;
- }
-
- $data = $rawData['twofactor']['yubikey'];
-
- // Warn if the securitycode is empty
- if (array_key_exists('securitycode', $data) && empty($data['securitycode']))
- {
- try
- {
- Factory::getApplication()->enqueueMessage(Text::_('PLG_TWOFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED'), 'error');
- }
- catch (Exception $exc)
- {
- // This only happens when we are in a CLI application. We cannot
- // enqueue a message, so just do nothing.
- }
-
- return false;
- }
-
- // Validate the Yubikey OTP
- $check = $this->validateYubikeyOtp($data['securitycode']);
-
- if (!$check)
- {
- Factory::getApplication()->enqueueMessage(Text::_('PLG_TWOFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED'), 'error');
-
- // Check failed. Do not change two factor authentication settings.
- return false;
- }
-
- // Remove the last 32 digits and store the rest in the user configuration parameters
- $yubikey = substr($data['securitycode'], 0, -32);
-
- // Check succeeded; return an OTP configuration object
- $otpConfig = (object) array(
- 'method' => $this->methodName,
- 'config' => array(
- 'yubikey' => $yubikey,
- ),
- 'otep' => array(),
- );
-
- return $otpConfig;
- }
-
- /**
- * This method should handle any two factor authentication and report back
- * to the subject.
- *
- * @param array $credentials Array holding the user credentials
- * @param array $options Array of extra options
- *
- * @return boolean True if the user is authorised with this two-factor authentication method
- *
- * @since 3.2
- */
- public function onUserTwofactorAuthenticate($credentials, $options)
- {
- // Get the OTP configuration object
- $otpConfig = $options['otp_config'];
-
- // Make sure it's an object
- if (empty($otpConfig) || !is_object($otpConfig))
- {
- return false;
- }
-
- // Check if we have the correct method
- if ($otpConfig->method !== $this->methodName)
- {
- return false;
- }
-
- // Check if there is a security code
- if (empty($credentials['secretkey']))
- {
- return false;
- }
-
- // Check if the Yubikey starts with the configured Yubikey user string
- $yubikey_valid = $otpConfig->config['yubikey'];
- $yubikey = substr($credentials['secretkey'], 0, -32);
-
- $check = $yubikey === $yubikey_valid;
-
- if ($check)
- {
- $check = $this->validateYubikeyOtp($credentials['secretkey']);
- }
-
- return $check;
- }
-
- /**
- * Validates a Yubikey OTP against the Yubikey servers
- *
- * @param string $otp The OTP generated by your Yubikey
- *
- * @return boolean True if it's a valid OTP
- *
- * @since 3.2
- */
- public function validateYubikeyOtp($otp)
- {
- $server_queue = array(
- 'api.yubico.com',
- 'api2.yubico.com',
- 'api3.yubico.com',
- 'api4.yubico.com',
- 'api5.yubico.com',
- );
-
- shuffle($server_queue);
-
- $gotResponse = false;
- $check = false;
-
- $token = Session::getFormToken();
- $nonce = md5($token . uniqid(mt_rand()));
-
- while (!$gotResponse && !empty($server_queue))
- {
- $server = array_shift($server_queue);
- $uri = new Uri('https://' . $server . '/wsapi/2.0/verify');
-
- // I don't see where this ID is used?
- $uri->setVar('id', 1);
-
- // The OTP we read from the user
- $uri->setVar('otp', $otp);
-
- // This prevents a REPLAYED_OTP status of the token doesn't change
- // after a user submits an invalid OTP
- $uri->setVar('nonce', $nonce);
-
- // Minimum service level required: 50% (at least 50% of the YubiCloud
- // servers must reply positively for the OTP to validate)
- $uri->setVar('sl', 50);
-
- // Timeout waiting for YubiCloud servers to reply: 5 seconds.
- $uri->setVar('timeout', 5);
-
- try
- {
- $response = HttpFactory::getHttp()->get($uri->toString(), [], 6);
-
- if (!empty($response))
- {
- $gotResponse = true;
- }
- else
- {
- continue;
- }
- }
- catch (Exception $exc)
- {
- // No response, continue with the next server
- continue;
- }
- }
-
- // No server replied; we can't validate this OTP
- if (!$gotResponse)
- {
- return false;
- }
-
- // Parse response
- $lines = explode("\n", $response->body);
- $data = array();
-
- foreach ($lines as $line)
- {
- $line = trim($line);
- $parts = explode('=', $line, 2);
-
- if (count($parts) < 2)
- {
- continue;
- }
-
- $data[$parts[0]] = $parts[1];
- }
-
- // Validate the response - We need an OK message reply
- if ($data['status'] !== 'OK')
- {
- return false;
- }
-
- // Validate the response - We need a confidence level over 50%
- if ($data['sl'] < 50)
- {
- return false;
- }
-
- // Validate the response - The OTP must match
- if ($data['otp'] !== $otp)
- {
- return false;
- }
-
- // Validate the response - The token must match
- if ($data['nonce'] !== $nonce)
- {
- return false;
- }
-
- return true;
- }
-}
diff --git a/plugins/twofactorauth/yubikey/yubikey.xml b/plugins/twofactorauth/yubikey/yubikey.xml
deleted file mode 100644
index 4df4178b2ce36..0000000000000
--- a/plugins/twofactorauth/yubikey/yubikey.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
- plg_twofactorauth_yubikey
- Joomla! Project
- 2013-09
- (C) 2013 Open Source Matters, Inc.
- GNU General Public License version 2 or later; see LICENSE.txt
- admin@joomla.org
- www.joomla.org
- 3.2.0
- PLG_TWOFACTORAUTH_YUBIKEY_XML_DESCRIPTION
-
- yubikey.php
- tmpl
-
-
- language/en-GB/plg_twofactorauth_yubikey.ini
- language/en-GB/plg_twofactorauth_yubikey.sys.ini
-
-
-
-
-
-
-
diff --git a/plugins/user/joomla/joomla.php b/plugins/user/joomla/joomla.php
index c2457900f84d3..38071f2517832 100644
--- a/plugins/user/joomla/joomla.php
+++ b/plugins/user/joomla/joomla.php
@@ -125,6 +125,39 @@ public function onUserAfterDelete($user, $success, $msg): void
{
// Do nothing.
}
+
+ // Delete Multi-factor Authentication user profile records
+ $profileKey = 'mfa.%';
+ $query = $this->db->getQuery(true)
+ ->delete($this->db->quoteName('#__user_profiles'))
+ ->where($this->db->quoteName('user_id') . ' = :userId')
+ ->where($this->db->quoteName('profile_key') . ' LIKE :profileKey')
+ ->bind(':userId', $userId, ParameterType::INTEGER)
+ ->bind(':profileKey', $profileKey, ParameterType::STRING);
+
+ try
+ {
+ $this->db->setQuery($query)->execute();
+ }
+ catch (Exception $e)
+ {
+ // Do nothing
+ }
+
+ // Delete Multi-factor Authentication records
+ $query = $this->db->getQuery(true)
+ ->delete($this->db->qn('#__user_mfa'))
+ ->where($this->db->quoteName('user_id') . ' = :userId')
+ ->bind(':userId', $userId, ParameterType::INTEGER);
+
+ try
+ {
+ $this->db->setQuery($query)->execute();
+ }
+ catch (Exception $e)
+ {
+ // Do nothing
+ }
}
/**
@@ -388,6 +421,74 @@ public function onUserLogout($user, $options = [])
return true;
}
+ /**
+ * Hooks on the Joomla! login event. Detects silent logins and disables the Multi-Factor
+ * Authentication page in this case.
+ *
+ * Moreover, it will save the redirection URL and the Captive URL which is necessary in Joomla 4. You see, in Joomla
+ * 4 having unified sessions turned on makes the backend login redirect you to the frontend of the site AFTER
+ * logging in, something which would cause the Captive page to appear in the frontend and redirect you to the public
+ * frontend homepage after successfully passing the Two Step verification process.
+ *
+ * @param array $options Passed by Joomla. user: a User object; responseType: string, authentication response type.
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function onUserAfterLogin(array $options): void
+ {
+ if (!($this->app->isClient('administrator')) && !($this->app->isClient('site')))
+ {
+ return;
+ }
+
+ $this->disableMfaOnSilentLogin($options);
+ }
+
+ /**
+ * Detect silent logins and disable MFA if the relevant com_users option is set.
+ *
+ * @param array $options The array of login options and login result
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ private function disableMfaOnSilentLogin(array $options): void
+ {
+ $userParams = ComponentHelper::getParams('com_users');
+ $doMfaOnSilentLogin = $userParams->get('mfaonsilent', 0) == 1;
+
+ // Should I show MFA even on silent logins? Default: 1 (yes, show)
+ if ($doMfaOnSilentLogin)
+ {
+ return;
+ }
+
+ // Make sure I have a valid user
+ /** @var User $user */
+ $user = $options['user'];
+
+ if (!is_object($user) || !($user instanceof User) || $user->guest)
+ {
+ return;
+ }
+
+ $silentResponseTypes = array_map(
+ 'trim',
+ explode(',', $userParams->get('silentresponses', '') ?: '')
+ );
+ $silentResponseTypes = $silentResponseTypes ?: ['cookie', 'passwordless'];
+
+ // Only proceed if this is not a silent login
+ if (!in_array(strtolower($options['responseType'] ?? ''), $silentResponseTypes))
+ {
+ return;
+ }
+
+ // Set the flag indicating that MFA is already checked.
+ $this->app->getSession()->set('com_users.mfa_checked', 1);
+ }
+
/**
* This method will return a user object
*
diff --git a/templates/cassiopeia/offline.php b/templates/cassiopeia/offline.php
index 41422a162768a..845f6006e41e3 100644
--- a/templates/cassiopeia/offline.php
+++ b/templates/cassiopeia/offline.php
@@ -18,7 +18,6 @@
/** @var Joomla\CMS\Document\HtmlDocument $this */
-$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
$extraButtons = AuthenticationHelper::getLoginButtons('form-login');
$app = Factory::getApplication();
$wa = $this->getWebAssetManager();
@@ -151,11 +150,6 @@
- 1) : ?>
-
-
-
-
registerAndUseStyle('template.system.general', 'media/system/css/system-site-general.css');
-$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
-
?>
@@ -68,12 +66,6 @@
- 1) : ?>
-
-
-
-
-