diff --git a/administrator/components/com_admin/script.php b/administrator/components/com_admin/script.php index 06ef940d6c7e9..4598024a5d017 100644 --- a/administrator/components/com_admin/script.php +++ b/administrator/components/com_admin/script.php @@ -7809,6 +7809,8 @@ public function deleteUnexistingFiles($dryRun = false, $suppressOutput = false) '/libraries/vendor/maximebf/debugbar/build', // From 4.1 to 4.2.0 '/libraries/vendor/nyholm/psr7/doc', + '/plugins/twofactorauth/totp', + '/plugins/twofactorauth/yubikey', ); $status['files_checked'] = $files; diff --git a/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-05-15.sql b/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-05-15.sql new file mode 100644 index 0000000000000..256b940a3639b --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-05-15.sql @@ -0,0 +1,57 @@ +-- +-- Create the new table for MFA +-- +CREATE TABLE IF NOT EXISTS `#__user_mfa` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `title` varchar(255) NOT NULL DEFAULT '', + `method` varchar(100) NOT NULL, + `default` tinyint NOT NULL DEFAULT 0, + `options` mediumtext NOT NULL, + `created_on` datetime NOT NULL, + `last_used` datetime, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci COMMENT='Multi-factor Authentication settings'; + +-- +-- Remove obsolete postinstallation message +-- +DELETE FROM `#__postinstall_messages` WHERE `condition_file` = 'site://plugins/twofactorauth/totp/postinstall/actions.php'; + +-- +-- Add new MFA plugins +-- +INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`) VALUES +(0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 1, 0), +(0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 2, 0), +(0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 3, 0), +(0, 'plg_multifactorauth_email', 'plugin', 'email', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 4, 0), +(0, 'plg_multifactorauth_fixed', 'plugin', 'fixed', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 5, 0); + +-- +-- Update MFA plugins' publish status +-- +UPDATE `#__extensions` AS `a` + INNER JOIN `#__extensions` AS `b` on `a`.`element` = `b`.`element` +SET `a`.enabled = `b`.enabled +WHERE `a`.folder = 'multifactorauth' + AND `b`.folder = 'twofactorauth'; + +-- +-- Remove legacy TFA plugins +-- +DELETE FROM `#__extensions` +WHERE `type` = 'plugin' AND `folder` = 'twofactorauth' AND `element` IN ('totp', 'yubikey'); + +-- +-- Add post-installation message +-- +INSERT IGNORE INTO `#__postinstall_messages` (`extension_id`, `title_key`, `description_key`, `action_key`, `language_extension`, `language_client_id`, `type`, `action_file`, `action`, `condition_file`, `condition_method`, `version_introduced`, `enabled`) +SELECT `extension_id`, 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_TITLE', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_BODY', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_ACTION', 'com_users', 1, 'action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_condition', '4.2.0', 1 FROM `#__extensions` WHERE `name` = 'files_joomla'; + +-- +-- Create a mail template for plg_multifactorauth_email +-- +INSERT IGNORE INTO `#__mail_templates` (`template_id`, `extension`, `language`, `subject`, `body`, `htmlbody`, `attachments`, `params`) VALUES +('plg_multifactorauth_email.mail', 'plg_multifactorauth_email', '', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY', '', '', '{"tags":["code","sitename","siteurl","username","email","fullname"]}'); diff --git a/administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-05-15.sql b/administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-05-15.sql new file mode 100644 index 0000000000000..0c06ffb925105 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-05-15.sql @@ -0,0 +1,63 @@ +-- +-- Create the new table for MFA +-- +CREATE TABLE IF NOT EXISTS "#__user_mfa" ( + "id" serial NOT NULL, + "user_id" bigint NOT NULL, + "title" varchar(255) DEFAULT '' NOT NULL, + "method" varchar(100) NOT NULL, + "default" smallint DEFAULT 0 NOT NULL, + "options" text NOT NULL, + "created_on" timestamp without time zone NOT NULL, + "last_used" timestamp without time zone, + PRIMARY KEY ("id") +); + +CREATE INDEX "#__user_mfa_idx_user_id" ON "#__user_mfa" ("user_id") /** CAN FAIL **/; + +COMMENT ON TABLE "#__user_mfa" IS 'Multi-factor Authentication settings'; + +-- +-- Remove obsolete postinstallation message +-- +DELETE FROM "#__postinstall_messages" WHERE "condition_file" = 'site://plugins/twofactorauth/totp/postinstall/actions.php'; + +-- +-- Add new MFA plugins +-- +INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") VALUES +(0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 1, 0), +(0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 2, 0), +(0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 3, 0), +(0, 'plg_multifactorauth_email', 'plugin', 'email', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 4, 0), +(0, 'plg_multifactorauth_fixed', 'plugin', 'fixed', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 5, 0); + +-- +-- Update MFA plugins' publish status +-- +UPDATE "#__extensions" AS "a" +SET "enabled" = "b"."enabled" +FROM "#__extensions" AS "b" +WHERE "a"."element" = "b"."element" + AND "a"."folder" = 'multifactorauth' + AND "b"."folder" = 'twofactorauth'; + +-- +-- Remove legacy TFA plugins +-- +DELETE FROM "#__extensions" +WHERE "type" = 'plugin' AND "folder" = 'twofactorauth' AND "element" IN ('totp', 'yubikey'); + +-- +-- Add post-installation message +-- +INSERT INTO "#__postinstall_messages" ("extension_id", "title_key", "description_key", "action_key", "language_extension", "language_client_id", "type", "action_file", "action", "condition_file", "condition_method", "version_introduced", "enabled") +SELECT "extension_id", 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_TITLE', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_BODY', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_ACTION', 'com_users', 1, 'action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_condition', '4.2.0', 1 FROM "#__extensions" WHERE "name" = 'files_joomla' +ON CONFLICT DO NOTHING; + +-- +-- Create a mail template for plg_multifactorauth_email +-- +INSERT INTO "#__mail_templates" ("template_id", "extension", "language", "subject", "body", "htmlbody", "attachments", "params") VALUES +('plg_multifactorauth_email.mail', 'plg_multifactorauth_email', '', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY', '', '', '{"tags":["code","sitename","siteurl","username","email","fullname"]}') +ON CONFLICT DO NOTHING; diff --git a/administrator/components/com_joomlaupdate/src/Model/UpdateModel.php b/administrator/components/com_joomlaupdate/src/Model/UpdateModel.php index 83ea010cdb88a..eb56123114a48 100644 --- a/administrator/components/com_joomlaupdate/src/Model/UpdateModel.php +++ b/administrator/components/com_joomlaupdate/src/Model/UpdateModel.php @@ -1484,7 +1484,7 @@ public function getNonCoreExtensions() * * @since 3.10.0 */ - public function getNonCorePlugins($folderFilter = ['system','user','authentication','actionlog','twofactorauth']) + public function getNonCorePlugins($folderFilter = ['system','user','authentication','actionlog','multifactorauth']) { $db = $this->getDbo(); $query = $db->getQuery(true); diff --git a/administrator/components/com_joomlaupdate/tmpl/update/finaliseconfirm.php b/administrator/components/com_joomlaupdate/tmpl/update/finaliseconfirm.php index 2c66e1245ce52..32cf3dde1cb68 100644 --- a/administrator/components/com_joomlaupdate/tmpl/update/finaliseconfirm.php +++ b/administrator/components/com_joomlaupdate/tmpl/update/finaliseconfirm.php @@ -19,8 +19,6 @@ $wa = $this->document->getWebAssetManager(); $wa->useScript('keepalive'); -$twofactormethods = AuthenticationHelper::getTwoFactorMethods(); - ?>
@@ -63,21 +61,6 @@
- 1) : ?> -
-
-
- - - - - -
-
-
-
diff --git a/administrator/components/com_joomlaupdate/tmpl/upload/captive.php b/administrator/components/com_joomlaupdate/tmpl/upload/captive.php index 86af707b21681..b3c030d177478 100644 --- a/administrator/components/com_joomlaupdate/tmpl/upload/captive.php +++ b/administrator/components/com_joomlaupdate/tmpl/upload/captive.php @@ -15,8 +15,6 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; -$twofactormethods = AuthenticationHelper::getTwoFactorMethods(); - /** @var Joomla\CMS\WebAsset\WebAssetManager $wa */ $wa = $this->document->getWebAssetManager(); $wa->useScript('core') @@ -67,21 +65,6 @@
- 1) : ?> -
-
-
- - - - - -
-
-
-
diff --git a/administrator/components/com_users/config.xml b/administrator/components/com_users/config.xml index bd592e9b4d0d9..a2a22141d5392 100644 --- a/administrator/components/com_users/config.xml +++ b/administrator/components/com_users/config.xml @@ -1,6 +1,7 @@ +
@@ -109,33 +110,6 @@ - - - - - - - - - -
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/administrator/components/com_users/forms/filter_users.xml b/administrator/components/com_users/forms/filter_users.xml index 104fa32b161f4..4bd61774d8e89 100644 --- a/administrator/components/com_users/forms/filter_users.xml +++ b/administrator/components/com_users/forms/filter_users.xml @@ -17,6 +17,16 @@ > + + + + + - diff --git a/administrator/components/com_users/postinstall/multifactorauth.php b/administrator/components/com_users/postinstall/multifactorauth.php new file mode 100644 index 0000000000000..38983acaeeb20 --- /dev/null +++ b/administrator/components/com_users/postinstall/multifactorauth.php @@ -0,0 +1,57 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Database\DatabaseDriver; +use Joomla\Database\ParameterType; + +/** + * Post-installation message about the new Multi-factor Authentication: condition check. + * + * Returns true if neither of the two new core MFA plugins are enabled. + * + * @return boolean + * @since __DEPLOY_VERSION__ + */ +// phpcs:ignore +function com_users_postinstall_mfa_condition(): bool +{ + return count(PluginHelper::getPlugin('multifactorauth')) < 1; +} + +/** + * Post-installation message about the new Multi-factor Authentication: action. + * + * Enables the core MFA plugins. + * + * @return void + * @since __DEPLOY_VERSION__ + */ +// phpcs:ignore +function com_users_postinstall_mfa_action(): void +{ + /** @var DatabaseDriver $db */ + $db = Factory::getContainer()->get('DatabaseDriver'); + $coreMfaPlugins = ['email', 'totp', 'webauthn', 'yubikey']; + + $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('multifactorauth')) + ->whereIn($db->quoteName('element'), $coreMfaPlugins, ParameterType::STRING); + $db->setQuery($query); + $db->execute(); + + $url = 'index.php?option=com_plugins&filter[folder]=multifactorauth'; + Factory::getApplication()->redirect($url); +} diff --git a/administrator/components/com_users/src/Controller/CallbackController.php b/administrator/components/com_users/src/Controller/CallbackController.php new file mode 100644 index 0000000000000..1951e52f19053 --- /dev/null +++ b/administrator/components/com_users/src/Controller/CallbackController.php @@ -0,0 +1,78 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Controller; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Event\MultiFactor\Callback; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Input\Input; +use RuntimeException; + +/** + * Multi-factor Authentication plugins' AJAX callback controller + * + * @since __DEPLOY_VERSION__ + */ +class CallbackController extends BaseController +{ + /** + * Public constructor + * + * @param array $config Plugin configuration + * @param MVCFactoryInterface|null $factory MVC Factory for the com_users component + * @param CMSApplication|null $app CMS application object + * @param Input|null $input Joomla CMS input object + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(array $config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null) + { + parent::__construct($config, $factory, $app, $input); + + $this->registerDefaultTask('callback'); + } + + /** + * Implement a callback feature, typically used for OAuth2 authentication + * + * @param bool $cachable Can this view be cached + * @param array|bool $urlparams An array of safe url parameters and their variable types, for valid values see + * {@link JFilterInput::clean()}. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function callback($cachable = false, $urlparams = false): void + { + $app = $this->app; + + // Get the Method and make sure it's non-empty + $method = $this->input->getCmd('method', ''); + + if (empty($method)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + PluginHelper::importPlugin('multifactorauth'); + + $event = new Callback($method); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + /** + * The first plugin to handle the request should either redirect or close the application. If we are still here + * no plugin handled the request successfully. Show an error. + */ + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } +} diff --git a/administrator/components/com_users/src/Controller/CaptiveController.php b/administrator/components/com_users/src/Controller/CaptiveController.php new file mode 100644 index 0000000000000..5b20b962891f0 --- /dev/null +++ b/administrator/components/com_users/src/Controller/CaptiveController.php @@ -0,0 +1,239 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Controller; + +use Exception; +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Date\Date; +use Joomla\CMS\Event\GenericEvent; +use Joomla\CMS\Event\MultiFactor\NotifyActionLog; +use Joomla\CMS\Event\MultiFactor\Validate; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Model\BackupcodesModel; +use Joomla\Component\Users\Administrator\Model\CaptiveModel; +use Joomla\Input\Input; +use ReflectionObject; +use RuntimeException; + +/** + * Captive Multi-factor Authentication page controller + * + * @since __DEPLOY_VERSION__ + */ +class CaptiveController extends BaseController +{ + /** + * Public constructor + * + * @param array $config Plugin configuration + * @param MVCFactoryInterface|null $factory MVC Factory for the com_users component + * @param CMSApplication|null $app CMS application object + * @param Input|null $input Joomla CMS input object + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(array $config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null) + { + parent::__construct($config, $factory, $app, $input); + + $this->registerTask('captive', 'display'); + } + + /** + * Displays the captive login page + * + * @param boolean $cachable Ignored. This page is never cached. + * @param boolean|array $urlparams Ignored. This page is never cached. + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function display($cachable = false, $urlparams = false): void + { + $user = $this->app->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + // Only allow logged in Users + if ($user->guest) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + // Get the view object + $viewLayout = $this->input->get('layout', 'default', 'string'); + $view = $this->getView('Captive', 'html', '', + [ + 'base_path' => $this->basePath, + 'layout' => $viewLayout, + ] + ); + + $view->document = $this->app->getDocument(); + + // If we're already logged in go to the site's home page + if ((int) $this->app->getSession()->get('com_users.mfa_checked', 0) === 1) + { + $url = Route::_('index.php?option=com_users&task=methods.display', false); + + $this->setRedirect($url); + } + + // Pass the model to the view + /** @var CaptiveModel $model */ + $model = $this->getModel('Captive'); + $view->setModel($model, true); + + /** @var BackupcodesModel $codesModel */ + $codesModel = $this->getModel('Backupcodes'); + $view->setModel($codesModel, false); + + try + { + // Suppress all modules on the page except those explicitly allowed + $model->suppressAllModules(); + } + catch (Exception $e) + { + // If we can't kill the modules we can still survive. + } + + // Pass the MFA record ID to the model + $recordId = $this->input->getInt('record_id', null); + $model->setState('record_id', $recordId); + + // Do not go through $this->display() because it overrides the model. + $view->display(); + } + + /** + * Validate the MFA code entered by the user + * + * @param bool $cachable Ignored. This page is never cached. + * @param array $urlparameters Ignored. This page is never cached. + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function validate($cachable = false, $urlparameters = []) + { + // CSRF Check + $this->checkToken($this->input->getMethod()); + + // Get the MFA parameters from the request + $recordId = $this->input->getInt('record_id', null); + $code = $this->input->get('code', null, 'raw'); + /** @var CaptiveModel $model */ + $model = $this->getModel('Captive'); + + // Validate the MFA record + $model->setState('record_id', $recordId); + $record = $model->getRecord(); + + if (empty($record)) + { + $event = new NotifyActionLog('onComUsersCaptiveValidateInvalidMethod'); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + throw new RuntimeException(Text::_('COM_USERS_MFA_INVALID_METHOD'), 500); + } + + // Validate the code + $user = $this->app->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + $event = new Validate($record, $user, $code); + $results = $this->app + ->getDispatcher() + ->dispatch($event->getName(), $event) + ->getArgument('result', []); + + $isValidCode = false; + + if ($record->method === 'backupcodes') + { + /** @var BackupcodesModel $codesModel */ + $codesModel = $this->getModel('Backupcodes'); + $results = [$codesModel->isBackupCode($code, $user)]; + /** + * This is required! Do not remove! + * + * There is a store() call below. It saves the in-memory MFA record to the database. That includes the + * options key which contains the configuration of the Method. For backup codes, these are the actual codes + * you can use. When we check for a backup code validity we also "burn" it, i.e. we remove it from the + * options table and save that to the database. However, this DOES NOT update the $record here. Therefore + * the call to saveRecord() would overwrite the database contents with a record that _includes_ the backup + * code we had just burned. As a result the single use backup codes end up being multiple use. + * + * By doing a getRecord() here, right after we have "burned" any correct backup codes, we resolve this + * issue. The loaded record will reflect the database contents where the options DO NOT include the code we + * just used. Therefore the call to store() will result in the correct database state, i.e. the used backup + * code being removed. + */ + $record = $model->getRecord(); + } + + $isValidCode = array_reduce( + $results, + function (bool $carry, $result) + { + return $carry || boolval($result); + }, + false + ); + + if (!$isValidCode) + { + // The code is wrong. Display an error and go back. + $captiveURL = Route::_('index.php?option=com_users&view=captive&record_id=' . $recordId, false); + $message = Text::_('COM_USERS_MFA_INVALID_CODE'); + $this->setRedirect($captiveURL, $message, 'error'); + + $event = new NotifyActionLog('onComUsersCaptiveValidateFailed', [$record->title]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + return; + } + + // Update the Last Used, UA and IP columns + $jNow = Date::getInstance(); + + // phpcs:ignore + $record->last_used = $jNow->toSql(); + $record->store(); + + // Flag the user as fully logged in + $session = $this->app->getSession(); + $session->set('com_users.mfa_checked', 1); + $session->set('com_users.mandatory_mfa_setup', 0); + + // Get the return URL stored by the plugin in the session + $returnUrl = $session->get('com_users.return_url', ''); + + // If the return URL is not set or not internal to this site redirect to the site's front page + if (empty($returnUrl) || !Uri::isInternal($returnUrl)) + { + $returnUrl = Uri::base(); + } + + $this->setRedirect($returnUrl); + + $event = new NotifyActionLog('onComUsersCaptiveValidateSuccess', [$record->title]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + } +} diff --git a/administrator/components/com_users/src/Controller/DisplayController.php b/administrator/components/com_users/src/Controller/DisplayController.php index 03bd449a13ceb..018bbde7b6782 100644 --- a/administrator/components/com_users/src/Controller/DisplayController.php +++ b/administrator/components/com_users/src/Controller/DisplayController.php @@ -131,6 +131,13 @@ public function display($cachable = false, $urlparams = array()) return false; } + elseif (in_array($view, ['captive', 'callback', 'methods', 'method'])) + { + $controller = $this->factory->createController($view, 'Administrator', [], $this->app, $this->input); + $task = $this->input->get('task', ''); + + return $controller->execute($task); + } return parent::display($cachable, $urlparams); } diff --git a/administrator/components/com_users/src/Controller/MethodController.php b/administrator/components/com_users/src/Controller/MethodController.php new file mode 100644 index 0000000000000..d6af9859e97a8 --- /dev/null +++ b/administrator/components/com_users/src/Controller/MethodController.php @@ -0,0 +1,514 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Controller; + +use Exception; +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Event\GenericEvent; +use Joomla\CMS\Event\MultiFactor\NotifyActionLog; +use Joomla\CMS\Event\MultiFactor\SaveSetup; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController as BaseControllerAlias; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Router\Route; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Component\Users\Administrator\Model\BackupcodesModel; +use Joomla\Component\Users\Administrator\Model\MethodModel; +use Joomla\Component\Users\Administrator\Table\MfaTable; +use Joomla\Input\Input; +use RuntimeException; + +/** + * Multi-factor Authentication method controller + * + * @since __DEPLOY_VERSION__ + */ +class MethodController extends BaseControllerAlias +{ + /** + * Public constructor + * + * @param array $config Plugin configuration + * @param MVCFactoryInterface|null $factory MVC Factory for the com_users component + * @param CMSApplication|null $app CMS application object + * @param Input|null $input Joomla CMS input object + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(array $config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null) + { + // We have to tell Joomla what is the name of the view, otherwise it defaults to the name of the *component*. + $config['default_view'] = 'method'; + $config['default_task'] = 'add'; + + parent::__construct($config, $factory, $app, $input); + } + + /** + * Execute a task by triggering a Method in the derived class. + * + * @param string $task The task to perform. If no matching task is found, the '__default' task is executed, if + * defined. + * + * @return mixed The value returned by the called Method. + * + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function execute($task) + { + if (empty($task) || $task === 'display') + { + $task = 'add'; + } + + return parent::execute($task); + } + + /** + * Add a new MFA Method + * + * @param boolean $cachable Ignored. This page is never cached. + * @param boolean|array $urlparams Ignored. This page is never cached. + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function add($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + + $this->assertCanEdit($user); + + // Also make sure the Method really does exist + $method = $this->input->getCmd('method'); + $this->assertMethodExists($method); + + /** @var MethodModel $model */ + $model = $this->getModel('Method'); + $model->setState('method', $method); + + // Pass the return URL to the view + $returnURL = $this->input->getBase64('returnurl'); + $viewLayout = $this->input->get('layout', 'default', 'string'); + $view = $this->getView('Method', 'html'); + $view->setLayout($viewLayout); + $view->returnURL = $returnURL; + $view->user = $user; + $view->document = $this->app->getDocument(); + + $view->setModel($model, true); + + $event = new NotifyActionLog('onComUsersControllerMethodBeforeAdd', [$user, $method]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + $view->display(); + } + + /** + * Edit an existing MFA Method + * + * @param boolean $cachable Ignored. This page is never cached. + * @param boolean|array $urlparams Ignored. This page is never cached. + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function edit($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + + $this->assertCanEdit($user); + + // Also make sure the Method really does exist + $id = $this->input->getInt('id'); + $record = $this->assertValidRecordId($id, $user); + + if ($id <= 0) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + /** @var MethodModel $model */ + $model = $this->getModel('Method'); + $model->setState('id', $id); + + // Pass the return URL to the view + $returnURL = $this->input->getBase64('returnurl'); + $viewLayout = $this->input->get('layout', 'default', 'string'); + $view = $this->getView('Method', 'html'); + $view->setLayout($viewLayout); + $view->returnURL = $returnURL; + $view->user = $user; + $view->document = $this->app->getDocument(); + + $view->setModel($model, true); + + $event = new NotifyActionLog('onComUsersControllerMethodBeforeEdit', [$id, $user]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + $view->display(); + } + + /** + * Regenerate backup codes + * + * @param boolean $cachable Ignored. This page is never cached. + * @param boolean|array $urlparams Ignored. This page is never cached. + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function regenerateBackupCodes($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + $this->checkToken($this->input->getMethod()); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + $this->assertCanEdit($user); + + /** @var BackupcodesModel $model */ + $model = $this->getModel('Backupcodes'); + $model->regenerateBackupCodes($user); + + $backupCodesRecord = $model->getBackupCodesRecord($user); + + // Redirect + $redirectUrl = 'index.php?option=com_users&task=method.edit&user_id=' . $userId . '&id=' . $backupCodesRecord->id; + $returnURL = $this->input->getBase64('returnurl'); + + if (!empty($returnURL)) + { + $redirectUrl .= '&returnurl=' . $returnURL; + } + + $this->setRedirect(Route::_($redirectUrl, false)); + + $event = new NotifyActionLog('onComUsersControllerMethodAfterRegenerateBackupCodes'); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + } + + /** + * Delete an existing MFA Method + * + * @param boolean $cachable Ignored. This page is never cached. + * @param boolean|array $urlparams Ignored. This page is never cached. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function delete($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + $this->checkToken($this->input->getMethod()); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + $this->assertCanDelete($user); + + // Also make sure the Method really does exist + $id = $this->input->getInt('id'); + $record = $this->assertValidRecordId($id, $user); + + if ($id <= 0) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + $type = null; + $message = null; + + $event = new NotifyActionLog('onComUsersControllerMethodBeforeDelete', [$id, $user]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + try + { + $record->delete(); + } + catch (Exception $e) + { + $message = $e->getMessage(); + $type = 'error'; + } + + // Redirect + $url = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false); + $returnURL = $this->input->getBase64('returnurl'); + + if (!empty($returnURL)) + { + $url = base64_decode($returnURL); + } + + $this->setRedirect($url, $message, $type); + } + + /** + * Save the MFA Method + * + * @param boolean $cachable Ignored. This page is never cached. + * @param boolean|array $urlparams Ignored. This page is never cached. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function save($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + $this->checkToken($this->input->getMethod()); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + $this->assertCanEdit($user); + + // Redirect + $url = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false); + $returnURL = $this->input->getBase64('returnurl'); + + if (!empty($returnURL)) + { + $url = base64_decode($returnURL); + } + + // The record must either be new (ID zero) or exist + $id = $this->input->getInt('id', 0); + $record = $this->assertValidRecordId($id, $user); + + // If it's a new record we need to read the Method from the request and update the (not yet created) record. + if ($record->id == 0) + { + $methodName = $this->input->getCmd('method'); + $this->assertMethodExists($methodName); + $record->method = $methodName; + } + + /** @var MethodModel $model */ + $model = $this->getModel('Method'); + + // Ask the plugin to validate the input by calling onUserMultifactorSaveSetup + $result = []; + $input = $this->app->input; + + $event = new NotifyActionLog('onComUsersControllerMethodBeforeSave', [$id, $user]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + try + { + $event = new SaveSetup($record, $input); + $pluginResults = $this->app + ->getDispatcher() + ->dispatch($event->getName(), $event) + ->getArgument('result', []); + + foreach ($pluginResults as $pluginResult) + { + $result = array_merge($result, $pluginResult); + } + } + catch (RuntimeException $e) + { + // Go back to the edit page + $nonSefUrl = 'index.php?option=com_users&task=method.'; + + if ($id) + { + $nonSefUrl .= 'edit&id=' . (int) $id; + } + else + { + $nonSefUrl .= 'add&method=' . $record->method; + } + + $nonSefUrl .= '&user_id=' . $userId; + + if (!empty($returnURL)) + { + $nonSefUrl .= '&returnurl=' . urlencode($returnURL); + } + + $url = Route::_($nonSefUrl, false); + $this->setRedirect($url, $e->getMessage(), 'error'); + + return; + } + + // Update the record's options with the plugin response + $title = $this->input->getString('title', null); + $title = trim($title); + + if (empty($title)) + { + $method = $model->getMethod($record->method); + $title = $method['display']; + } + + // Update the record's "default" flag + $default = $this->input->getBool('default', false); + $record->title = $title; + $record->options = $result; + $record->default = $default ? 1 : 0; + + // Ask the model to save the record + $saved = $record->store(); + + if (!$saved) + { + // Go back to the edit page + $nonSefUrl = 'index.php?option=com_users&task=method.'; + + if ($id) + { + $nonSefUrl .= 'edit&id=' . (int) $id; + } + else + { + $nonSefUrl .= 'add'; + } + + $nonSefUrl .= '&user_id=' . $userId; + + if (!empty($returnURL)) + { + $nonSefUrl .= '&returnurl=' . urlencode($returnURL); + } + + $url = Route::_($nonSefUrl, false); + $this->setRedirect($url, $record->getError(), 'error'); + + return; + } + + $this->setRedirect($url); + } + + /** + * Assert that the provided ID is a valid record identified for the given user + * + * @param int $id Record ID to check + * @param User|null $user User record. Null to use current user. + * + * @return MfaTable The loaded record + * @since __DEPLOY_VERSION__ + */ + private function assertValidRecordId($id, ?User $user = null): MfaTable + { + if (is_null($user)) + { + $user = $this->app->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + /** @var MethodModel $model */ + $model = $this->getModel('Method'); + + $model->setState('id', $id); + + $record = $model->getRecord($user); + + // phpcs:ignore + if (is_null($record) || ($record->id != $id) || ($record->user_id != $user->id)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + return $record; + } + + /** + * Assert that the user can add / edit MFA methods. + * + * @param User|null $user User record. Null to use current user. + * + * @return void + * @throws RuntimeException|Exception + * @since __DEPLOY_VERSION__ + */ + private function assertCanEdit(?User $user = null): void + { + if (!MfaHelper::canAddEditMethod($user)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + } + + /** + * Assert that the user can delete MFA records / disable MFA. + * + * @param User|null $user User record. Null to use current user. + * + * @return void + * @throws RuntimeException|Exception + * @since __DEPLOY_VERSION__ + */ + private function assertCanDelete(?User $user = null): void + { + if (!MfaHelper::canDeleteMethod($user)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + } + + /** + * Assert that the specified MFA Method exists, is activated and enabled for the current user + * + * @param string|null $method The Method to check + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function assertMethodExists(?string $method): void + { + /** @var MethodModel $model */ + $model = $this->getModel('Method'); + + if (empty($method) || !$model->methodExists($method)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + } + + /** + * Assert that there is a logged in user. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function assertLoggedInUser(): void + { + $user = $this->app->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + if ($user->guest) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + } +} diff --git a/administrator/components/com_users/src/Controller/MethodsController.php b/administrator/components/com_users/src/Controller/MethodsController.php new file mode 100644 index 0000000000000..94eb0605ec422 --- /dev/null +++ b/administrator/components/com_users/src/Controller/MethodsController.php @@ -0,0 +1,221 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Controller; + +use Exception; +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Event\GenericEvent; +use Joomla\CMS\Event\MultiFactor\NotifyActionLog; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Component\Users\Administrator\Model\MethodsModel; +use Joomla\Input\Input; +use ReflectionObject; +use RuntimeException; + +/** + * Multi-factor Authentication methods selection and management controller + * + * @since __DEPLOY_VERSION__ + */ +class MethodsController extends BaseController +{ + /** + * Public constructor + * + * @param array $config Plugin configuration + * @param MVCFactoryInterface|null $factory MVC Factory for the com_users component + * @param CMSApplication|null $app CMS application object + * @param Input|null $input Joomla CMS input object + * + * @since __DEPLOY_VERSION__ + */ + public function __construct($config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null) + { + // We have to tell Joomla what is the name of the view, otherwise it defaults to the name of the *component*. + $config['default_view'] = 'Methods'; + + parent::__construct($config, $factory, $app, $input); + } + + /** + * Disable Multi-factor Authentication for the current user + * + * @param bool $cachable Can this view be cached + * @param array $urlparams An array of safe url parameters and their variable types, for valid values see + * {@link JFilterInput::clean()}. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function disable($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + $this->checkToken($this->input->getMethod()); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = ($userId === null) + ? $this->app->getIdentity() + : Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + $user = $user ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + if (!MfaHelper::canDeleteMethod($user)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + // Delete all MFA Methods for the user + /** @var MethodsModel $model */ + $model = $this->getModel('Methods'); + $type = null; + $message = null; + + $event = new NotifyActionLog('onComUsersControllerMethodsBeforeDisable', [$user]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + try + { + $model->deleteAll($user); + } + catch (Exception $e) + { + $message = $e->getMessage(); + $type = 'error'; + } + + // Redirect + // phpcs:ignore + $url = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false); + $returnURL = $this->input->getBase64('returnurl'); + + if (!empty($returnURL)) + { + $url = base64_decode($returnURL); + } + + $this->setRedirect($url, $message, $type); + } + + /** + * List all available Multi-factor Authentication Methods available and guide the user to setting them up + * + * @param bool $cachable Can this view be cached + * @param array $urlparams An array of safe url parameters and their variable types, for valid values see + * {@link JFilterInput::clean()}. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function display($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = ($userId === null) + ? $this->app->getIdentity() + : Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + $user = $user ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + if (!MfaHelper::canShowConfigurationInterface($user)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + $returnURL = $this->input->getBase64('returnurl'); + $viewLayout = $this->input->get('layout', 'default', 'string'); + $view = $this->getView('Methods', 'html'); + $view->setLayout($viewLayout); + $view->returnURL = $returnURL; + $view->user = $user; + $view->document = $this->app->getDocument(); + + $methodsModel = $this->getModel('Methods'); + $view->setModel($methodsModel, true); + + $backupCodesModel = $this->getModel('Backupcodes'); + $view->setModel($backupCodesModel, false); + + $view->display(); + } + + /** + * Disable Multi-factor Authentication for the current user + * + * @param bool $cachable Can this view be cached + * @param array $urlparams An array of safe url parameters and their variable types, for valid values see + * {@link JFilterInput::clean()}. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function doNotShowThisAgain($cachable = false, $urlparams = []): void + { + $this->assertLoggedInUser(); + + $this->checkToken($this->input->getMethod()); + + // Make sure I am allowed to edit the specified user + $userId = $this->input->getInt('user_id', null); + $user = ($userId === null) + ? $this->app->getIdentity() + : Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + $user = $user ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + if (!MfaHelper::canAddEditMethod($user)) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + $event = new NotifyActionLog('onComUsersControllerMethodsBeforeDoNotShowThisAgain', [$user]); + $this->app->getDispatcher()->dispatch($event->getName(), $event); + + /** @var MethodsModel $model */ + $model = $this->getModel('Methods'); + $model->setFlag($user, true); + + // Redirect + $url = Uri::base(); + $returnURL = $this->input->getBase64('returnurl'); + + if (!empty($returnURL)) + { + $url = base64_decode($returnURL); + } + + $this->setRedirect($url); + } + + /** + * Assert that there is a user currently logged in + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function assertLoggedInUser(): void + { + $user = $this->app->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + if ($user->guest) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + } +} diff --git a/administrator/components/com_users/src/DataShape/CaptiveRenderOptions.php b/administrator/components/com_users/src/DataShape/CaptiveRenderOptions.php new file mode 100644 index 0000000000000..1da1399d88b75 --- /dev/null +++ b/administrator/components/com_users/src/DataShape/CaptiveRenderOptions.php @@ -0,0 +1,210 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\DataShape; + +use InvalidArgumentException; + +/** + * @property string $pre_message Custom HTML to display above the MFA form + * @property string $field_type How to render the MFA code field. "input" or "custom". + * @property string $input_type The type attribute for the HTML input box. Typically "text" or "password". + * @property string $placeholder Placeholder text for the HTML input box. Leave empty if you don't need it. + * @property string $label Label to show above the HTML input box. Leave empty if you don't need it. + * @property string $html Custom HTML. Only used when field_type = custom. + * @property string $post_message Custom HTML to display below the MFA form + * @property bool $hide_submit Should I hide the default Submit button? + * @property bool $allowEntryBatching Is this method validating against all configured authenticators of this type? + * @property string $help_url URL for help content + * + * @since __DEPLOY_VERSION__ + */ +class CaptiveRenderOptions extends DataShapeObject +{ + /** + * Display a standard HTML5 input field. Use the input_type, placeholder and label properties to set it up. + * + * @since __DEPLOY_VERSION__ + */ + public const FIELD_INPUT = 'input'; + + /** + * Display a custom HTML document. Use the html property to set it up. + * + * @since __DEPLOY_VERSION__ + */ + public const FIELD_CUSTOM = 'custom'; + + /** + * Custom HTML to display above the MFA form + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $pre_message = ''; + + /** + * How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML) + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $field_type = 'input'; + + /** + * The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $input_type = ''; + + /** + * Attributes other than type and id which will be added to the HTML input box. + * + * @var array + * @@since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $input_attributes = []; + + /** + * Placeholder text for the HTML input box. Leave empty if you don't need it. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $placeholder = ''; + + /** + * Label to show above the HTML input box. Leave empty if you don't need it. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $label = ''; + + /** + * Custom HTML. Only used when field_type = custom. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $html = ''; + + /** + * Custom HTML to display below the MFA form + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $post_message = ''; + + /** + * Should I hide the default Submit button? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $hide_submit = false; + + /** + * Additional CSS classes for the submit button (apply the MFA setup) + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $submit_class = ''; + + /** + * Icon class to use for the submit button + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $submit_icon = 'icon icon-rightarrow icon-arrow-right'; + + /** + * Language key to use for the text on the submit button + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $submit_text = 'COM_USERS_MFA_VALIDATE'; + + /** + * Is this MFA method validating against all configured authenticators of the same type? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $allowEntryBatching = true; + + /** + * URL for help content + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $help_url = ''; + + /** + * Setter for the field_type property + * + * @param string $value One of self::FIELD_INPUT, self::FIELD_CUSTOM + * + * @since __DEPLOY_VERSION__ + * @throws InvalidArgumentException + */ + // phpcs:ignore + protected function setField_type(string $value) + { + if (!in_array($value, [self::FIELD_INPUT, self::FIELD_CUSTOM])) + { + throw new InvalidArgumentException('Invalid value for property field_type.'); + } + + // phpcs:ignore + $this->field_type = $value; + } + + /** + * Setter for the input_attributes property. + * + * @param array $value The value to set + * + * @return void + * @@since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected function setInput_attributes(array $value) + { + $forbiddenAttributes = ['id', 'type', 'name', 'value']; + + foreach ($forbiddenAttributes as $key) + { + if (isset($value[$key])) + { + unset($value[$key]); + } + } + + // phpcs:ignore + $this->input_attributes = $value; + } +} diff --git a/administrator/components/com_users/src/DataShape/DataShapeObject.php b/administrator/components/com_users/src/DataShape/DataShapeObject.php new file mode 100644 index 0000000000000..d5d001daa99b9 --- /dev/null +++ b/administrator/components/com_users/src/DataShape/DataShapeObject.php @@ -0,0 +1,209 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\DataShape; + +use InvalidArgumentException; + +// This line is required because of the PHP 8 attributes which are necessary to prevent PHP notices +//phpcs:ignoreFile + +/** + * Generic helper for handling data shapes in com_users + * + * @since __DEPLOY_VERSION__ + */ +abstract class DataShapeObject implements \ArrayAccess +{ + /** + * Public constructor + * + * @param array $array The data to initialise this object with + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(array $array = []) + { + if (!is_array($array) && !($array instanceof self)) + { + throw new InvalidArgumentException(sprintf('%s needs an array or a %s object', __METHOD__, __CLASS__)); + } + + foreach (($array instanceof self) ? $array->asArray() : $array as $k => $v) + { + $this[$k] = $v; + } + } + + /** + * Get the data shape as a key-value array + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function asArray(): array + { + return get_object_vars($this); + } + + /** + * Merge another data shape object or key-value array into this object. + * + * @param array|self $newValues The object or array to merge into self. + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function merge($newValues): self + { + if (!is_array($newValues) && !($newValues instanceof self)) + { + throw new InvalidArgumentException(sprintf('%s needs an array or a %s object', __METHOD__, __CLASS__)); + } + + foreach (($newValues instanceof self) ? $newValues->asArray() : $newValues as $k => $v) + { + if (!isset($this->{$k})) + { + continue; + } + + $this[$k] = $v; + } + + return $this; + } + + /** + * Magic getter + * + * @param string $name The name of the property to retrieve + * + * @return mixed + * + * @since __DEPLOY_VERSION__ + */ + public function __get($name) + { + $methodName = 'get' . ucfirst($name); + + if (method_exists($this, $methodName)) + { + return $this->{$methodName}; + } + + if (property_exists($this, $name)) + { + return $this->{$name}; + } + + throw new InvalidArgumentException(sprintf('Property %s not found in %s', $name, __CLASS__)); + } + + /** + * Magic Setter + * + * @param string $name The property to set the value for + * @param mixed $value The property value to set it to + * + * @return mixed + * @since __DEPLOY_VERSION__ + */ + public function __set($name, $value) + { + $methodName = 'set' . ucfirst($name); + + if (method_exists($this, $methodName)) + { + return $this->{$methodName}($value); + } + + if (property_exists($this, $name)) + { + $this->{$name} = $value; + } + + throw new InvalidArgumentException(sprintf('Property %s not found in %s', $name, __CLASS__)); + } + + /** + * Is a property set? + * + * @param string $name Property name + * + * @return boolean Does it exist in the object? + * @since __DEPLOY_VERSION__ + */ + #[\ReturnTypeWillChange] + public function __isset($name) + { + $methodName = 'get' . ucfirst($name); + + return method_exists($this, $methodName) || property_exists($this, $name); + } + + /** + * Does the property exist (array access)? + * + * @param string $offset Property name + * + * @return boolean + * @since __DEPLOY_VERSION__ + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->{$offset}); + } + + /** + * Get the value of a property (array access). + * + * @param string $offset Property name + * + * @return mixed + * @since __DEPLOY_VERSION__ + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->{$offset}; + } + + /** + * Set the value of a property (array access). + * + * @param string $offset Property name + * @param mixed $value Property value + * + * @return void + * @since __DEPLOY_VERSION__ + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + $this->{$offset} = $value; + } + + /** + * Unset a property (array access). + * + * @param string $offset Property name + * + * @return mixed + * @since __DEPLOY_VERSION__ + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + throw new \LogicException(sprintf('You cannot unset members of %s', __CLASS__)); + } +} diff --git a/administrator/components/com_users/src/DataShape/MethodDescriptor.php b/administrator/components/com_users/src/DataShape/MethodDescriptor.php new file mode 100644 index 0000000000000..da957de7dfd4b --- /dev/null +++ b/administrator/components/com_users/src/DataShape/MethodDescriptor.php @@ -0,0 +1,116 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\DataShape; + +use Joomla\Component\Users\Administrator\Table\MfaTable; + +/** + * @property string $name Internal code of this MFA Method + * @property string $display User-facing name for this MFA Method + * @property string $shortinfo Short description of this MFA Method displayed to the user + * @property string $image URL to the logo image for this Method + * @property bool $canDisable Are we allowed to disable it? + * @property bool $allowMultiple Are we allowed to have multiple instances of it per user? + * @property string $help_url URL for help content + * @property bool $allowEntryBatching Allow authentication against all entries of this MFA Method. + * + * @since __DEPLOY_VERSION__ + */ +class MethodDescriptor extends DataShapeObject +{ + /** + * Internal code of this MFA Method + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $name = ''; + + /** + * User-facing name for this MFA Method + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $display = ''; + + /** + * Short description of this MFA Method displayed to the user + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $shortinfo = ''; + + /** + * URL to the logo image for this Method + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $image = ''; + + /** + * Are we allowed to disable it? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $canDisable = true; + + /** + * Are we allowed to have multiple instances of it per user? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $allowMultiple = false; + + /** + * URL for help content + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $help_url = ''; + + /** + * Allow authentication against all entries of this MFA Method. + * + * Otherwise authentication takes place against a SPECIFIC entry at a time. + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $allowEntryBatching = false; + + /** + * Active authentication methods, used internally only + * + * @var MfaTable[] + * @since __DEPLOY_VERSION__ + * @internal + */ + protected $active = []; + + /** + * Adds an active MFA method + * + * @param MfaTable $record The MFA method record to add + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function addActiveMethod(MfaTable $record) + { + $this->active[$record->id] = $record; + } +} diff --git a/administrator/components/com_users/src/DataShape/SetupRenderOptions.php b/administrator/components/com_users/src/DataShape/SetupRenderOptions.php new file mode 100644 index 0000000000000..549c3e355b23c --- /dev/null +++ b/administrator/components/com_users/src/DataShape/SetupRenderOptions.php @@ -0,0 +1,259 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\DataShape; + +use InvalidArgumentException; +use Joomla\Database\ParameterType; + +/** + * Data shape for Method Setup Render Options + * + * @property string $default_title Default title if you are setting up this MFA Method for the first time + * @property string $pre_message Custom HTML to display above the MFA setup form + * @property string $table_heading Heading for displayed tabular data. Typically used to display a list of fixed MFA + * codes, TOTP setup parameters etc + * @property array $tabular_data Any tabular data to display (label => custom HTML). See above + * @property array $hidden_data Hidden fields to include in the form (name => value) + * @property string $field_type How to render the MFA setup code field. "input" (HTML input element) or "custom" + * (custom HTML) + * @property string $input_type The type attribute for the HTML input box. Typically "text" or "password". Use any + * HTML5 input type. + * @property string $input_value Pre-filled value for the HTML input box. Typically used for fixed codes, the fixed + * YubiKey ID etc. + * @property string $placeholder Placeholder text for the HTML input box. Leave empty if you don't need it. + * @property string $label Label to show above the HTML input box. Leave empty if you don't need it. + * @property string $html Custom HTML. Only used when field_type = custom. + * @property bool $show_submit Should I show the submit button (apply the MFA setup)? + * @property string $submit_class Additional CSS classes for the submit button (apply the MFA setup) + * @property string $post_message Custom HTML to display below the MFA setup form + * @property string $help_url A URL with help content for this Method to display to the user + * + * @since __DEPLOY_VERSION__ + */ +class SetupRenderOptions extends DataShapeObject +{ + /** + * Display a standard HTML5 input field. Use the input_type, placeholder and label properties to set it up. + * + * @since __DEPLOY_VERSION__ + */ + public const FIELD_INPUT = 'input'; + + /** + * Display a custom HTML document. Use the html property to set it up. + * + * @since __DEPLOY_VERSION__ + */ + public const FIELD_CUSTOM = 'custom'; + + /** + * Default title if you are setting up this MFA Method for the first time + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $default_title = ''; + + /** + * Custom HTML to display above the MFA setup form parameters etc + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $pre_message = ''; + + /** + * Heading for displayed tabular data. Typically used to display a list of fixed MFA codes, TOTP setup + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $table_heading = ''; + + /** + * Any tabular data to display (label => custom HTML). See above + * + * @var array + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $tabular_data = []; + + /** + * Hidden fields to include in the form (name => value) + * + * @var array + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $hidden_data = []; + + /** + * How to render the MFA setup code field. "input" (HTML input element) or "custom" (custom HTML) + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $field_type = 'input'; + + /** + * The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $input_type = 'text'; + + /** + * Attributes other than type and id which will be added to the HTML input box. + * + * @var array + * @@since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $input_attributes = []; + + /** + * Pre-filled value for the HTML input box. Typically used for fixed codes, the fixed YubiKey ID etc. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $input_value = ''; + + /** + * Placeholder text for the HTML input box. Leave empty if you don't need it. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $placeholder = ''; + + /** + * Label to show above the HTML input box. Leave empty if you don't need it. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $label = ''; + + /** + * Custom HTML. Only used when field_type = custom. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $html = ''; + + /** + * Should I show the submit button (apply the MFA setup)? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $show_submit = true; + + /** + * Additional CSS classes for the submit button (apply the MFA setup) + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $submit_class = ''; + + /** + * Icon class to use for the submit button + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $submit_icon = 'icon icon-ok'; + + /** + * Language key to use for the text on the submit button + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $submit_text = 'JSAVE'; + + /** + * Custom HTML to display below the MFA setup form + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $post_message = ''; + + /** + * A URL with help content for this Method to display to the user + * + * @var string + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $help_url = ''; + + /** + * Setter for the field_type property + * + * @param string $value One of self::FIELD_INPUT, self::FIELD_CUSTOM + * + * @since __DEPLOY_VERSION__ + * @throws InvalidArgumentException + */ + // phpcs:ignore + protected function setField_type($value) + { + if (!in_array($value, [self::FIELD_INPUT, self::FIELD_CUSTOM])) + { + throw new InvalidArgumentException('Invalid value for property field_type.'); + } + + // phpcs:ignore + $this->field_type = $value; + } + + /** + * Setter for the input_attributes property. + * + * @param array $value The value to set + * + * @return void + * @@since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected function setInput_attributes(array $value) + { + $forbiddenAttributes = ['id', 'type', 'name', 'value']; + + foreach ($forbiddenAttributes as $key) + { + if (isset($value[$key])) + { + unset($value[$key]); + } + } + + // phpcs:ignore + $this->input_attributes = $value; + } +} diff --git a/administrator/components/com_users/src/Dispatcher/Dispatcher.php b/administrator/components/com_users/src/Dispatcher/Dispatcher.php index 25352ba4c4084..91b413d77af40 100644 --- a/administrator/components/com_users/src/Dispatcher/Dispatcher.php +++ b/administrator/components/com_users/src/Dispatcher/Dispatcher.php @@ -46,6 +46,29 @@ protected function checkAccess() } } + /** + * Special case: Multi-factor Authentication + * + * We allow access to all MFA views and tasks. Access control for MFA tasks is performed in + * the Controllers since what is allowed depends on who is logged in and whose account you + * are trying to modify. Implementing these checks in the Dispatcher would violate the + * separation of concerns. + */ + $allowedViews = ['callback', 'captive', 'method', 'methods']; + $isAllowedTask = array_reduce( + $allowedViews, + function ($carry, $taskPrefix) use ($task) + { + return $carry || strpos($task, $taskPrefix . '.') === 0; + }, + false + ); + + if (in_array(strtolower($view), $allowedViews) || $isAllowedTask) + { + return; + } + parent::checkAccess(); } } diff --git a/administrator/components/com_users/src/Field/ModulesPositionField.php b/administrator/components/com_users/src/Field/ModulesPositionField.php new file mode 100644 index 0000000000000..727353d88200e --- /dev/null +++ b/administrator/components/com_users/src/Field/ModulesPositionField.php @@ -0,0 +1,23 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Field; + +defined('_JEXEC') || die(); + +/** + * Select modules positions. + * + * Reuses the same field from com_modules. Don't lose it; reuse it! + * + * @since __DEPLOY_VERSION__ + */ +class ModulesPositionField extends \Joomla\Component\Modules\Administrator\Field\ModulesPositionField +{ +} diff --git a/administrator/components/com_users/src/Helper/Mfa.php b/administrator/components/com_users/src/Helper/Mfa.php new file mode 100644 index 0000000000000..3fbddde3c1c0c --- /dev/null +++ b/administrator/components/com_users/src/Helper/Mfa.php @@ -0,0 +1,379 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Helper; + +use Exception; +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Document\HtmlDocument; +use Joomla\CMS\Event\MultiFactor\GetMethod; +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +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\MethodDescriptor; +use Joomla\Component\Users\Administrator\Model\BackupcodesModel; +use Joomla\Component\Users\Administrator\Model\MethodsModel; +use Joomla\Component\Users\Administrator\Table\MfaTable; +use Joomla\Component\Users\Administrator\View\Methods\HtmlView; +use Joomla\Database\DatabaseDriver; +use Joomla\Database\ParameterType; + +/** + * Helper functions for captive MFA handling + * + * @since __DEPLOY_VERSION__ + */ +abstract class Mfa +{ + /** + * Cache of all currently active MFAs + * + * @var array|null + * @since __DEPLOY_VERSION__ + */ + protected static $allMFAs = null; + + /** + * Are we inside the administrator application + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected static $isAdmin = null; + + /** + * Get the HTML for the Multi-factor Authentication configuration interface for a user. + * + * This helper method uses a sort of primitive HMVC to display the com_users' Methods page which + * renders the MFA configuration interface. + * + * @param User $user The user we are going to show the configuration UI for. + * + * @return string|null The HTML of the UI; null if we cannot / must not show it. + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public static function getConfigurationInterface(User $user): ?string + { + // Check the conditions + if (!self::canShowConfigurationInterface($user)) + { + return null; + } + + /** @var CMSApplication $app */ + $app = Factory::getApplication(); + + if (!$app->input->getCmd('option', '') === 'com_users') + { + $app->getLanguage()->load('com_users'); + $app->getDocument() + ->getWebAssetManager() + ->getRegistry() + ->addExtensionRegistryFile('com_users'); + } + + // Get a model + /** @var MVCFactoryInterface $factory */ + $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory(); + + /** @var MethodsModel $methodsModel */ + $methodsModel = $factory->createModel('Methods', 'Administrator'); + /** @var BackupcodesModel $methodsModel */ + $backupCodesModel = $factory->createModel('Backupcodes', 'Administrator'); + + // Get a view object + $appRoot = $app->isClient('site') ? \JPATH_SITE : \JPATH_ADMINISTRATOR; + $prefix = $app->isClient('site') ? 'Site' : 'Administrator'; + /** @var HtmlView $view */ + $view = $factory->createView('Methods', $prefix, 'Html', + [ + 'base_path' => $appRoot . '/components/com_users', + ] + ); + $view->setModel($methodsModel, true); + /** @noinspection PhpParamsInspection */ + $view->setModel($backupCodesModel); + $view->document = $app->getDocument(); + $view->returnURL = base64_encode(Uri::getInstance()->toString()); + $view->user = $user; + $view->set('forHMVC', true); + + @ob_start(); + + try + { + $view->display(); + } + catch (\Throwable $e) + { + @ob_end_clean(); + + /** + * This is intentional! When you are developing a Multi-factor Authentication plugin you + * will inevitably mess something up and end up with an error. This would cause the + * entire MFA configuration page to dissappear. No problem! Set Debug System to Yes in + * Global Configuration and you can see the error exception which will help you solve + * your problem. + */ + if (defined('JDEBUG') && JDEBUG) + { + throw $e; + } + + return null; + } + + return @ob_get_clean(); + } + + /** + * Get a list of all of the MFA Methods + * + * @return MethodDescriptor[] + * @since __DEPLOY_VERSION__ + */ + public static function getMfaMethods(): array + { + PluginHelper::importPlugin('multifactorauth'); + + if (is_null(self::$allMFAs)) + { + // Get all the plugin results + $event = new GetMethod; + $temp = Factory::getApplication() + ->getDispatcher() + ->dispatch($event->getName(), $event) + ->getArgument('result', []); + + // Normalize the results + self::$allMFAs = []; + + foreach ($temp as $method) + { + if (!is_array($method) && !($method instanceof MethodDescriptor)) + { + continue; + } + + $method = $method instanceof MethodDescriptor + ? $method : new MethodDescriptor($method); + + if (empty($method['name'])) + { + continue; + } + + self::$allMFAs[$method['name']] = $method; + } + } + + return self::$allMFAs; + } + + /** + * Is the current user allowed to add/edit MFA methods for $user? + * + * This is only allowed if I am adding / editing methods for myself. + * + * If the target user is a member of any group disallowed to use MFA this will return false. + * + * @param User|null $user The user you want to know if we're allowed to edit + * + * @return boolean + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public static function canAddEditMethod(?User $user = null): bool + { + // Cannot do MFA operations on no user or a guest user. + if (is_null($user) || $user->guest) + { + return false; + } + + // If the user is in a user group which disallows MFA we cannot allow adding / editing methods. + $neverMFAGroups = ComponentHelper::getParams('com_users')->get('neverMFAUserGroups', []); + $neverMFAGroups = is_array($neverMFAGroups) ? $neverMFAGroups : []; + + if (count(array_intersect($user->getAuthorisedGroups(), $neverMFAGroups))) + { + return false; + } + + // Check if this is the same as the logged-in user. + $myUser = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + return $myUser->id === $user->id; + } + + /** + * Is the current user allowed to delete MFA methods / disable MFA for $user? + * + * This is allowed if: + * - The user being queried is the same as the logged-in user + * - The logged-in user is a Super User AND the queried user is NOT a Super User. + * + * Note that Super Users can be edited by their own user only for security reasons. If a Super + * User gets locked out they must use the Backup Codes to regain access. If that's not possible, + * they will need to delete their records from the `#__user_mfa` table. + * + * @param User|null $user The user being queried. + * + * @return boolean + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public static function canDeleteMethod(?User $user = null): bool + { + // Cannot do MFA operations on no user or a guest user. + if (is_null($user) || $user->guest) + { + return false; + } + + $myUser = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + return $myUser->id === $user->id + || ($myUser->authorise('core.admin') && !$user->authorise('core.admin')); + } + + /** + * Return all MFA records for a specific user + * + * @param int|null $userId User ID. NULL for currently logged in user. + * + * @return MfaTable[] + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public static function getUserMfaRecords(?int $userId): array + { + if (empty($userId)) + { + $user = Factory::getApplication()->getIdentity() ?: Factory::getUser(); + $userId = $user->id ?: 0; + } + + /** @var DatabaseDriver $db */ + $db = Factory::getContainer()->get('DatabaseDriver'); + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__user_mfa')) + ->where($db->quoteName('user_id') . ' = :user_id') + ->bind(':user_id', $userId, ParameterType::INTEGER); + + try + { + $ids = $db->setQuery($query)->loadColumn() ?: []; + } + catch (Exception $e) + { + $ids = []; + } + + if (empty($ids)) + { + return []; + } + + /** @var MVCFactoryInterface $factory */ + $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory(); + + // Map all results to MFA table objects + $records = array_map( + function ($id) use ($factory) + { + /** @var MfaTable $record */ + $record = $factory->createTable('Mfa', 'Administrator'); + $loaded = $record->load($id); + + return $loaded ? $record : null; + }, + $ids + ); + + // Let's remove Methods we couldn't decrypt when reading from the database. + $hasBackupCodes = false; + + $records = array_filter( + $records, + function ($record) use (&$hasBackupCodes) + { + $isValid = !is_null($record) && (!empty($record->options)); + + if ($isValid && ($record->method === 'backupcodes')) + { + $hasBackupCodes = true; + } + + return $isValid; + } + ); + + // If the only Method is backup codes it's as good as having no records + if ((count($records) === 1) && $hasBackupCodes) + { + return []; + } + + return $records; + } + + /** + * Are the conditions for showing the MFA configuration interface met? + * + * @param User|null $user The user to be configured + * + * @return boolean + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public static function canShowConfigurationInterface(?User $user = null): bool + { + // If I have no user to check against that's all the checking I can do. + if (empty($user)) + { + return false; + } + + // I need at least one MFA method plugin for the setup interface to make any sense. + $plugins = PluginHelper::getPlugin('multifactorauth'); + + if (count($plugins) < 1) + { + return false; + } + + /** @var CMSApplication $app */ + $app = Factory::getApplication(); + + // We can only show a configuration page in the front- or backend application. + if (!$app->isClient('site') && !$app->isClient('administrator')) + { + return false; + } + + // Only show the configuration page if we have an HTML document + if (!($app->getDocument() instanceof HtmlDocument)) + { + return false; + } + + // I must be able to add, edit or delete the user's MFA settings + return self::canAddEditMethod($user) || self::canDeleteMethod($user); + } +} diff --git a/administrator/components/com_users/src/Helper/UsersHelper.php b/administrator/components/com_users/src/Helper/UsersHelper.php index ca1eb74691077..7c40baeaece5e 100644 --- a/administrator/components/com_users/src/Helper/UsersHelper.php +++ b/administrator/components/com_users/src/Helper/UsersHelper.php @@ -110,37 +110,18 @@ public static function getRangeOptions() } /** - * Creates a list of two factor authentication methods used in com_users - * on user view + * No longer used. * * @return array * * @since 3.2.0 * @throws \Exception + * + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0 */ public static function getTwoFactorMethods() { - PluginHelper::importPlugin('twofactorauth'); - $identities = Factory::getApplication()->triggerEvent('onUserTwofactorIdentify', array()); - - $options = array( - HTMLHelper::_('select.option', 'none', Text::_('JGLOBAL_OTPMETHOD_NONE'), 'value', 'text'), - ); - - if (!empty($identities)) - { - foreach ($identities as $identity) - { - if (!is_object($identity)) - { - continue; - } - - $options[] = HTMLHelper::_('select.option', $identity->method, $identity->title, 'value', 'text'); - } - } - - return $options; + return []; } /** diff --git a/administrator/components/com_users/src/Model/BackupcodesModel.php b/administrator/components/com_users/src/Model/BackupcodesModel.php new file mode 100644 index 0000000000000..8e41d56f215ae --- /dev/null +++ b/administrator/components/com_users/src/Model/BackupcodesModel.php @@ -0,0 +1,304 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Model; + +use Joomla\CMS\Crypt\Crypt; +use Joomla\CMS\Date\Date; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Table\MfaTable; + +/** + * Model for managing backup codes + * + * @since __DEPLOY_VERSION__ + */ +class BackupcodesModel extends BaseDatabaseModel +{ + /** + * Caches the backup codes per user ID + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected $cache = []; + + /** + * Get the backup codes record for the specified user + * + * @param User|null $user The user in question. Use null for the currently logged in user. + * + * @return MfaTable|null Record object or null if none is found + * @throws \Exception + * @since __DEPLOY_VERSION__ + */ + public function getBackupCodesRecord(User $user = null): ?MfaTable + { + // Make sure I have a user + if (empty($user)) + { + $user = Factory::getApplication()->getIdentity() ?: + Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + /** @var MfaTable $record */ + $record = $this->getTable('Mfa', 'Administrator'); + $loaded = $record->load( + [ + 'user_id' => $user->id, + 'method' => 'backupcodes', + ] + ); + + if (!$loaded) + { + $record = null; + } + + return $record; + } + + /** + * Generate a new set of backup codes for the specified user. The generated codes are immediately saved to the + * database and the internal cache is updated. + * + * @param User|null $user Which user to generate codes for? + * + * @return void + * @throws \Exception + * @since __DEPLOY_VERSION__ + */ + public function regenerateBackupCodes(User $user = null): void + { + // Make sure I have a user + if (empty($user)) + { + $user = Factory::getApplication()->getIdentity() ?: + Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + // Generate backup codes + $backupCodes = []; + + for ($i = 0; $i < 10; $i++) + { + // Each backup code is 2 groups of 4 digits + $backupCodes[$i] = sprintf('%04u%04u', random_int(0, 9999), random_int(0, 9999)); + } + + // Save the backup codes to the database and update the cache + $this->saveBackupCodes($backupCodes, $user); + } + + /** + * Saves the backup codes to the database + * + * @param array $codes An array of exactly 10 elements + * @param User|null $user The user for which to save the backup codes + * + * @return boolean + * @throws \Exception + * @since __DEPLOY_VERSION__ + */ + public function saveBackupCodes(array $codes, ?User $user = null): bool + { + // Make sure I have a user + if (empty($user)) + { + $user = Factory::getApplication()->getIdentity() ?: + Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + // Try to load existing backup codes + $existingCodes = $this->getBackupCodes($user); + $jNow = Date::getInstance(); + + /** @var MfaTable $record */ + $record = $this->getTable('Mfa', 'Administrator'); + + if (is_null($existingCodes)) + { + $record->reset(); + + $newData = [ + 'user_id' => $user->id, + 'title' => Text::_('COM_USERS_PROFILE_OTEPS'), + 'method' => 'backupcodes', + 'default' => 0, + 'created_on' => $jNow->toSql(), + 'options' => $codes, + ]; + } + else + { + $record->load( + [ + 'user_id' => $user->id, + 'method' => 'backupcodes', + ] + ); + + $newData = [ + 'options' => $codes, + ]; + } + + $saved = $record->save($newData); + + if (!$saved) + { + return false; + } + + // Finally, update the cache + $this->cache[$user->id] = $codes; + + return true; + } + + /** + * Returns the backup codes for the specified user. Cached values will be preferentially returned, therefore you + * MUST go through this model's Methods ONLY when dealing with backup codes. + * + * @param User|null $user The user for which you want the backup codes + * + * @return array|null The backup codes, or null if they do not exist + * @throws \Exception + * @since __DEPLOY_VERSION__ + */ + public function getBackupCodes(User $user = null): ?array + { + // Make sure I have a user + if (empty($user)) + { + $user = Factory::getApplication()->getIdentity() ?: + Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + if (isset($this->cache[$user->id])) + { + return $this->cache[$user->id]; + } + + // If there is no cached record try to load it from the database + $this->cache[$user->id] = null; + + // Try to load the record + /** @var MfaTable $record */ + $record = $this->getTable('Mfa', 'Administrator'); + $loaded = $record->load( + [ + 'user_id' => $user->id, + 'method' => 'backupcodes', + ] + ); + + if ($loaded) + { + $this->cache[$user->id] = $record->options; + } + + return $this->cache[$user->id]; + } + + /** + * Check if the provided string is a backup code. If it is, it will be removed from the list (replaced with an empty + * string) and the codes will be saved to the database. All comparisons are performed in a timing safe manner. + * + * @param string $code The code to check + * @param User|null $user The user to check against + * + * @return boolean + * @throws \Exception + * @since __DEPLOY_VERSION__ + */ + public function isBackupCode($code, ?User $user = null): bool + { + // Load the backup codes + $codes = $this->getBackupCodes($user) ?: array_fill(0, 10, ''); + + // Keep only the numbers in the provided $code + $code = filter_var($code, FILTER_SANITIZE_NUMBER_INT); + $code = trim($code); + + // Check if the code is in the array. We always check against ten codes to prevent timing attacks which + // determine the amount of codes. + $result = false; + + // The two arrays let us always add an element to an array, therefore having PHP expend the same amount of time + // for the correct code, the incorrect codes and the fake codes. + $newArray = []; + $dummyArray = []; + + $realLength = count($codes); + $restLength = 10 - $realLength; + + for ($i = 0; $i < $realLength; $i++) + { + if (hash_equals($codes[$i], $code)) + { + // This may seem redundant but makes sure both branches of the if-block are isochronous + $result = $result || true; + $newArray[] = ''; + $dummyArray[] = $codes[$i]; + } + else + { + // This may seem redundant but makes sure both branches of the if-block are isochronous + $result = $result || false; + $dummyArray[] = ''; + $newArray[] = $codes[$i]; + } + } + + /** + * This is an intentional waste of time, symmetrical to the code above, making sure + * evaluating each of the total of ten elements takes the same time. This code should never + * run UNLESS someone messed up with our backup codes array and it no longer contains 10 + * elements. + */ + $otherResult = false; + + $temp1 = ''; + + for ($i = 0; $i < 10; $i++) + { + $temp1[$i] = random_int(0, 99999999); + } + + for ($i = 0; $i < $restLength; $i++) + { + if (Crypt::timingSafeCompare($temp1[$i], $code)) + { + $otherResult = $otherResult || true; + $newArray[] = ''; + $dummyArray[] = $temp1[$i]; + } + else + { + $otherResult = $otherResult || false; + $newArray[] = ''; + $dummyArray[] = $temp1[$i]; + } + } + + // This last check makes sure than an empty code does not validate + $result = $result && !hash_equals('', $code); + + // Save the backup codes + $this->saveBackupCodes($newArray, $user); + + // Finally return the result + return $result; + } +} diff --git a/administrator/components/com_users/src/Model/CaptiveModel.php b/administrator/components/com_users/src/Model/CaptiveModel.php new file mode 100644 index 0000000000000..23c0545785894 --- /dev/null +++ b/administrator/components/com_users/src/Model/CaptiveModel.php @@ -0,0 +1,443 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Model; + +use Exception; +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Event\MultiFactor\Captive; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Component\Users\Administrator\Table\MfaTable; +use Joomla\Event\Event; + +/** + * Captive Multi-factor Authentication page's model + * + * @since __DEPLOY_VERSION__ + */ +class CaptiveModel extends BaseDatabaseModel +{ + /** + * Cache of the names of the currently active MFA Methods + * + * @var array|null + * @since __DEPLOY_VERSION__ + */ + protected $activeMFAMethodNames = null; + + /** + * Prevents Joomla from displaying any modules. + * + * This is implemented with a trick. If you use jdoc tags to load modules the JDocumentRendererHtmlModules + * uses JModuleHelper::getModules() to load the list of modules to render. This goes through JModuleHelper::load() + * which triggers the onAfterModuleList event after cleaning up the module list from duplicates. By resetting + * the list to an empty array we force Joomla to not display any modules. + * + * Similar code paths are followed by any canonical code which tries to load modules. So even if your template does + * not use jdoc tags this code will still work as expected. + * + * @param CMSApplication|null $app The CMS application to manipulate + * + * @return void + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function suppressAllModules(CMSApplication $app = null): void + { + if (is_null($app)) + { + $app = Factory::getApplication(); + } + + $app->registerEvent('onAfterModuleList', [$this, 'onAfterModuleList']); + } + + /** + * Get the MFA records for the user which correspond to active plugins + * + * @param User|null $user The user for which to fetch records. Skip to use the current user. + * @param bool $includeBackupCodes Should I include the backup codes record? + * + * @return array + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function getRecords(User $user = null, bool $includeBackupCodes = false): array + { + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + // Get the user's MFA records + $records = MfaHelper::getUserMfaRecords($user->id); + + // No MFA Methods? Then we obviously don't need to display a Captive login page. + if (empty($records)) + { + return []; + } + + // Get the enabled MFA Methods' names + $methodNames = $this->getActiveMethodNames(); + + // Filter the records based on currently active MFA Methods + $ret = []; + + $methodNames[] = 'backupcodes'; + $methodNames = array_unique($methodNames); + + if (!$includeBackupCodes) + { + $methodNames = array_filter( + $methodNames, + function ($method) + { + return $method != 'backupcodes'; + } + ); + } + + foreach ($records as $record) + { + // Backup codes must not be included in the list. We add them in the View, at the end of the list. + if (in_array($record->method, $methodNames)) + { + $ret[$record->id] = $record; + } + } + + return $ret; + } + + /** + * Return all the active MFA Methods' names + * + * @return array + * @since __DEPLOY_VERSION__ + */ + private function getActiveMethodNames(): ?array + { + if (!is_null($this->activeMFAMethodNames)) + { + return $this->activeMFAMethodNames; + } + + // Let's get a list of all currently active MFA Methods + $mfaMethods = MfaHelper::getMfaMethods(); + + // If no MFA Method is active we can't really display a Captive login page. + if (empty($mfaMethods)) + { + $this->activeMFAMethodNames = []; + + return $this->activeMFAMethodNames; + } + + // Get a list of just the Method names + $this->activeMFAMethodNames = []; + + foreach ($mfaMethods as $mfaMethod) + { + $this->activeMFAMethodNames[] = $mfaMethod['name']; + } + + return $this->activeMFAMethodNames; + } + + /** + * Get the currently selected MFA record for the current user. If the record ID is empty, it does not correspond to + * the currently logged in user or does not correspond to an active plugin null is returned instead. + * + * @param User|null $user The user for which to fetch records. Skip to use the current user. + * + * @return MfaTable|null + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function getRecord(?User $user = null): ?MfaTable + { + $id = (int) $this->getState('record_id', null); + + if ($id <= 0) + { + return null; + } + + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + /** @var MfaTable $record */ + $record = $this->getTable('Mfa', 'Administrator'); + $loaded = $record->load( + [ + 'user_id' => $user->id, + 'id' => $id, + ] + ); + + if (!$loaded) + { + return null; + } + + $methodNames = $this->getActiveMethodNames(); + + if (!in_array($record->method, $methodNames) && ($record->method != 'backupcodes')) + { + return null; + } + + return $record; + } + + /** + * Load the Captive login page render options for a specific MFA record + * + * @param MfaTable $record The MFA record to process + * + * @return CaptiveRenderOptions The rendering options + * @since __DEPLOY_VERSION__ + */ + public function loadCaptiveRenderOptions(?MfaTable $record): CaptiveRenderOptions + { + $renderOptions = new CaptiveRenderOptions; + + if (empty($record)) + { + return $renderOptions; + } + + $event = new Captive($record); + $results = Factory::getApplication() + ->getDispatcher() + ->dispatch($event->getName(), $event) + ->getArgument('result', []); + + if (empty($results)) + { + if ($record->method === 'backupcodes') + { + return $renderOptions->merge( + [ + 'input_type' => 'number', + 'label' => Text::_('COM_USERS_USER_BACKUPCODE'), + ] + ); + } + + return $renderOptions; + } + + foreach ($results as $result) + { + if (empty($result)) + { + continue; + } + + return $renderOptions->merge($result); + } + + return $renderOptions; + } + + /** + * Returns the title to display in the Captive login page, or an empty string if no title is to be displayed. + * + * @return string + * @since __DEPLOY_VERSION__ + */ + public function getPageTitle(): string + { + // In the frontend we can choose if we will display a title + $showTitle = (bool) ComponentHelper::getParams('com_users') + ->get('frontend_show_title', 1); + + if (!$showTitle) + { + return ''; + } + + return Text::_('COM_USERS_USER_MULTIFACTOR_AUTH'); + } + + /** + * Translate a MFA Method's name into its human-readable, display name + * + * @param string $name The internal MFA Method name + * + * @return string + * @since __DEPLOY_VERSION__ + */ + public function translateMethodName(string $name): string + { + static $map = null; + + if (!is_array($map)) + { + $map = []; + $mfaMethods = MfaHelper::getMfaMethods(); + + if (!empty($mfaMethods)) + { + foreach ($mfaMethods as $mfaMethod) + { + $map[$mfaMethod['name']] = $mfaMethod['display']; + } + } + } + + if ($name == 'backupcodes') + { + return Text::_('COM_USERS_USER_BACKUPCODES'); + } + + return $map[$name] ?? $name; + } + + /** + * Translate a MFA Method's name into the relative URL if its logo image + * + * @param string $name The internal MFA Method name + * + * @return string + * @since __DEPLOY_VERSION__ + */ + public function getMethodImage(string $name): string + { + static $map = null; + + if (!is_array($map)) + { + $map = []; + $mfaMethods = MfaHelper::getMfaMethods(); + + if (!empty($mfaMethods)) + { + foreach ($mfaMethods as $mfaMethod) + { + $map[$mfaMethod['name']] = $mfaMethod['image']; + } + } + } + + if ($name == 'backupcodes') + { + return 'media/com_users/images/emergency.svg'; + } + + return $map[$name] ?? $name; + } + + /** + * Process the modules list on Joomla! 4. + * + * Joomla! 4.x is passing an Event object. The first argument of the event object is the array of modules. After + * filtering it we have to overwrite the event argument (NOT just return the new list of modules). If a future + * version of Joomla! uses immutable events we'll have to use Reflection to do that or Joomla! would have to fix + * the way this event is handled, taking its return into account. For now, we just abuse the mutable event + * properties - a feature of the event objects we discussed in the Joomla! 4 Working Group back in August 2015. + * + * @param Event $event The Joomla! event object + * + * @return void + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function onAfterModuleList(Event $event): void + { + $modules = $event->getArgument(0); + + if (empty($modules)) + { + return; + } + + $this->filterModules($modules); + + $event->setArgument(0, $modules); + } + + /** + * This is the Method which actually filters the sites modules based on the allowed module positions specified by + * the user. + * + * @param array $modules The list of the site's modules. Passed by reference. + * + * @return void The by-reference value is modified instead. + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + private function filterModules(array &$modules): void + { + $allowedPositions = $this->getAllowedModulePositions(); + + if (empty($allowedPositions)) + { + $modules = []; + + return; + } + + $filtered = []; + + foreach ($modules as $module) + { + if (in_array($module->position, $allowedPositions)) + { + $filtered[] = $module; + } + } + + $modules = $filtered; + } + + /** + * Get a list of module positions we are allowed to display + * + * @return array + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + private function getAllowedModulePositions(): array + { + $isAdmin = Factory::getApplication()->isClient('administrator'); + + // Load the list of allowed module positions from the component's settings. May be different for front- and back-end + $configKey = 'allowed_positions_' . ($isAdmin ? 'backend' : 'frontend'); + $res = ComponentHelper::getParams('com_users')->get($configKey, []); + + // In the backend we must always add the 'title' module position + if ($isAdmin) + { + $res[] = 'title'; + $res[] = 'toolbar'; + } + + return $res; + } + +} diff --git a/administrator/components/com_users/src/Model/MethodModel.php b/administrator/components/com_users/src/Model/MethodModel.php new file mode 100644 index 0000000000000..cb422acbe6f7e --- /dev/null +++ b/administrator/components/com_users/src/Model/MethodModel.php @@ -0,0 +1,273 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Model; + +use Exception; +use Joomla\CMS\Event\GenericEvent; +use Joomla\CMS\Event\MultiFactor\GetSetup; +use Joomla\CMS\Language\Text; +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\CMS\Factory; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; + +/** + * Multi-factor Authentication management model + * + * @since __DEPLOY_VERSION__ + */ +class MethodModel extends BaseDatabaseModel +{ + /** + * List of MFA Methods + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected $mfaMethods = null; + + /** + * Get the specified MFA Method's record + * + * @param string $method The Method to retrieve. + * + * @return array + * @since __DEPLOY_VERSION__ + */ + public function getMethod(string $method): array + { + if (!$this->methodExists($method)) + { + return [ + 'name' => $method, + 'display' => '', + 'shortinfo' => '', + 'image' => '', + 'canDisable' => true, + 'allowMultiple' => true, + ]; + } + + return $this->mfaMethods[$method]; + } + + /** + * Is the specified MFA Method available? + * + * @param string $method The Method to check. + * + * @return boolean + * @since __DEPLOY_VERSION__ + */ + public function methodExists(string $method): bool + { + if (!is_array($this->mfaMethods)) + { + $this->populateMfaMethods(); + } + + return isset($this->mfaMethods[$method]); + } + + /** + * @param User|null $user The user record. Null to use the currently logged in user. + * + * @return array + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function getRenderOptions(?User $user = null): SetupRenderOptions + { + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() ?: Factory::getUser(); + } + + $renderOptions = new SetupRenderOptions; + + $event = new GetSetup($this->getRecord($user)); + $results = Factory::getApplication() + ->getDispatcher() + ->dispatch($event->getName(), $event) + ->getArgument('result', []); + + if (empty($results)) + { + return $renderOptions; + } + + foreach ($results as $result) + { + if (empty($result)) + { + continue; + } + + return $renderOptions->merge($result); + } + + return $renderOptions; + } + + /** + * Get the specified MFA record. It will return a fake default record when no record ID is specified. + * + * @param User|null $user The user record. Null to use the currently logged in user. + * + * @return MfaTable + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function getRecord(User $user = null): MfaTable + { + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + $defaultRecord = $this->getDefaultRecord($user); + $id = (int) $this->getState('id', 0); + + if ($id <= 0) + { + return $defaultRecord; + } + + /** @var MfaTable $record */ + $record = $this->getTable('Mfa', 'Administrator'); + $loaded = $record->load( + [ + 'user_id' => $user->id, + 'id' => $id, + ] + ); + + if (!$loaded) + { + return $defaultRecord; + } + + if (!$this->methodExists($record->method)) + { + return $defaultRecord; + } + + return $record; + } + + /** + * Return the title to use for the page + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getPageTitle(): string + { + $task = $this->getState('task', 'edit'); + + switch ($task) + { + case 'mfa': + $key = 'COM_USERS_USER_MULTIFACTOR_AUTH'; + break; + + default: + $key = sprintf('COM_USERS_MFA_%s_PAGE_HEAD', $task); + break; + } + + return Text::_($key); + } + + /** + * @param User|null $user The user record. Null to use the current user. + * + * @return MfaTable + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + protected function getDefaultRecord(?User $user = null): MfaTable + { + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + $method = $this->getState('method'); + $title = ''; + + if (is_null($this->mfaMethods)) + { + $this->populateMfaMethods(); + } + + if ($method && isset($this->mfaMethods[$method])) + { + $title = $this->mfaMethods[$method]['display']; + } + + /** @var MfaTable $record */ + $record = $this->getTable('Mfa', 'Administrator'); + + $record->bind( + [ + 'id' => null, + 'user_id' => $user->id, + 'title' => $title, + 'method' => $method, + 'default' => 0, + 'options' => [], + ] + ); + + return $record; + } + + /** + * Populate the list of MFA Methods + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function populateMfaMethods(): void + { + $this->mfaMethods = []; + $mfaMethods = MfaHelper::getMfaMethods(); + + if (empty($mfaMethods)) + { + return; + } + + foreach ($mfaMethods as $method) + { + $this->mfaMethods[$method['name']] = $method; + } + + // We also need to add the backup codes Method + $this->mfaMethods['backupcodes'] = [ + 'name' => 'backupcodes', + 'display' => Text::_('COM_USERS_USER_BACKUPCODES'), + 'shortinfo' => Text::_('COM_USERS_USER_BACKUPCODES_DESC'), + 'image' => 'media/com_users/images/emergency.svg', + 'canDisable' => false, + 'allowMultiple' => false, + ]; + } +} diff --git a/administrator/components/com_users/src/Model/MethodsModel.php b/administrator/components/com_users/src/Model/MethodsModel.php new file mode 100644 index 0000000000000..177185aaa3e99 --- /dev/null +++ b/administrator/components/com_users/src/Model/MethodsModel.php @@ -0,0 +1,241 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Model; + +use DateInterval; +use DateTimeZone; +use Exception; +use Joomla\CMS\Date\Date; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Database\ParameterType; +use RuntimeException; + +/** + * Multi-factor Authentication Methods list page's model + * + * @since __DEPLOY_VERSION__ + */ +class MethodsModel extends BaseDatabaseModel +{ + /** + * Returns a list of all available MFA methods and their currently active records for a given user. + * + * @param User|null $user The user object. Skip to use the current user. + * + * @return array + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function getMethods(?User $user = null): array + { + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + if ($user->guest) + { + return []; + } + + // Get an associative array of MFA Methods + $rawMethods = MfaHelper::getMfaMethods(); + $methods = []; + + foreach ($rawMethods as $method) + { + $method['active'] = []; + $methods[$method['name']] = $method; + } + + // Put the user MFA records into the Methods array + $userMfaRecords = MfaHelper::getUserMfaRecords($user->id); + + if (!empty($userMfaRecords)) + { + foreach ($userMfaRecords as $record) + { + if (!isset($methods[$record->method])) + { + continue; + } + + $methods[$record->method]->addActiveMethod($record); + } + } + + return $methods; + } + + /** + * Delete all Multi-factor Authentication Methods for the given user. + * + * @param User|null $user The user object to reset MFA for. Null to use the current user. + * + * @return void + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function deleteAll(?User $user = null): void + { + // Make sure we have a user object + if (is_null($user)) + { + $user = Factory::getApplication()->getIdentity() ?: Factory::getUser(); + } + + // If the user object is a guest (who can't have MFA) we abort with an error + if ($user->guest) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + $db = $this->getDbo(); + $query = $db->getQuery(true) + ->delete($db->quoteName('#__user_mfa')) + ->where($db->quoteName('user_id') . ' = :user_id') + ->bind(':user_id', $user->id, ParameterType::INTEGER); + $db->setQuery($query)->execute(); + } + + /** + * Format a relative timestamp. It deals with timestamps today and yesterday in a special manner. Example returns: + * Yesterday, 13:12 + * Today, 08:33 + * January 1, 2015 + * + * @param string $dateTimeText The database time string to use, e.g. "2017-01-13 13:25:36" + * + * @return string The formatted, human-readable date + * @throws Exception + * + * @since __DEPLOY_VERSION__ + */ + public function formatRelative(?string $dateTimeText): string + { + if (empty($dateTimeText)) + { + return Text::_('JNEVER'); + } + + // The timestamp is given in UTC. Make sure Joomla! parses it as such. + $utcTimeZone = new DateTimeZone('UTC'); + $jDate = new Date($dateTimeText, $utcTimeZone); + $unixStamp = $jDate->toUnix(); + + // I'm pretty sure we didn't have MFA in Joomla back in 1970 ;) + if ($unixStamp < 0) + { + return Text::_('JNEVER'); + } + + // I need to display the date in the user's local timezone. That's how you do it. + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + $userTZ = $user->getParam('timezone', 'UTC'); + $tz = new DateTimeZone($userTZ); + $jDate->setTimezone($tz); + + // Default format string: way in the past, the time of the day is not important + $formatString = Text::_('COM_USERS_MFA_LBL_DATE_FORMAT_PAST'); + $containerString = Text::_('COM_USERS_MFA_LBL_PAST'); + + // If the timestamp is within the last 72 hours we may need a special format + if ($unixStamp > (time() - (72 * 3600))) + { + // Is this timestamp today? + $jNow = new Date; + $jNow->setTimezone($tz); + $checkNow = $jNow->format('Ymd', true); + $checkDate = $jDate->format('Ymd', true); + + if ($checkDate == $checkNow) + { + $formatString = Text::_('COM_USERS_MFA_LBL_DATE_FORMAT_TODAY'); + $containerString = Text::_('COM_USERS_MFA_LBL_TODAY'); + } + else + { + // Is this timestamp yesterday? + $jYesterday = clone $jNow; + $jYesterday->setTime(0, 0, 0); + $oneSecond = new DateInterval('PT1S'); + $jYesterday->sub($oneSecond); + $checkYesterday = $jYesterday->format('Ymd', true); + + if ($checkDate == $checkYesterday) + { + $formatString = Text::_('COM_USERS_MFA_LBL_DATE_FORMAT_YESTERDAY'); + $containerString = Text::_('COM_USERS_MFA_LBL_YESTERDAY'); + } + } + } + + return sprintf($containerString, $jDate->format($formatString, true)); + } + + /** + * Set the user's "don't show this again" flag. + * + * @param User $user The user to check + * @param bool $flag True to set the flag, false to unset it (it will be set to 0, actually) + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function setFlag(User $user, bool $flag = true): void + { + $db = $this->getDbo(); + $profileKey = 'mfa.dontshow'; + $query = $db->getQuery(true) + ->select($db->quoteName('profile_value')) + ->from($db->quoteName('#__user_profiles')) + ->where($db->quoteName('user_id') . ' = :user_id') + ->where($db->quoteName('profile_key') . ' = :profileKey') + ->bind(':user_id', $user->id, ParameterType::INTEGER) + ->bind(':profileKey', $profileKey, ParameterType::STRING); + + try + { + $result = $db->setQuery($query)->loadResult(); + } + catch (Exception $e) + { + return; + } + + $exists = !is_null($result); + + $object = (object) [ + 'user_id' => $user->id, + 'profile_key' => 'mfa.dontshow', + 'profile_value' => ($flag ? 1 : 0), + 'ordering' => 1, + ]; + + if (!$exists) + { + $db->insertObject('#__user_profiles', $object); + } + else + { + $db->updateObject('#__user_profiles', $object, ['user_id', 'profile_key']); + } + } +} diff --git a/administrator/components/com_users/src/Model/UserModel.php b/administrator/components/com_users/src/Model/UserModel.php index d0003a0ba6280..ecbf22257f686 100644 --- a/administrator/components/com_users/src/Model/UserModel.php +++ b/administrator/components/com_users/src/Model/UserModel.php @@ -278,57 +278,6 @@ public function save($data) } } - // Handle the two factor authentication setup - if (isset($data['twofactor']['method'])) - { - $twoFactorMethod = $data['twofactor']['method']; - - // Get the current One Time Password (two factor auth) configuration - $otpConfig = $this->getOtpConfig($pk); - - if ($twoFactorMethod != 'none') - { - // Run the plugins - PluginHelper::importPlugin('twofactorauth'); - $otpConfigReplies = Factory::getApplication()->triggerEvent('onUserTwofactorApplyConfiguration', array($twoFactorMethod)); - - // Look for a valid reply - foreach ($otpConfigReplies as $reply) - { - if (!is_object($reply) || empty($reply->method) || ($reply->method != $twoFactorMethod)) - { - continue; - } - - $otpConfig->method = $reply->method; - $otpConfig->config = $reply->config; - - break; - } - - // Save OTP configuration. - $this->setOtpConfig($pk, $otpConfig); - - // Generate one time emergency passwords if required (depleted or not set) - if (empty($otpConfig->otep)) - { - $oteps = $this->generateOteps($pk); - } - } - else - { - $otpConfig->method = 'none'; - $otpConfig->config = array(); - $this->setOtpConfig($pk, $otpConfig); - } - - // Unset the raw data - unset($data['twofactor']); - - // Reload the user record with the updated OTP configuration - $user->load($pk); - } - // Bind the data. if (!$user->bind($data)) { @@ -997,463 +946,176 @@ public function getAssignedGroups($userId = null) } /** - * Returns the one time password (OTP) – a.k.a. two factor authentication – - * configuration for a particular user. + * No longer used * - * @param integer $userId The numeric ID of the user + * @param integer $userId Ignored * - * @return \stdClass An object holding the OTP configuration for this user + * @return \stdClass * * @since 3.2 + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0 */ public function getOtpConfig($userId = null) { - $userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id'); + @trigger_error( + sprintf( + '%s() is deprecated. Use \Joomla\Component\Users\Administrator\Helper\Mfa::getUserMfaRecords() instead.', + __METHOD__ + ), + E_USER_DEPRECATED + ); - // Initialise - $otpConfig = (object) array( + // Return the configuration object + return (object) array( 'method' => 'none', 'config' => array(), 'otep' => array() ); - - /** - * Get the raw data, without going through User (required in order to - * be able to modify the user record before logging in the user). - */ - $db = $this->getDbo(); - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__users')) - ->where($db->quoteName('id') . ' = :id') - ->bind(':id', $userId, ParameterType::INTEGER); - $db->setQuery($query); - $item = $db->loadObject(); - - // Make sure this user does have OTP enabled - if (empty($item->otpKey)) - { - return $otpConfig; - } - - // Get the encrypted data - list($method, $config) = explode(':', $item->otpKey, 2); - $encryptedOtep = $item->otep; - - // Get the secret key, yes the thing that is saved in the configuration file - $key = $this->getOtpConfigEncryptionKey(); - - // Cleanup old encryption methods, and convert to using openssl as the adapter to use. - if (strpos($config, '{') === false) - { - /** - * This part of the if statement block of code has been reviewed just before 4.0.0 release and determined that it is wrong, - * and has never worked. - * - * The aim is/was to migrate away from mcrypt encrypted data by decrypting the data and then re-encrypting - * it with the openssl adapter, but there has been a bug for a long time in the constructing of the - * mcrypt Aes class, where the number of parameters passed were wrong, meaning it was actually returning - * an openssl adapter not an mcrypt one. - * - * Rather than fix this just before 4.0.0 release, we will deprecate this block and remove it in 5.0.0 - * - * @deprecated 4.0.0 Will be removed in 5.0.0 - always use the openssl (default) adapter with the Aes class from now on. - */ - - // We use the openssl adapter by default now. - $openssl = new Aes($key, 256); - - /** - * Deal with legacy mcrypt encrypted data - * NOTE THIS NEXT LINE IS WRONG and contains wrong number of params, thus returns the openssl adapter and not the mcrypt adapter. - */ - $mcrypt = new Aes($key, 256, 'cbc', null, 'mcrypt'); - - // Attempt to decrypt using the mcrypt adapter, under normal circumstances this should fail (We no longer use mcrypt adapter to encrypt). - $decryptedConfig = $mcrypt->decryptString($config); - - // If we were able to decrypt using the mcrypt adapter, { will be in the config (JSON String), so lets update to openssl adapter use. - if (strpos($decryptedConfig, '{') !== false) - { - // Data encrypted with mcrypt, decrypt it, and then convert to openssl. - $decryptedOtep = $mcrypt->decryptString($encryptedOtep); - $encryptedOtep = $openssl->encryptString($decryptedOtep); - } - else - { - // Config data seems to be save encrypted, this can happen with 3.6.3 and openssl, lets get the data. - $decryptedConfig = $openssl->decryptString($config); - } - - $otpKey = $method . ':' . $decryptedConfig; - - $query = $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('otep') . ' = :otep') - ->set($db->quoteName('otpKey') . ' = :otpKey') - ->where($db->quoteName('id') . ' = :id') - ->bind(':otep', $encryptedOtep) - ->bind(':otpKey', $otpKey) - ->bind(':id', $userId, ParameterType::INTEGER); - $db->setQuery($query); - $db->execute(); - } - else - { - $decryptedConfig = $config; - } - - // Create an encryptor class - $aes = new Aes($key, 256); - - // Decrypt the data - $decryptedOtep = $aes->decryptString($encryptedOtep); - - // Remove the null padding added during encryption - $decryptedConfig = rtrim($decryptedConfig, "\0"); - $decryptedOtep = rtrim($decryptedOtep, "\0"); - - // Update the configuration object - $otpConfig->method = $method; - $otpConfig->config = @json_decode($decryptedConfig); - $otpConfig->otep = @json_decode($decryptedOtep); - - /* - * If the decryption failed for any reason we essentially disable the - * two-factor authentication. This prevents impossible to log in sites - * if the site admin changes the site secret for any reason. - */ - if (is_null($otpConfig->config)) - { - $otpConfig->config = array(); - } - - if (is_object($otpConfig->config)) - { - $otpConfig->config = (array) $otpConfig->config; - } - - if (is_null($otpConfig->otep)) - { - $otpConfig->otep = array(); - } - - if (is_object($otpConfig->otep)) - { - $otpConfig->otep = (array) $otpConfig->otep; - } - - // Return the configuration object - return $otpConfig; } /** - * Sets the one time password (OTP) – a.k.a. two factor authentication – - * configuration for a particular user. The $otpConfig object is the same as - * the one returned by the getOtpConfig method. + * No longer used * - * @param integer $userId The numeric ID of the user - * @param \stdClass $otpConfig The OTP configuration object + * @param integer $userId Ignored + * @param \stdClass $otpConfig Ignored * * @return boolean True on success * * @since 3.2 + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0 */ public function setOtpConfig($userId, $otpConfig) { - $userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id'); - - $updates = (object) array( - 'id' => $userId, - 'otpKey' => '', - 'otep' => '' + @trigger_error( + sprintf( + '%s() is deprecated. Multi-factor Authentication actions are handled by plugins in the multifactorauth folder.', + __METHOD__ + ), + E_USER_DEPRECATED ); - // Create an encryptor class - $key = $this->getOtpConfigEncryptionKey(); - $aes = new Aes($key, 256); - - // Create the encrypted option strings - if (!empty($otpConfig->method) && ($otpConfig->method != 'none')) - { - $decryptedConfig = json_encode($otpConfig->config); - $decryptedOtep = json_encode($otpConfig->otep); - $updates->otpKey = $otpConfig->method . ':' . $decryptedConfig; - $updates->otep = $aes->encryptString($decryptedOtep); - } - - $db = $this->getDbo(); - $result = $db->updateObject('#__users', $updates, 'id'); - - return $result; + return true; } /** - * Gets the symmetric encryption key for the OTP configuration data. It - * currently returns the site's secret. + * No longer used * - * @return string The encryption key + * @return string * * @since 3.2 + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0 */ public function getOtpConfigEncryptionKey() { + @trigger_error( + sprintf( + '%s() is deprecated. Use \Joomla\CMS\Factory::getApplication()->get(\'secret\') instead', + __METHOD__ + ), + E_USER_DEPRECATED + ); + return Factory::getApplication()->get('secret'); } /** - * Gets the configuration forms for all two-factor authentication methods - * in an array. + * No longer used * - * @param integer $userId The user ID to load the forms for (optional) + * @param integer $userId Ignored * - * @return array + * @return array Empty array * * @since 3.2 * @throws \Exception + * + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0. */ public function getTwofactorform($userId = null) { - $userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id'); - - $otpConfig = $this->getOtpConfig($userId); - - PluginHelper::importPlugin('twofactorauth'); + @trigger_error( + sprintf( + '%s() is deprecated. Use \Joomla\Component\Users\Administrator\Helper\Mfa::getConfigurationInterface()', + __METHOD__ + ), + E_USER_DEPRECATED + ); - return Factory::getApplication()->triggerEvent('onUserTwofactorShowConfiguration', array($otpConfig, $userId)); + return []; } /** - * Generates a new set of One Time Emergency Passwords (OTEPs) for a given user. + * No longer used * - * @param integer $userId The user ID - * @param integer $count How many OTEPs to generate? Default: 10 + * @param integer $userId Ignored + * @param integer $count Ignored * - * @return array The generated OTEPs + * @return array Empty array * * @since 3.2 + * @deprecated __DEPLOY_VERSION__ Wil be removed in 5.0. */ public function generateOteps($userId, $count = 10) { - $userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id'); - - // Initialise - $oteps = array(); - - // Get the OTP configuration for the user - $otpConfig = $this->getOtpConfig($userId); - - // If two factor authentication is not enabled, abort - if (empty($otpConfig->method) || ($otpConfig->method == 'none')) - { - return $oteps; - } - - $salt = '0123456789'; - $base = strlen($salt); - $length = 16; - - for ($i = 0; $i < $count; $i++) - { - $makepass = ''; - $random = Crypt::genRandomBytes($length + 1); - $shift = ord($random[0]); - - for ($j = 1; $j <= $length; ++$j) - { - $makepass .= $salt[($shift + ord($random[$j])) % $base]; - $shift += ord($random[$j]); - } - - $oteps[] = $makepass; - } - - $otpConfig->otep = $oteps; - - // Save the now modified OTP configuration - $this->setOtpConfig($userId, $otpConfig); + @trigger_error( + sprintf( + '%s() is deprecated. See \Joomla\Component\Users\Administrator\Model\BackupcodesModel::saveBackupCodes()', + __METHOD__ + ), + E_USER_DEPRECATED + ); - return $oteps; + return []; } /** - * Checks if the provided secret key is a valid two factor authentication - * secret key. If not, it will check it against the list of one time - * emergency passwords (OTEPs). If it's a valid OTEP it will also remove it - * from the user's list of OTEPs. - * - * This method will return true in the following conditions: - * - The two factor authentication is not enabled - * - You have provided a valid secret key for - * - You have provided a valid OTEP - * - * You can define the following options in the $options array: - * otp_config The OTP (one time password, a.k.a. two factor auth) - * configuration object. If not set we'll load it automatically. - * warn_if_not_req Issue a warning if you are checking a secret key against - * a user account which doesn't have any two factor - * authentication method enabled. - * warn_irq_msg The string to use for the warn_if_not_req warning - * - * @param integer $userId The user's numeric ID - * @param string $secretKey The secret key you want to check - * @param array $options Options; see above - * - * @return boolean True if it's a valid secret key for this user. + * No longer used. Always returns true. + * + * @param integer $userId Ignored + * @param string $secretKey Ignored + * @param array $options Ignored + * + * @return boolean Always true * * @since 3.2 * @throws \Exception + * + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0. MFA validation is done in the captive login. */ public function isValidSecretKey($userId, $secretKey, $options = array()) { - // Load the user's OTP (one time password, a.k.a. two factor auth) configuration - if (!array_key_exists('otp_config', $options)) - { - $otpConfig = $this->getOtpConfig($userId); - $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')) - { - // Load language - $lang = Factory::getLanguage(); - $extension = 'com_users'; - $source = JPATH_ADMINISTRATOR . '/components/' . $extension; - - $lang->load($extension, JPATH_ADMINISTRATOR) - || $lang->load($extension, $source); - - $warn = true; - $warnMessage = Text::_('COM_USERS_ERROR_SECRET_CODE_WITHOUT_TFA'); - - if (array_key_exists('warn_if_not_req', $options)) - { - $warn = $options['warn_if_not_req']; - } - - if (array_key_exists('warn_irq_msg', $options)) - { - $warnMessage = $options['warn_irq_msg']; - } - - // Warn the user if they are using a secret code but they have not - // enabled two factor auth in their account. - if (!empty($secretKey) && $warn) - { - try - { - $app = Factory::getApplication(); - $app->enqueueMessage($warnMessage, 'warning'); - } - catch (\Exception $exc) - { - // This happens when we are in CLI mode. In this case - // no warning is issued - return true; - } - } - - return true; - } - - $credentials = array( - 'secretkey' => $secretKey, + @trigger_error( + sprintf( + '%s() is deprecated. Multi-factor Authentication actions are handled by plugins in the multifactorauth folder.', + __METHOD__ + ), + E_USER_DEPRECATED ); - // Try to validate the OTP - PluginHelper::importPlugin('twofactorauth'); - - $otpAuthReplies = Factory::getApplication()->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) - { - $check = $this->isValidOtep($userId, $secretKey, $otpConfig); - } - - return $check; + return true; } /** - * Checks if the supplied string is a valid one time emergency password - * (OTEP) for this user. If it is it will be automatically removed from the - * user's list of OTEPs. + * No longer used * - * @param integer $userId The user ID against which you are checking - * @param string $otep The string you want to test for validity - * @param object $otpConfig Optional; the two factor authentication configuration (automatically fetched if not set) + * @param integer $userId Ignored + * @param string $otep Ignored + * @param object $otpConfig Ignored * - * @return boolean True if it's a valid OTEP or if two factor auth is not - * enabled in this user's account. + * @return boolean Always true * * @since 3.2 + * @deprecated __DEPLOY_VERSION__ Will be removed in 5.0 */ public function isValidOtep($userId, $otep, $otpConfig = null) { - if (is_null($otpConfig)) - { - $otpConfig = $this->getOtpConfig($userId); - } - - // 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 true; - } - else - { - /** - * Two factor authentication enabled and no OTEPs defined. The - * user has used them all up. Therefore anything they enter is - * an invalid OTEP. - */ - return false; - } - } - - // Clean up the OTEP (remove dashes, spaces and other funny stuff - // our beloved users may have unwittingly stuffed in it) - $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)); - - $this->setOtpConfig($userId, $otpConfig); - - // Return true; the OTEP was a valid one - $check = true; - } + @trigger_error( + sprintf( + '%s() is deprecated. Multi-factor Authentication actions are handled by plugins in the multifactorauth folder.', + __METHOD__ + ), + E_USER_DEPRECATED + ); - return $check; + return true; } } diff --git a/administrator/components/com_users/src/Model/UsersModel.php b/administrator/components/com_users/src/Model/UsersModel.php index 95505a31a4a9e..44880a5953bf3 100644 --- a/administrator/components/com_users/src/Model/UsersModel.php +++ b/administrator/components/com_users/src/Model/UsersModel.php @@ -16,6 +16,9 @@ use Joomla\CMS\Factory; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\ListModel; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor; +use Joomla\Component\Users\Administrator\Helper\Mfa; use Joomla\Database\DatabaseQuery; use Joomla\Database\ParameterType; use Joomla\Utilities\ArrayHelper; @@ -63,6 +66,7 @@ public function __construct($config = array(), MVCFactoryInterface $factory = nu 'range', 'lastvisitrange', 'state', + 'mfa' ); } @@ -140,6 +144,11 @@ protected function getStoreId($id = '') $id .= ':' . $this->getState('filter.group_id'); $id .= ':' . $this->getState('filter.range'); + if (PluginHelper::isEnabled('multifactorauth')) + { + $id .= ':' . $this->getState('filter.mfa'); + } + return parent::getStoreId($id); } @@ -187,8 +196,11 @@ public function getItems() foreach ($items as $item) { $userIds[] = (int) $item->id; + // phpcs:ignore $item->group_count = 0; + // phpcs:ignore $item->group_names = ''; + // phpcs:ignore $item->note_count = 0; } @@ -244,14 +256,17 @@ public function getItems() { if (isset($userGroups[$item->id])) { + // phpcs:ignore $item->group_count = $userGroups[$item->id]->group_count; // Group_concat in other databases is not supported - $item->group_names = $this->_getUserDisplayedGroups($item->id); + // phpcs:ignore + $item->group_names = $this->getUserDisplayedGroups($item->id); } if (isset($userNotes[$item->id])) { + // phpcs:ignore $item->note_count = $userNotes[$item->id]->note_count; } } @@ -263,6 +278,29 @@ public function getItems() return $this->cache[$store]; } + /** + * Get the filter form + * + * @param array $data data + * @param boolean $loadData load current data + * + * @return Form|null The \JForm object or null if the form can't be found + * + * @since __DEPLOY_VERSION__ + */ + public function getFilterForm($data = [], $loadData = true) + { + $form = parent::getFilterForm($data, $loadData); + + if (empty($form) || PluginHelper::isEnabled('multifactorauth')) + { + return $form; + } + + $form->removeField('mfa', 'filter'); + } + + /** * Build an SQL query to load the list data. * @@ -286,6 +324,51 @@ protected function getListQuery() $query->from($db->quoteName('#__users') . ' AS a'); + // Include MFA information + if (PluginHelper::isEnabled('multifactorauth')) + { + $subQuery = $db->getQuery(true) + ->select( + [ + 'MIN(' . $db->quoteName('user_id') . ') AS ' . $db->quoteName('uid'), + 'COUNT(*) AS ' . $db->quoteName('mfaRecords') + ] + ) + ->from($db->quoteName('#__user_mfa')) + ->group($db->quoteName('user_id')); + $query->select($db->quoteName('mfa.mfaRecords')) + ->join( + 'left', + '(' . $subQuery . ') AS ' . $db->quoteName('mfa'), + $db->quoteName('mfa.uid') . ' = ' . $db->quoteName('a.id') + ); + + $mfaState = $this->getState('filter.mfa'); + + if (is_numeric($mfaState)) + { + $mfaState = (int) $mfaState; + + if ($mfaState === 1) + { + $query->where( + '((' . $db->quoteName('mfa.mfaRecords') . ' > 0) OR (' . + $db->quoteName('a.otpKey') . ' IS NOT NULL AND ' . + $db->quoteName('a.otpKey') . ' != ' . $db->quote('') . '))' + ); + } + else + { + $query->where( + '((' . $db->quoteName('mfa.mfaRecords') . ' = 0 OR ' . + $db->quoteName('mfa.mfaRecords') . ' IS NULL) AND (' . + $db->quoteName('a.otpKey') . ' IS NULL OR ' . + $db->quoteName('a.otpKey') . ' = ' . $db->quote('') . '))' + ); + } + } + } + // If the model is set to check item state, add to the query. $state = $this->getState('filter.state'); @@ -560,7 +643,7 @@ private function buildDateRange($range) * * @return string Groups titles imploded :$ */ - protected function _getUserDisplayedGroups($userId) + protected function getUserDisplayedGroups($userId) { $db = $this->getDbo(); $query = $db->getQuery(true) diff --git a/administrator/components/com_users/src/Service/Encrypt.php b/administrator/components/com_users/src/Service/Encrypt.php new file mode 100644 index 0000000000000..055941b4f9927 --- /dev/null +++ b/administrator/components/com_users/src/Service/Encrypt.php @@ -0,0 +1,135 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Service; + +use Joomla\CMS\Encrypt\Aes; +use Joomla\CMS\Factory; + +/** + * Data encryption service. + * + * @since __DEPLOY_VERSION__ + */ +class Encrypt +{ + /** + * The encryption engine used by this service + * + * @var Aes + * @since __DEPLOY_VERSION__ + */ + private $aes; + + /** + * EncryptService constructor. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct() + { + $this->initialize(); + } + + /** + * Encrypt the plaintext $data and return the ciphertext prefixed by ###AES128### + * + * @param string $data The plaintext data + * + * @return string The ciphertext, prefixed by ###AES128### + * + * @since __DEPLOY_VERSION__ + */ + public function encrypt(string $data): string + { + if (!is_object($this->aes)) + { + return $data; + } + + $this->aes->setPassword($this->getPassword(), false); + $encrypted = $this->aes->encryptString($data, true); + + return '###AES128###' . $encrypted; + } + + /** + * Decrypt the ciphertext, prefixed by ###AES128###, and return the plaintext. + * + * @param string $data The ciphertext, prefixed by ###AES128### + * @param bool $legacy Use legacy key expansion? Use it to decrypt data encrypted with FOF 3. + * + * @return string The plaintext data + * + * @since __DEPLOY_VERSION__ + */ + public function decrypt(string $data, bool $legacy = false): string + { + if (substr($data, 0, 12) != '###AES128###') + { + return $data; + } + + $data = substr($data, 12); + + if (!is_object($this->aes)) + { + return $data; + } + + $this->aes->setPassword($this->getPassword(), $legacy); + $decrypted = $this->aes->decryptString($data, true, $legacy); + + // Decrypted data is null byte padded. We have to remove the padding before proceeding. + return rtrim($decrypted, "\0"); + } + + /** + * Initialize the AES cryptography object + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function initialize(): void + { + if (is_object($this->aes)) + { + return; + } + + $password = $this->getPassword(); + + if (empty($password)) + { + return; + } + + $this->aes = new Aes('cbc'); + $this->aes->setPassword($password); + } + + /** + * Returns the password used to encrypt information in the component + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function getPassword(): string + { + try + { + return Factory::getApplication()->get('secret', ''); + } + catch (\Exception $e) + { + return ''; + } + } +} diff --git a/administrator/components/com_users/src/Table/MfaTable.php b/administrator/components/com_users/src/Table/MfaTable.php new file mode 100644 index 0000000000000..b763b9b6b821b --- /dev/null +++ b/administrator/components/com_users/src/Table/MfaTable.php @@ -0,0 +1,460 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\Table; + +use Exception; +use Joomla\CMS\Date\Date; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Table\Table; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Component\Users\Administrator\Model\BackupcodesModel; +use Joomla\Component\Users\Administrator\Service\Encrypt; +use Joomla\Database\DatabaseDriver; +use Joomla\Database\ParameterType; +use Joomla\Event\DispatcherInterface; +use RuntimeException; +use Throwable; + +/** + * Table for the Multi-Factor Authentication records + * + * @property int $id Record ID. + * @property int $user_id User ID + * @property string $title Record title. + * @property string $method MFA Method (corresponds to one of the plugins). + * @property int $default Is this the default Method? + * @property array $options Configuration options for the MFA Method. + * @property string $created_on Date and time the record was created. + * @property string $last_used Date and time the record was last used successfully. + * + * @since __DEPLOY_VERSION__ + */ +class MfaTable extends Table +{ + /** + * Delete flags per ID, set up onBeforeDelete and used onAfterDelete + * + * @var array + * @since __DEPLOY_VERSION__ + */ + private $deleteFlags = []; + + /** + * Encryption service + * + * @var Encrypt + * @since __DEPLOY_VERSION__ + */ + private $encryptService; + + /** + * Indicates that columns fully support the NULL value in the database + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + // phpcs:ignore + protected $_supportNullValue = true; + + /** + * Table constructor + * + * @param DatabaseDriver $db Database driver object + * @param DispatcherInterface|null $dispatcher Events dispatcher object + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseDriver $db, DispatcherInterface $dispatcher = null) + { + parent::__construct('#__user_mfa', 'id', $db, $dispatcher); + + $this->encryptService = new Encrypt; + } + + /** + * Method to store a row in the database from the Table instance properties. + * + * If a primary key value is set the row with that primary key value will be updated with the instance property values. + * If no primary key value is set a new row will be inserted into the database with the properties from the Table instance. + * + * @param boolean $updateNulls True to update fields even if they are null. + * + * @return boolean True on success. + * + * @since __DEPLOY_VERSION__ + */ + public function store($updateNulls = true) + { + // Encrypt the options before saving them + $this->options = $this->encryptService->encrypt(json_encode($this->options ?: [])); + + // Set last_used date to null if empty or zero date + // phpcs:ignore + if (!((int) $this->last_used)) + { + // phpcs:ignore + $this->last_used = null; + } + + // phpcs:ignore + $records = MfaHelper::getUserMfaRecords($this->user_id); + + if ($this->id) + { + // Existing record. Remove it from the list of records. + $records = array_filter( + $records, + function ($rec) { + return $rec->id != $this->id; + } + ); + } + + // Update the dates on a new record + if (empty($this->id)) + { + // phpcs:ignore + $this->created_on = Date::getInstance()->toSql(); + // phpcs:ignore + $this->last_used = null; + } + + // Do I need to mark this record as the default? + if ($this->default == 0) + { + $hasDefaultRecord = array_reduce( + $records, + function ($carry, $record) + { + return $carry || ($record->default == 1); + }, + false + ); + + $this->default = $hasDefaultRecord ? 0 : 1; + } + + // Let's find out if we are saving a new MFA method record without having backup codes yet. + $mustCreateBackupCodes = false; + + if (empty($this->id) && $this->method !== 'backupcodes') + { + // Do I have any backup records? + $hasBackupCodes = array_reduce( + $records, + function (bool $carry, $record) + { + return $carry || $record->method === 'backupcodes'; + }, + false + ); + + $mustCreateBackupCodes = !$hasBackupCodes; + + // If the only other entry is the backup records one I need to make this the default method + if ($hasBackupCodes && count($records) === 1) + { + $this->default = 1; + } + } + + // Store the record + try + { + $result = parent::store($updateNulls); + } + catch (Throwable $e) + { + $this->setError($e->getMessage()); + + $result = false; + } + + // Decrypt the options (they must be decrypted in memory) + $this->decryptOptions(); + + if ($result) + { + // If this record is the default unset the default flag from all other records + $this->switchDefaultRecord(); + + // Do I need to generate backup codes? + if ($mustCreateBackupCodes) + { + $this->generateBackupCodes(); + } + } + + return $result; + } + + /** + * Method to load a row from the database by primary key and bind the fields to the Table instance properties. + * + * @param mixed $keys An optional primary key value to load the row by, or an array of fields to match. + * If not set the instance property value is used. + * @param boolean $reset True to reset the default values before loading the new row. + * + * @return boolean True if successful. False if row not found. + * + * @since __DEPLOY_VERSION__ + * @throws \InvalidArgumentException + * @throws RuntimeException + * @throws \UnexpectedValueException + */ + public function load($keys = null, $reset = true) + { + $result = parent::load($keys, $reset); + + if ($result) + { + $this->decryptOptions(); + } + + return $result; + } + + /** + * Method to delete a row from the database table by primary key value. + * + * @param mixed $pk An optional primary key value to delete. If not set the instance property value is used. + * + * @return boolean True on success. + * + * @since __DEPLOY_VERSION__ + * @throws \UnexpectedValueException + */ + public function delete($pk = null) + { + $record = $this; + + if ($pk != $this->id) + { + $record = clone $this; + $record->reset(); + $result = $record->load($pk); + + if (!$result) + { + // If the record does not exist I will stomp my feet and deny your request + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + } + + $user = Factory::getApplication()->getIdentity() + ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + // The user must be a registered user, not a guest + if ($user->guest) + { + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + // Save flags used onAfterDelete + $this->deleteFlags[$record->id] = [ + 'default' => $record->default, + // phpcs:ignore + 'numRecords' => $this->getNumRecords($record->user_id), + // phpcs:ignore + 'user_id' => $record->user_id, + 'method' => $record->method, + ]; + + if (\is_null($pk)) + { + // phpcs:ignore + $pk = [$this->_tbl_key => $this->id]; + } + elseif (!\is_array($pk)) + { + // phpcs:ignore + $pk = [$this->_tbl_key => $pk]; + } + + $isDeleted = parent::delete($pk); + + if ($isDeleted) + { + $this->afterDelete($pk); + } + + return $isDeleted; + } + + /** + * Decrypt the possibly encrypted options + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function decryptOptions(): void + { + // Try with modern decryption + $decrypted = @json_decode($this->encryptService->decrypt($this->options ?? ''), true); + + if (is_string($decrypted)) + { + $decrypted = @json_decode($decrypted, true); + } + + // Fall back to legacy decryption + if (!is_array($decrypted)) + { + $decrypted = @json_decode($this->encryptService->decrypt($this->options ?? '', true), true); + + if (is_string($decrypted)) + { + $decrypted = @json_decode($decrypted, true); + } + } + + $this->options = $decrypted ?: []; + } + + /** + * If this record is set to be the default, unset the default flag from the other records for the same user. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function switchDefaultRecord(): void + { + if (!$this->default) + { + return; + } + + /** + * This record is marked as default, therefore we need to unset the default flag from all other records for this + * user. + */ + $db = $this->getDbo(); + $query = $db->getQuery(true) + ->update($db->quoteName('#__user_mfa')) + ->set($db->quoteName('default') . ' = 0') + ->where($db->quoteName('user_id') . ' = :user_id') + ->where($db->quoteName('id') . ' != :id') + // phpcs:ignore + ->bind(':user_id', $this->user_id, ParameterType::INTEGER) + ->bind(':id', $this->id, ParameterType::INTEGER); + $db->setQuery($query)->execute(); + } + + /** + * Regenerate backup code is the flag is set. + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + private function generateBackupCodes(): void + { + /** @var MVCFactoryInterface $factory */ + $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory(); + + /** @var BackupcodesModel $backupCodes */ + $backupCodes = $factory->createModel('Backupcodes', 'Administrator'); + // phpcs:ignore + $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($this->user_id); + $backupCodes->regenerateBackupCodes($user); + } + + /** + * Runs after successfully deleting a record + * + * @param int|array $pk The promary key of the deleted record + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function afterDelete($pk): void + { + if (is_array($pk)) + { + // phpcs:ignore + $pk = $pk[$this->_tbl_key] ?? array_shift($pk); + } + + if (!isset($this->deleteFlags[$pk])) + { + return; + } + + if (($this->deleteFlags[$pk]['numRecords'] <= 2) && ($this->deleteFlags[$pk]['method'] != 'backupcodes')) + { + /** + * This was the second to last MFA record in the database (the last one is the `backupcodes`). Therefore, we + * need to delete the remaining entry and go away. We don't trigger this if the Method we are deleting was + * the `backupcodes` because we might just be regenerating the backup codes. + */ + $db = $this->getDbo(); + $query = $db->getQuery(true) + ->delete($db->quoteName('#__user_mfa')) + ->where($db->quoteName('user_id') . ' = :user_id') + ->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER); + $db->setQuery($query)->execute(); + + unset($this->deleteFlags[$pk]); + + return; + } + + // This was the default record. Promote the next available record to default. + if ($this->deleteFlags[$pk]['default']) + { + $db = $this->getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__user_mfa')) + ->where($db->quoteName('user_id') . ' = :user_id') + ->where($db->quoteName('method') . ' != ' . $db->quote('backupcodes')) + ->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER); + $ids = $db->setQuery($query)->loadColumn(); + + if (empty($ids)) + { + return; + } + + $id = array_shift($ids); + $query = $db->getQuery(true) + ->update($db->quoteName('#__user_mfa')) + ->set($db->quoteName('default') . ' = 1') + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query)->execute(); + } + } + + /** + * Get the number of MFA records for a give user ID + * + * @param int $userId The user ID to check + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + private function getNumRecords(int $userId): int + { + $db = $this->getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__user_mfa')) + ->where($db->quoteName('user_id') . ' = :user_id') + ->bind(':user_id', $userId, ParameterType::INTEGER); + $numOldRecords = $db->setQuery($query)->loadResult(); + + return (int) $numOldRecords; + } +} diff --git a/administrator/components/com_users/src/View/Captive/HtmlView.php b/administrator/components/com_users/src/View/Captive/HtmlView.php new file mode 100644 index 0000000000000..5143bb4c6505e --- /dev/null +++ b/administrator/components/com_users/src/View/Captive/HtmlView.php @@ -0,0 +1,228 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\View\Captive; + +use Exception; +use Joomla\CMS\Event\MultiFactor\BeforeDisplayMethods; +use Joomla\CMS\Event\MultiFactor\NotifyActionLog; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\Button\BasicButton; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarFactoryInterface; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Component\Users\Administrator\Model\BackupcodesModel; +use Joomla\Component\Users\Administrator\Model\CaptiveModel; +use Joomla\Component\Users\Administrator\View\SiteTemplateTrait; +use stdClass; + +/** + * View for Multi-factor Authentication captive page + * + * @since __DEPLOY_VERSION__ + */ +class HtmlView extends BaseHtmlView +{ + use SiteTemplateTrait; + + /** + * The MFA Method records for the current user which correspond to enabled plugins + * + * @var array + * @since __DEPLOY_VERSION__ + */ + public $records = []; + + /** + * The currently selected MFA Method record against which we'll be authenticating + * + * @var null|stdClass + * @since __DEPLOY_VERSION__ + */ + public $record = null; + + /** + * The Captive MFA page's rendering options + * + * @var array|null + * @since __DEPLOY_VERSION__ + */ + public $renderOptions = null; + + /** + * The title to display at the top of the page + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public $title = ''; + + /** + * Is this an administrator page? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $isAdmin = false; + + /** + * Does the currently selected Method allow authenticating against all of its records? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $allowEntryBatching = false; + + /** + * All enabled MFA Methods (plugins) + * + * @var array + * @since __DEPLOY_VERSION__ + */ + public $mfaMethods; + + /** + * Execute and display a template script. + * + * @param string $tpl The name of the template file to parse; automatically searches through the template paths. + * + * @return void A string if successful, otherwise an Error object. + * + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + public function display($tpl = null) + { + $this->setSiteTemplateStyle(); + + $app = Factory::getApplication(); + $user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + + $event = new BeforeDisplayMethods($user); + $app->getDispatcher()->dispatch($event->getName(), $event); + + /** @var CaptiveModel $model */ + $model = $this->getModel(); + + // Load data from the model + $this->isAdmin = $app->isClient('administrator'); + $this->records = $this->get('records'); + $this->record = $this->get('record'); + $this->mfaMethods = MfaHelper::getMfaMethods(); + + if (!empty($this->records)) + { + /** @var BackupcodesModel $codesModel */ + $codesModel = $this->getModel('Backupcodes'); + $backupCodesRecord = $codesModel->getBackupCodesRecord(); + + if (!is_null($backupCodesRecord)) + { + $backupCodesRecord->title = Text::_('COM_USERS_USER_BACKUPCODES'); + $this->records[] = $backupCodesRecord; + } + } + + // If we only have one record there's no point asking the user to select a MFA Method + if (empty($this->record) && !empty($this->records)) + { + // Default to the first record + $this->record = reset($this->records); + + // If we have multiple records try to make this record the default + if (count($this->records) > 1) + { + foreach ($this->records as $record) + { + if ($record->default) + { + $this->record = $record; + + break; + } + } + } + } + + // Set the correct layout based on the availability of a MFA record + $this->setLayout('default'); + + // If we have no record selected or explicitly asked to run the 'select' task use the correct layout + if (is_null($this->record) || ($model->getState('task') == 'select')) + { + $this->setLayout('select'); + } + + switch ($this->getLayout()) + { + case 'select': + $this->allowEntryBatching = 1; + + $event = new NotifyActionLog('onComUsersCaptiveShowSelect', []); + Factory::getApplication()->getDispatcher()->dispatch($event->getName(), $event); + break; + + case 'default': + default: + $this->renderOptions = $model->loadCaptiveRenderOptions($this->record); + $this->allowEntryBatching = $this->renderOptions['allowEntryBatching'] ?? 0; + + $event = new NotifyActionLog( + 'onComUsersCaptiveShowCaptive', + [ + $this->escape($this->record->title), + ] + ); + Factory::getApplication()->getDispatcher()->dispatch($event->getName(), $event); + break; + } + + // Which title should I use for the page? + $this->title = $this->get('PageTitle'); + + // Back-end: always show a title in the 'title' module position, not in the page body + if ($this->isAdmin) + { + ToolbarHelper::title(Text::_('COM_USERS_HEADING_MFA'), 'users user-lock'); + $this->title = ''; + } + + if ($this->isAdmin && $this->getLayout() === 'default') + { + $bar = Toolbar::getInstance(); + $button = (new BasicButton('user-mfa-submit')) + ->text($this->renderOptions['submit_text']) + ->icon($this->renderOptions['submit_icon']); + $bar->appendButton($button); + + $button = (new BasicButton('user-mfa-logout')) + ->text('COM_USERS_MFA_LOGOUT') + ->buttonClass('btn btn-danger') + ->icon('icon icon-lock'); + $bar->appendButton($button); + + if (count($this->records) > 1) + { + $arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left'; + $button = (new BasicButton('user-mfa-choose-another')) + ->text('COM_USERS_MFA_USE_DIFFERENT_METHOD') + ->icon('icon-' . $arrow); + $bar->appendButton($button); + } + } + + // Display the view + parent::display($tpl); + } +} diff --git a/administrator/components/com_users/src/View/Method/HtmlView.php b/administrator/components/com_users/src/View/Method/HtmlView.php new file mode 100644 index 0000000000000..7b6c7a55f1b0c --- /dev/null +++ b/administrator/components/com_users/src/View/Method/HtmlView.php @@ -0,0 +1,226 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\View\Method; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Button\BasicButton; +use Joomla\CMS\Toolbar\Button\LinkButton; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Model\MethodModel; + +/** + * View for Multi-factor Authentication method add/edit page + * + * @since __DEPLOY_VERSION__ + */ +class HtmlView extends BaseHtmlView +{ + /** + * Is this an administrator page? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $isAdmin = false; + + /** + * The editor page render options + * + * @var array + * @since __DEPLOY_VERSION__ + */ + public $renderOptions = []; + + /** + * The MFA Method record being edited + * + * @var object + * @since __DEPLOY_VERSION__ + */ + public $record = null; + + /** + * The title text for this page + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public $title = ''; + + /** + * The return URL to use for all links and forms + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public $returnURL = null; + + /** + * The user object used to display this page + * + * @var User + * @since __DEPLOY_VERSION__ + */ + public $user = null; + + /** + * The backup codes for the current user. Only applies when the backup codes record is being "edited" + * + * @var array + * @since __DEPLOY_VERSION__ + */ + public $backupCodes = []; + + /** + * Am I editing an existing Method? If it's false then I'm adding a new Method. + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $isEditExisting = false; + + /** + * Execute and display a template script. + * + * @param string $tpl The name of the template file to parse; automatically searches through the template paths. + * + * @return void + * + * @throws \Exception + * @see \JViewLegacy::loadTemplate() + * @since __DEPLOY_VERSION__ + */ + public function display($tpl = null): void + { + $app = Factory::getApplication(); + + if (empty($this->user)) + { + $this->user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + /** @var MethodModel $model */ + $model = $this->getModel(); + $this->setLayout('edit'); + $this->renderOptions = $model->getRenderOptions($this->user); + $this->record = $model->getRecord($this->user); + $this->title = $model->getPageTitle(); + $this->isAdmin = $app->isClient('administrator'); + + // Backup codes are a special case, rendered with a special layout + if ($this->record->method == 'backupcodes') + { + $this->setLayout('backupcodes'); + + $backupCodes = $this->record->options; + + if (!is_array($backupCodes)) + { + $backupCodes = []; + } + + $backupCodes = array_filter( + $backupCodes, + function ($x) { + return !empty($x); + } + ); + + if (count($backupCodes) % 2 != 0) + { + $backupCodes[] = ''; + } + + /** + * The call to array_merge resets the array indices. This is necessary since array_filter kept the indices, + * meaning our elements are completely out of order. + */ + $this->backupCodes = array_merge($backupCodes); + } + + // Set up the isEditExisting property. + $this->isEditExisting = !empty($this->record->id); + + // Back-end: always show a title in the 'title' module position, not in the page body + if ($this->isAdmin) + { + ToolbarHelper::title($this->title, 'users user-lock'); + + $helpUrl = $this->renderOptions['help_url']; + + if (!empty($helpUrl)) + { + ToolbarHelper::help('', false, $helpUrl); + } + + $this->title = ''; + } + + $returnUrl = empty($this->returnURL) ? '' : base64_decode($this->returnURL); + $returnUrl = $returnUrl ?: Route::_('index.php?option=com_users&task=methods.display&user_id=' . $this->user->id); + + if ($this->isAdmin && $this->getLayout() === 'edit') + { + $bar = Toolbar::getInstance(); + $button = (new BasicButton('user-mfa-edit-save')) + ->text($this->renderOptions['submit_text']) + ->icon($this->renderOptions['submit_icon']) + ->onclick('document.getElementById(\'user-mfa-edit-save\').click()'); + + if ($this->renderOptions['show_submit'] || $this->isEditExisting) + { + $bar->appendButton($button); + } + + $button = (new LinkButton('user-mfa-edit-cancel')) + ->text('JCANCEL') + ->buttonClass('btn btn-danger') + ->icon('icon-cancel-2') + ->url($returnUrl); + $bar->appendButton($button); + } + elseif ($this->isAdmin && $this->getLayout() === 'backupcodes') + { + $bar = Toolbar::getInstance(); + + $arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left'; + $button = (new LinkButton('user-mfa-edit-cancel')) + ->text('JTOOLBAR_BACK') + ->icon('icon-' . $arrow) + ->url($returnUrl); + $bar->appendButton($button); + + $button = (new LinkButton('user-mfa-edit-cancel')) + ->text('COM_USERS_MFA_BACKUPCODES_RESET') + ->buttonClass('btn btn-danger') + ->icon('icon-refresh') + ->url( + Route::_( + sprintf( + "index.php?option=com_users&task=method.regenerateBackupCodes&user_id=%s&%s=1&returnurl=%s", + $this->user->id, + Factory::getApplication()->getFormToken(), + base64_encode($returnUrl) + ) + ) + ); + $bar->appendButton($button); + } + + // Display the view + parent::display($tpl); + } +} diff --git a/administrator/components/com_users/src/View/Methods/HtmlView.php b/administrator/components/com_users/src/View/Methods/HtmlView.php new file mode 100644 index 0000000000000..c3c3473b5baa4 --- /dev/null +++ b/administrator/components/com_users/src/View/Methods/HtmlView.php @@ -0,0 +1,200 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\View\Methods; + +use Joomla\CMS\Event\MultiFactor\NotifyActionLog; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor; +use Joomla\Component\Users\Administrator\Model\BackupcodesModel; +use Joomla\Component\Users\Administrator\Model\MethodsModel; +use Joomla\Component\Users\Administrator\View\SiteTemplateTrait; + +/** + * View for Multi-factor Authentication methods list page + * + * @since __DEPLOY_VERSION__ + */ +class HtmlView extends BaseHtmlView +{ + use SiteTemplateTrait; + + /** + * Is this an administrator page? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $isAdmin = false; + + /** + * The MFA Methods available for this user + * + * @var array + * @since __DEPLOY_VERSION__ + */ + public $methods = []; + + /** + * The return URL to use for all links and forms + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public $returnURL = null; + + /** + * Are there any active MFA Methods at all? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $mfaActive = false; + + /** + * Which Method has the default record? + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public $defaultMethod = ''; + + /** + * The user object used to display this page + * + * @var User + * @since __DEPLOY_VERSION__ + */ + public $user = null; + + /** + * Is this page part of the mandatory Multi-factor Authentication setup? + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + public $isMandatoryMFASetup = false; + + /** + * Execute and display a template script. + * + * @param string $tpl The name of the template file to parse; automatically searches through the template paths. + * + * @return void + * + * @throws \Exception + * @see \JViewLegacy::loadTemplate() + * @since __DEPLOY_VERSION__ + */ + public function display($tpl = null): void + { + $this->setSiteTemplateStyle(); + + $app = Factory::getApplication(); + + if (empty($this->user)) + { + $this->user = Factory::getApplication()->getIdentity() + ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); + } + + /** @var MethodsModel $model */ + $model = $this->getModel(); + + if ($this->getLayout() !== 'firsttime') + { + $this->setLayout('default'); + } + + $this->methods = $model->getMethods($this->user); + $this->isAdmin = $app->isClient('administrator'); + $activeRecords = 0; + + foreach ($this->methods as $methodName => $method) + { + $methodActiveRecords = count($method['active']); + + if (!$methodActiveRecords) + { + continue; + } + + $activeRecords += $methodActiveRecords; + $this->mfaActive = true; + + foreach ($method['active'] as $record) + { + if ($record->default) + { + $this->defaultMethod = $methodName; + + break; + } + } + } + + // If there are no backup codes yet we should create new ones + /** @var BackupcodesModel $model */ + $model = $this->getModel('backupcodes'); + $backupCodes = $model->getBackupCodes($this->user); + + if ($activeRecords && empty($backupCodes)) + { + $model->regenerateBackupCodes($this->user); + } + + $backupCodesRecord = $model->getBackupCodesRecord($this->user); + + if (!is_null($backupCodesRecord)) + { + $this->methods = array_merge( + [ + 'backupcodes' => new MethodDescriptor( + [ + 'name' => 'backupcodes', + 'display' => Text::_('COM_USERS_USER_BACKUPCODES'), + 'shortinfo' => Text::_('COM_USERS_USER_BACKUPCODES_DESC'), + 'image' => 'media/com_users/images/emergency.svg', + 'canDisable' => false, + 'active' => [$backupCodesRecord], + ] + ) + ], + $this->methods + ); + } + + $this->isMandatoryMFASetup = $activeRecords === 0 && $app->getSession()->get('com_users.mandatory_mfa_setup', 0) === 1; + + // Back-end: always show a title in the 'title' module position, not in the page body + if ($this->isAdmin) + { + ToolbarHelper::title(Text::_('COM_USERS_MFA_LIST_PAGE_HEAD'), 'users user-lock'); + + if (Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_users')) + { + ToolbarHelper::back('JTOOLBAR_BACK', Route::_('index.php?option=com_users')); + } + } + + // Display the view + parent::display($tpl); + + $event = new NotifyActionLog('onComUsersViewMethodsAfterDisplay', [$this]); + Factory::getApplication()->getDispatcher()->dispatch($event->getName(), $event); + + Text::script('JGLOBAL_CONFIRM_DELETE'); + } +} diff --git a/administrator/components/com_users/src/View/SiteTemplateTrait.php b/administrator/components/com_users/src/View/SiteTemplateTrait.php new file mode 100644 index 0000000000000..1f029364b79a0 --- /dev/null +++ b/administrator/components/com_users/src/View/SiteTemplateTrait.php @@ -0,0 +1,69 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Users\Administrator\View; + +use Exception; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use ReflectionException; +use ReflectionObject; + +/** + * Dynamically modify the frontend template when showing a MFA captive page. + * + * @since __DEPLOY_VERSION__ + */ +trait SiteTemplateTrait +{ + /** + * Set a specific site template style in the frontend application + * + * @return void + * @throws Exception + * @since __DEPLOY_VERSION__ + */ + private function setSiteTemplateStyle(): void + { + $app = Factory::getApplication(); + $templateStyle = (int) ComponentHelper::getParams('com_users')->get('captive_template', ''); + + if (empty($templateStyle) || !$app->isClient('site')) + { + return; + } + + $itemId = $app->input->get('Itemid'); + + if (!empty($itemId)) + { + return; + } + + $app->input->set('templateStyle', $templateStyle); + + try + { + $refApp = new ReflectionObject($app); + $refTemplate = $refApp->getProperty('template'); + $refTemplate->setAccessible(true); + $refTemplate->setValue($app, null); + } + catch (ReflectionException $e) + { + return; + } + + $template = $app->getTemplate(true); + + $app->set('theme', $template->template); + $app->set('themeParams', $template->params); + } + +} diff --git a/administrator/components/com_users/src/View/User/HtmlView.php b/administrator/components/com_users/src/View/User/HtmlView.php index d2e40a73aa564..9e10f02d1e99e 100644 --- a/administrator/components/com_users/src/View/User/HtmlView.php +++ b/administrator/components/com_users/src/View/User/HtmlView.php @@ -18,6 +18,9 @@ use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\Object\CMSObject; use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\CMS\User\User; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Users\Administrator\Helper\Mfa; /** * User view class. @@ -63,21 +66,12 @@ class HtmlView extends BaseHtmlView protected $state; /** - * Configuration forms for all two-factor authentication methods + * The Multi-factor Authentication configuration interface for the user. * - * @var array - * @since 3.2 + * @var string|null + * @since __DEPLOY_VERSION__ */ - protected $tfaform; - - /** - * Returns the one time password (OTP) – a.k.a. two factor authentication – - * configuration for the user. - * - * @var \stdClass - * @since 3.2 - */ - protected $otpConfig; + protected $mfaConfigurationUI; /** * Display the view @@ -98,10 +92,8 @@ public function display($tpl = null) $app->redirect('index.php?option=com_users&view=users'); } - $this->form = $this->get('Form'); - $this->state = $this->get('State'); - $this->tfaform = $this->get('Twofactorform'); - $this->otpConfig = $this->get('otpConfig'); + $this->form = $this->get('Form'); + $this->state = $this->get('State'); // Check for errors. if (count($errors = $this->get('Errors'))) @@ -121,7 +113,28 @@ public function display($tpl = null) $this->form->setValue('password', null); $this->form->setValue('password2', null); + /** @var User $userBeingEdited */ + $userBeingEdited = Factory::getContainer() + ->get(UserFactoryInterface::class) + ->loadUserById($this->item->id); + + if ($this->item->id > 0 && (int) $userBeingEdited->id == (int) $this->item->id) + { + try + { + $this->mfaConfigurationUI = Mfa::canShowConfigurationInterface($userBeingEdited) + ? Mfa::getConfigurationInterface($userBeingEdited) + : ''; + } + catch (\Exception $e) + { + // In case something goes really wrong with the plugins; prevents hard breaks. + $this->mfaConfigurationUI = null; + } + } + parent::display($tpl); + $this->addToolbar(); } diff --git a/administrator/components/com_users/tmpl/captive/default.php b/administrator/components/com_users/tmpl/captive/default.php new file mode 100644 index 0000000000000..a2a9bcd0a41bc --- /dev/null +++ b/administrator/components/com_users/tmpl/captive/default.php @@ -0,0 +1,135 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\Component\Users\Administrator\Model\CaptiveModel; +use Joomla\Component\Users\Administrator\View\Captive\HtmlView; +use Joomla\Utilities\ArrayHelper; + +// phpcs:ignoreFile + +/** + * @var HtmlView $this View object + * @var CaptiveModel $model The model + */ +$model = $this->getModel(); + +$this->document->getWebAssetManager() + ->useScript('com_users.two-factor-focus'); + +?> +
+

+ title)): ?> + title ?> – + + allowEntryBatching): ?> + escape($this->record->title) ?> + + escape($this->getModel()->translateMethodName($this->record->method)) ?> + + title)): ?> + + + renderOptions['help_url'])): ?> + + + + + + + +

+ + renderOptions['pre_message']): ?> +
+ renderOptions['pre_message'] ?> +
+ + +
+ + +
+ renderOptions['field_type'] == 'custom'): ?> + renderOptions['html']; ?> + +
+ renderOptions['label']): ?> + + + $this->renderOptions['input_type'], + 'name' => 'code', + 'value' => '', + 'placeholder' => $this->renderOptions['placeholder'] ?? null, + 'id' => 'users-mfa-code', + 'class' => 'form-control' + ], + $this->renderOptions['input_attributes'] + ); + + if (strpos($attributes['class'], 'form-control') === false) + { + $attributes['class'] .= ' form-control'; + } + ?> + > +
+
+ +
+
+ + + + + + + + records) > 1): ?> + + + + +
+
+
+ + renderOptions['post_message']): ?> +
+ renderOptions['post_message'] ?> +
+ + +
diff --git a/administrator/components/com_users/tmpl/captive/select.php b/administrator/components/com_users/tmpl/captive/select.php new file mode 100644 index 0000000000000..236082589fd52 --- /dev/null +++ b/administrator/components/com_users/tmpl/captive/select.php @@ -0,0 +1,75 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Prevent direct access +defined('_JEXEC') or die; + +use Joomla\Component\Users\Administrator\View\Captive\HtmlView; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; + +// phpcs:ignoreFile +/** @var HtmlView $this */ + +$shownMethods = []; + +?> +
+

+ +

+
+

+ +

+
+ +
+ records as $record): + if (!array_key_exists($record->method, $this->mfaMethods) && ($record->method != 'backupcodes')) continue; + + $allowEntryBatching = isset($this->mfaMethods[$record->method]) ? $this->mfaMethods[$record->method]['allowEntryBatching'] : false; + + if ($this->allowEntryBatching) + { + if ($allowEntryBatching && in_array($record->method, $shownMethods)) continue; + $shownMethods[] = $record->method; + } + + $methodName = $this->getModel()->translateMethodName($record->method); + ?> + + <?php echo $this->escape(strip_tags($record->title)) ?> + allowEntryBatching || !$allowEntryBatching): ?> + + method === 'backupcodes'): ?> + title ?> + + escape($record->title) ?> + + + + + + + + + + + + + + + +
+
diff --git a/administrator/components/com_users/tmpl/method/backupcodes.php b/administrator/components/com_users/tmpl/method/backupcodes.php new file mode 100644 index 0000000000000..26cac8e9ba5b5 --- /dev/null +++ b/administrator/components/com_users/tmpl/method/backupcodes.php @@ -0,0 +1,82 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Prevent direct access +defined('_JEXEC') or die; + +use Joomla\Component\Users\Administrator\View\Method\HtmlView; +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +// phpcs:ignoreFile +/** @var HtmlView $this */ + +HTMLHelper::_('bootstrap.tooltip', '.hasTooltip'); + +$cancelURL = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $this->user->id); + +if (!empty($this->returnURL)) +{ + $cancelURL = $this->escape(base64_decode($this->returnURL)); +} + +if ($this->record->method != 'backupcodes') +{ + throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); +} + +?> +

+ +

+ +

+ +

+ + + backupCodes) / 2); $i++): ?> + + + + + +
+ backupCodes[2 * $i])): ?> + + + backupCodes[2 * $i] ?> + + + backupCodes[1 + 2 * $i])): ?> + + + backupCodes[1 + 2 * $i] ?> + +
+ +
+ + +
+ + diff --git a/administrator/components/com_users/tmpl/method/edit.php b/administrator/components/com_users/tmpl/method/edit.php new file mode 100644 index 0000000000000..952ae9f48e82d --- /dev/null +++ b/administrator/components/com_users/tmpl/method/edit.php @@ -0,0 +1,187 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Prevent direct access +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\Component\Users\Administrator\View\Method\HtmlView; +use Joomla\Utilities\ArrayHelper; + +// phpcs:ignoreFile +/** @var HtmlView $this */ + +$cancelURL = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $this->user->id); + +if (!empty($this->returnURL)) +{ + $cancelURL = $this->escape(base64_decode($this->returnURL)); +} + +$recordId = (int)$this->record->id ?? 0; +$method = $this->record->method ?? $this->getModel()->getState('method'); +$userId = (int)$this->user->id ?? 0; +$headingLevel = 2; +$hideSubmit = !$this->renderOptions['show_submit'] && !$this->isEditExisting +?> +
+
" + class="form form-horizontal" id="com-users-method-edit" method="post"> + + returnURL)): ?> + + + + renderOptions['hidden_data'])): ?> + renderOptions['hidden_data'] as $key => $value): ?> + + + + + title)): ?> + renderOptions['help_url'])): ?> + + + + + + + + id="com-users-method-edit-head"> + title) ?> + > + + + +
+ +
+ +

+ escape(Text::_('COM_USERS_MFA_EDIT_FIELD_TITLE_DESC')) ?> +

+
+
+ +
+
+
+ record->default ? 'checked="checked"' : ''; ?> name="default"> + +
+
+
+ + renderOptions['pre_message'])): ?> +
+ renderOptions['pre_message'] ?> +
+ + + renderOptions['tabular_data'])): ?> +
+ renderOptions['table_heading'])): ?> + class="h3 border-bottom mb-3"> + renderOptions['table_heading'] ?> + > + + + + renderOptions['tabular_data'] as $cell1 => $cell2): ?> + + + + + + +
+ + + +
+
+ + + renderOptions['field_type'] == 'custom'): ?> + renderOptions['html']; ?> + +
+ renderOptions['label']): ?> + + +
renderOptions['label'] ? '' : 'offset-sm-3' ?>> + $this->renderOptions['input_type'], + 'name' => 'code', + 'value' => $this->escape($this->renderOptions['input_value']), + 'id' => 'com-users-method-code', + 'class' => 'form-control', + 'aria-describedby' => 'com-users-method-code-help', + ], + $this->renderOptions['input_attributes'] + ); + + if (strpos($attributes['class'], 'form-control') === false) + { + $attributes['class'] .= ' form-control'; + } + ?> + > +

+ escape($this->renderOptions['placeholder']) ?> +

+
+
+ +
+
+
+ + + + + + +
+
+
+ + renderOptions['post_message'])): ?> +
+ renderOptions['post_message'] ?> +
+ +
+
diff --git a/administrator/components/com_users/tmpl/methods/default.php b/administrator/components/com_users/tmpl/methods/default.php new file mode 100644 index 0000000000000..894f445cc2266 --- /dev/null +++ b/administrator/components/com_users/tmpl/methods/default.php @@ -0,0 +1,53 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Prevent direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\Component\Users\Administrator\View\Methods\HtmlView; + +// phpcs:ignoreFile +/** @var HtmlView $this */ +?> +
+
+
+ mfaActive ? 'ON' : 'OFF')) ?> +
+ mfaActive): ?> +
+ + + +
+ +
+ + methods)): ?> +
+ + +
+ isMandatoryMFASetup): ?> +
+

+ +

+

+ +

+
+ + + setLayout('list'); echo $this->loadTemplate(); ?> +
diff --git a/administrator/components/com_users/tmpl/methods/firsttime.php b/administrator/components/com_users/tmpl/methods/firsttime.php new file mode 100644 index 0000000000000..2507716a6af4f --- /dev/null +++ b/administrator/components/com_users/tmpl/methods/firsttime.php @@ -0,0 +1,49 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Prevent direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\Component\Users\Administrator\View\Methods\HtmlView; + +// phpcs:ignoreFile +/** @var HtmlView $this */ + +$headingLevel = 2; +?> +
+ isAdmin): ?> + id="com-users-methods-list-head"> + + > + +
+ class="alert-heading"> + + + > +

+ +

+ + + +
+ + setLayout('list'); echo $this->loadTemplate(); ?> +
diff --git a/administrator/components/com_users/tmpl/methods/list.php b/administrator/components/com_users/tmpl/methods/list.php new file mode 100644 index 0000000000000..4fb95e3c0cc97 --- /dev/null +++ b/administrator/components/com_users/tmpl/methods/list.php @@ -0,0 +1,144 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Prevent direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; +use Joomla\Component\Users\Administrator\Model\MethodsModel; +use Joomla\Component\Users\Administrator\View\Methods\HtmlView; + +// phpcs:ignoreFile +/** @var HtmlView $this */ + +HTMLHelper::_('bootstrap.tooltip', '.hasTooltip'); + +/** @var MethodsModel $model */ +$model = $this->getModel(); + +$this->document->getWebAssetManager()->useScript('com_users.two-factor-list'); + +$canAddEdit = MfaHelper::canAddEditMethod($this->user); +$canDelete = MfaHelper::canDeleteMethod($this->user); +?> +
+ methods as $methodName => $method): + $methodClass = 'com-users-methods-list-method-name-' . htmlentities($method['name']) + . ($this->defaultMethod == $methodName ? ' com-users-methods-list-method-default' : ''); + ?> +
+
+
+ <?php echo $this->escape($method['display']) ?> +
+
+

+ + + + defaultMethod == $methodName): ?> + + + + +

+
+
+ +
+
+ +
+ + +
+ +
+
+ +
+ + id . ($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') . '&user_id=' . $this->user->id)) ?> + +
+ +

+ default): ?> + + + escape(Text::_('COM_USERS_MFA_LIST_DEFAULTTAG')) ?> + + + + escape($record->title); ?> + +

+ + +
+ + formatRelative($record->created_on)) ?> + + + formatRelative($record->last_used)) ?> + +
+ +
+ + + + +
+ +
+ + + + + +
+
+ +
diff --git a/administrator/components/com_users/tmpl/user/edit.php b/administrator/components/com_users/tmpl/user/edit.php index 7515e95c3ec44..1df46ed313115 100644 --- a/administrator/components/com_users/tmpl/user/edit.php +++ b/administrator/components/com_users/tmpl/user/edit.php @@ -16,11 +16,12 @@ use Joomla\CMS\Router\Route; use Joomla\Component\Users\Administrator\Helper\UsersHelper; +/** @var Joomla\Component\Users\Administrator\View\User\HtmlView $this */ + /** @var Joomla\CMS\WebAsset\WebAssetManager $wa */ $wa = $this->document->getWebAssetManager(); $wa->useScript('keepalive') - ->useScript('form.validate') - ->useScript('com_users.two-factor-switcher'); + ->useScript('form.validate'); $input = Factory::getApplication()->input; @@ -63,50 +64,12 @@ echo LayoutHelper::render('joomla.edit.params', $this); ?> - tfaform) && $this->item->id) : ?> - + mfaConfigurationUI)) : ?> +
- -
-
- -
-
- 'Joomla.twoFactorMethodChange();', 'class' => 'form-select'), 'value', 'text', $this->otpConfig->method, 'jform_twofactor_method', false); ?> -
-
-
- tfaform as $form) : ?> - otpConfig->method ? '' : ' class="hidden"'; ?> -
> - -
- -
+ + mfaConfigurationUI ?>
-
- -

- -

- -
- - -
- otpConfig->otep)) : ?> -
- - -
- - otpConfig->otep as $otep) : ?> -
- - - diff --git a/administrator/components/com_users/tmpl/users/default.php b/administrator/components/com_users/tmpl/users/default.php index 874bbf9b9c605..3571ea5488fe7 100644 --- a/administrator/components/com_users/tmpl/users/default.php +++ b/administrator/components/com_users/tmpl/users/default.php @@ -18,6 +18,10 @@ use Joomla\CMS\Router\Route; use Joomla\CMS\String\PunycodeHelper; +/** @var \Joomla\Component\Users\Administrator\View\Users\HtmlView $this */ + +// phpcs:ignoreFile + /** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */ $wa = $this->document->getWebAssetManager(); $wa->useScript('table.columns') @@ -26,7 +30,7 @@ $listOrder = $this->escape($this->state->get('list.ordering')); $listDirn = $this->escape($this->state->get('list.direction')); $loggeduser = Factory::getUser(); -$tfa = PluginHelper::isEnabled('twofactorauth'); +$mfa = PluginHelper::isEnabled('multifactorauth'); ?>
@@ -66,9 +70,9 @@ - + - + @@ -149,15 +153,19 @@ echo HTMLHelper::_('jgrid.state', HTMLHelper::_('users.activateStates'), $activated, $i, 'users.', (boolean) $activated); ?> - + - otpKey)) : ?> - - + mfaRecords > 0 || !empty($item->otpKey)) : ?> + + - - + + diff --git a/administrator/components/com_users/users.xml b/administrator/components/com_users/users.xml index 3f494871f0e3c..cbcef9b849501 100644 --- a/administrator/components/com_users/users.xml +++ b/administrator/components/com_users/users.xml @@ -29,6 +29,7 @@ users.xml forms helpers + postinstall services src tmpl diff --git a/administrator/language/en-GB/com_login.ini b/administrator/language/en-GB/com_login.ini index f9959904fd99f..cc5788e54138e 100644 --- a/administrator/language/en-GB/com_login.ini +++ b/administrator/language/en-GB/com_login.ini @@ -6,6 +6,5 @@ COM_LOGIN="Login" COM_LOGIN_JOOMLA_ADMINISTRATION_LOGIN="Joomla! Administration Login" COM_LOGIN_RETURN_TO_SITE_HOME_PAGE="Go to site home page" -COM_LOGIN_TWOFACTOR="For Two-Factor Authentication" COM_LOGIN_VALID="Use a valid username and password to gain access to the Administrator Backend." COM_LOGIN_XML_DESCRIPTION="This component lets users login to the site." diff --git a/administrator/language/en-GB/com_users.ini b/administrator/language/en-GB/com_users.ini index 2dfa4f441a46e..bf526b33e653c 100644 --- a/administrator/language/en-GB/com_users.ini +++ b/administrator/language/en-GB/com_users.ini @@ -22,6 +22,10 @@ COM_USERS_BATCH_SET="Move To Group" COM_USERS_CATEGORIES_TITLE="User Notes: Categories" COM_USERS_CATEGORY_HEADING="Category" COM_USERS_CONFIGURATION="Users: Options" +COM_USERS_CONFIG_ALLOWED_POSITIONS_BACKEND_DESC="When displaying the backend Multi-factor Authentication page all modules will be hidden except those in the positions selected here. Please note that modules in the title position are always shown: this is required to show the backend page icon and title." +COM_USERS_CONFIG_ALLOWED_POSITIONS_BACKEND_LABEL="Allowed backend module positions" +COM_USERS_CONFIG_ALLOWED_POSITIONS_FRONTEND_DESC="When displaying the frontend Multi-factor Authentication page all modules will be hidden except those in the positions selected here." +COM_USERS_CONFIG_ALLOWED_POSITIONS_FRONTEND_LABEL="Allowed frontend module positions" COM_USERS_CONFIG_DOMAIN_OPTIONS="Email Domain Options" COM_USERS_CONFIG_FIELD_ALLOWREGISTRATION_LABEL="Allow User Registration" COM_USERS_CONFIG_FIELD_CAPTCHA_LABEL="Captcha" @@ -34,12 +38,6 @@ COM_USERS_CONFIG_FIELD_DOMAIN_RULE_DESC="Select whether to allow or disallow the COM_USERS_CONFIG_FIELD_DOMAIN_RULE_LABEL="Rule" COM_USERS_CONFIG_FIELD_DOMAIN_RULE_OPTION_ALLOW="Allow" COM_USERS_CONFIG_FIELD_DOMAIN_RULE_OPTION_DISALLOW="Disallow" -COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_ADMIN="Admin (Backend)" -COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_BOTH="Both" -COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_DESC="You must enable at least one Two Factor Authentication plugin." -COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_LABEL="Enforce Two Factor Authentication" -COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_SITE="Site (Frontend)" -COM_USERS_CONFIG_FIELD_ENFORCE_2FA_GROUPS_LABEL="Enforce Two Factor Authentication for Usergroups" COM_USERS_CONFIG_FIELD_FRONTEND_LANG_LABEL="Frontend Language" COM_USERS_CONFIG_FIELD_FRONTEND_RESET_COUNT_LABEL="Maximum Reset Count" COM_USERS_CONFIG_FIELD_FRONTEND_RESET_TIME_LABEL="Reset Time (hours)" @@ -59,10 +57,29 @@ COM_USERS_CONFIG_FIELD_SUBJECT_PREFIX_LABEL="Subject Prefix" COM_USERS_CONFIG_FIELD_USERACTIVATION_LABEL="New User Account Activation" COM_USERS_CONFIG_FIELD_USERACTIVATION_OPTION_ADMINACTIVATION="Administrator" COM_USERS_CONFIG_FIELD_USERACTIVATION_OPTION_SELFACTIVATION="Self" +COM_USERS_CONFIG_FORCEMFAUSERGROUPS_DESC="Any user who belongs in any of the following user groups will be required to enable Multi-factor Authentication before being able to use the site." +COM_USERS_CONFIG_FORCEMFAUSERGROUPS_LABEL="Enforce Multi-factor Authentication" +COM_USERS_CONFIG_FRONTEND_CAPTIVE_TEMPLATE_DESC="Choose the frontend template style to use in the Multi-factor Authentication page. Select “- Use Default -” to use the default site template style." +COM_USERS_CONFIG_FRONTEND_CAPTIVE_TEMPLATE_LABEL="Frontend template style" +COM_USERS_CONFIG_FRONTEND_SHOW_TITLE_DESC="Should I display a title in the frontend Multi-factor Authentication verification page? Please note that the title is always displayed in the backend. If you need to change the title please override the language key COM_USERS_HEADING_MFA using the System, Manage, Language Overrides page of the site's backend." +COM_USERS_CONFIG_FRONTEND_SHOW_TITLE_LABEL="Show title in frontend" COM_USERS_CONFIG_IMPORT_FAILED="An error was encountered while importing the configuration: %s." COM_USERS_CONFIG_INTEGRATION_SETTINGS_DESC="These settings determine how the Users Component will integrate with other extensions." +COM_USERS_CONFIG_LBL_NOGROUP="( no group )" +COM_USERS_CONFIG_MFAONSILENT_DESC="Should the user have to go through Multi-factor Authentication after a silent user login? Silent logins are those which do not require a username and password e.g. the Remember Me feature, WebAuthn etc." +COM_USERS_CONFIG_MFAONSILENT_LABEL="Multi-factor Authentication after silent login" +COM_USERS_CONFIG_MULTIFACTORAUTH_SETTINGS_DESC="Configure how Multi-factor Authentication works in Joomla." +COM_USERS_CONFIG_MULTIFACTORAUTH_SETTINGS_LABEL="Multi-factor Authentication" +COM_USERS_CONFIG_NEVERMFAUSERGROUPS_DESC="Any user who belongs in any of the following user groups will be exempt from Multi-factor Authentication. Even if they have set up Multi-factor Authentication methods they will not be asked to use them when they are logging in, nor will they be able to view them, remove them, or change their configuration." +COM_USERS_CONFIG_NEVERMFAUSERGROUPS_LABEL="Disable Multi-factor Authentication" COM_USERS_CONFIG_PASSWORD_OPTIONS="Password Options" +COM_USERS_CONFIG_REDIRECTONLOGIN_DESC="If the user has not yet set up Multi-factor Authentication and this option is enabled they will be redirected to the Multi-factor Authentication setup page or the custom URL you set up below. This is meant to be a simple way to to let your users know that Multi-factor Authentication is an option on your site." +COM_USERS_CONFIG_REDIRECTONLOGIN_LABEL="Onboard new users" +COM_USERS_CONFIG_REDIRECTURL_DESC="If it's not empty redirects to this URL instead of the Multi-factor Authentication setup page when the option above is enabled. WARNING: This must be a URL inside your site. You cannot log in to an external link or to a different subdomain." +COM_USERS_CONFIG_REDIRECTURL_LABEL="Custom redirection URL" COM_USERS_CONFIG_SAVE_FAILED="An error was encountered while saving the configuration: %s." +COM_USERS_CONFIG_SILENTRESPONSES_DESC="For experts. A comma–separated list of Joomla authentication response types which are considered silent logins. The default is cookie (the Remember Me feature) and passwordless (WebAuthn)." +COM_USERS_CONFIG_SILENTRESPONSES_LABEL="Silent login authentication response types (for experts)" COM_USERS_CONFIG_USER_OPTIONS="User Options" COM_USERS_COUNT_DISABLED_USERS="Blocked Users" COM_USERS_COUNT_ENABLED_USERS="Enabled Users" @@ -83,7 +100,6 @@ COM_USERS_ERROR_CANNOT_BATCH_SUPERUSER="A non-Super User can't perform batch ope COM_USERS_ERROR_INVALID_GROUP="Invalid Group" COM_USERS_ERROR_LEVELS_NOLEVELS_SELECTED="No View Permission Level(s) selected." COM_USERS_ERROR_NO_ADDITIONS="The selected user(s) are already assigned to the selected group." -COM_USERS_ERROR_SECRET_CODE_WITHOUT_TFA="You have entered a Secret Code but two factor authentication is not enabled in your user account. If you want to use a secret code to secure your login please edit your user profile and enable two factor authentication." COM_USERS_ERROR_VIEW_LEVEL_IN_USE="You can't delete the view access level '%d:%s' because it is being used by content." COM_USERS_FIELDS_USER_FIELDS_TITLE="Users: Fields" COM_USERS_FIELDS_USER_FIELD_ADD_TITLE="Users: New Field" @@ -116,6 +132,7 @@ COM_USERS_FILTER_ACTIVE="- Select Active State -" COM_USERS_FILTER_NOTES="Show notes list" COM_USERS_FILTER_STATE="- Select State -" COM_USERS_FILTER_USERGROUP="- Select Group -" +COM_USERS_FILTER_MFA="- Multi-factor Authentication -" COM_USERS_GROUPS_CONFIRM_DELETE="Are you sure you wish to delete groups that have users?" COM_USERS_GROUPS_NO_ITEM_SELECTED="No User Groups selected." COM_USERS_GROUPS_N_ITEMS_DELETED="%d User Groups deleted." @@ -156,6 +173,7 @@ COM_USERS_HEADING_LEVEL_NAME_DESC="Level Name descending" COM_USERS_HEADING_LFT="LFT" COM_USERS_HEADING_LFT_ASC="LFT ascending" COM_USERS_HEADING_LFT_DESC="LFT descending" +COM_USERS_HEADING_MFA="Multi-factor Authentication" COM_USERS_HEADING_NAME="Name" COM_USERS_HEADING_REGISTRATION_DATE="Registered" COM_USERS_HEADING_REGISTRATION_DATE_ASC="Registration date ascending" @@ -166,13 +184,13 @@ COM_USERS_HEADING_REVIEW_DESC="Review Date descending" COM_USERS_HEADING_SUBJECT="Subject" COM_USERS_HEADING_SUBJECT_ASC="Subject ascending" COM_USERS_HEADING_SUBJECT_DESC="Subject descending" -COM_USERS_HEADING_TFA="Two Factor" COM_USERS_HEADING_USER="User" COM_USERS_HEADING_USERNAME_ASC="Username ascending" COM_USERS_HEADING_USERNAME_DESC="Username descending" COM_USERS_HEADING_USERS_IN_GROUP="Users in group" COM_USERS_HEADING_USER_ASC="User ascending" COM_USERS_HEADING_USER_DESC="User descending" +COM_USERS_LBL_SELECT_INSTRUCTIONS="Please select how you would like to verify your login to this site." COM_USERS_LEVELS_N_ITEMS_DELETED="%d View Permission Levels deleted." COM_USERS_LEVELS_N_ITEMS_DELETED_1="View Permission Level deleted." COM_USERS_LEVELS_TABLE_CAPTION="Table of Viewing Access Levels" @@ -229,9 +247,45 @@ COM_USERS_MASSMAIL_MAIL_BODY="{BODY} {BODYSUFFIX}" COM_USERS_MASSMAIL_MAIL_SUBJECT="{SUBJECTPREFIX} {SUBJECT}" COM_USERS_MASS_MAIL="Mass Mail Users" COM_USERS_MASS_MAIL_DESC="Mass Mail options." +COM_USERS_MFA_ACTIVE="Uses Multi-factor Authentication" +COM_USERS_MFA_ADD_AUTHENTICATOR_OF_TYPE="Add a new %s" +COM_USERS_MFA_ADD_PAGE_HEAD="Add a Multi-factor Authentication Method" +COM_USERS_MFA_BACKUPCODES_PRINT_PROMPT="Backup Codes let you log into the site if your regular Multi-factor Authentication method does not work or you no longer have access to it. Each code can be used only once." +COM_USERS_MFA_BACKUPCODES_PRINT_PROMPT_HEAD="Print these codes and keep them in your wallet." +COM_USERS_MFA_BACKUPCODES_RESET="Regenerate Backup Codes" +COM_USERS_MFA_BACKUPCODES_RESET_INFO="Use the “Regenerate Backup Codes” button on the toolbar to generate a new set of Backup Codes. We recommend that you do this if you think your Backup Codes are compromised, e.g. someone got hold of a printout with them, or if you are running low on available Backup Codes." +COM_USERS_MFA_EDIT_FIELD_DEFAULT="Make this the default Multi-factor Authentication method" +COM_USERS_MFA_EDIT_FIELD_TITLE="Title" +COM_USERS_MFA_EDIT_FIELD_TITLE_DESC="You and the site administrators will see this name in the list of available Multi-factor Authentication methods for your user account. Please do not include any sensitive or personally identifiable information." +COM_USERS_MFA_EDIT_PAGE_HEAD="Modify a Multi-factor Authentication method" +COM_USERS_MFA_FIRSTTIME_INSTRUCTIONS_HEAD="Use Multi-factor Authentication for added security" +COM_USERS_MFA_FIRSTTIME_INSTRUCTIONS_WHATITDOES="Here's how it works. Add a Multi-factor Authentication method below. From now on, every time you log into the site you will be asked to use this method to complete the login. Even if someone steals your username and password they won't have access to your account on this site." +COM_USERS_MFA_FIRSTTIME_NOTINTERESTED="Don't show this again" +COM_USERS_MFA_FIRSTTIME_PAGE_HEAD="Set up your Multi-factor Authentication" +COM_USERS_MFA_INVALID_CODE = "Multi-factor Authentication failed. Please try again." +COM_USERS_MFA_INVALID_METHOD="Invalid Multi-factor Authentication method." +COM_USERS_MFA_LBL_CREATEDON="Added: %s" +COM_USERS_MFA_LBL_DATE_FORMAT_PAST="F d, Y" +COM_USERS_MFA_LBL_DATE_FORMAT_TODAY="H:i" +COM_USERS_MFA_LBL_DATE_FORMAT_YESTERDAY="H:i" +COM_USERS_MFA_LBL_LASTUSED="Last used: %s" +COM_USERS_MFA_LBL_PAST="%s" +COM_USERS_MFA_LBL_TODAY="Today, %s" +COM_USERS_MFA_LBL_YESTERDAY="Yesterday, %s" +COM_USERS_MFA_LIST_DEFAULTTAG="Default" +COM_USERS_MFA_LIST_INSTRUCTIONS="Add at least one Multi-factor Authentication method. Every time you log into the site you will be asked to provide it." +COM_USERS_MFA_LIST_PAGE_HEAD="Your Multi-factor Authentication options" +COM_USERS_MFA_LIST_REMOVEALL="Turn Off" +COM_USERS_MFA_LIST_STATUS_OFF="Multi-factor Authentication is not enabled." +COM_USERS_MFA_LIST_STATUS_ON="Multi-factor Authentication is enabled." +COM_USERS_MFA_LOGOUT="Log Out" +COM_USERS_MFA_MANDATORY_NOTICE_BODY="Please enable a Multi-factor Authentication method for your user account. You will not be able to proceed using the site until you do so." +COM_USERS_MFA_MANDATORY_NOTICE_HEAD="Multi-factor Authentication is mandatory for your user account" +COM_USERS_MFA_NOTACTIVE="Does not use Multi-factor Authentication" +COM_USERS_MFA_SELECT_PAGE_HEAD="Select a Multi-factor Authentication method" +COM_USERS_MFA_USE_DIFFERENT_METHOD="Select a different method" +COM_USERS_MFA_VALIDATE="Validate" COM_USERS_NEW_NOTE="New Note" -COM_USERS_NOTE_FORM_EDIT="Edit Note" -COM_USERS_NOTE_FORM_NEW="New Note" COM_USERS_NOTES="User Notes: New/Edit" COM_USERS_NOTES_EMPTYSTATE_BUTTON_ADD="Add your first note" COM_USERS_NOTES_EMPTYSTATE_CONTENT="User Notes can be used to store a range of information about each user on your site." @@ -250,6 +304,8 @@ COM_USERS_NOTES_N_ITEMS_TRASHED_1="User Note trashed." COM_USERS_NOTES_N_ITEMS_UNPUBLISHED="%d User Notes unpublished." COM_USERS_NOTES_N_ITEMS_UNPUBLISHED_1="User Note unpublished." COM_USERS_NOTES_TABLE_CAPTION="Table of User Notes" +COM_USERS_NOTE_FORM_EDIT="Edit Note" +COM_USERS_NOTE_FORM_NEW="New Note" COM_USERS_NOTE_N_SUBJECT="#%d %s" COM_USERS_NO_ACTION="No Action" COM_USERS_NO_LEVELS_SELECTED="No Viewing Access Levels selected." @@ -293,6 +349,9 @@ COM_USERS_OPTION_SELECT_COMPONENT="- Select Component -" COM_USERS_OPTION_SELECT_LEVEL_END="- Select End Level -" COM_USERS_OPTION_SELECT_LEVEL_START="- Select Start Level -" COM_USERS_PASSWORD_RESET_REQUIRED="Password Reset Required" +COM_USERS_POSTINSTALL_MULTIFACTORAUTH_ACTION="Enable the new Multi-factor Authentication plugins" +COM_USERS_POSTINSTALL_MULTIFACTORAUTH_BODY="

Joomla! comes with a drastically improved Multi-factor Authentication experience to help you secure the logins of your users.

Unlike the Two Factor Authentication feature in previous versions of Joomla, users no longer have to enter a Security Code with their username and password. The Multi-factor Authentication happens in a separate step after logging into the site. Until they complete their Multi-factor Authentication validation users cannot navigate to other pages or use the site. This makes Multi-factor Authentication phishing–resistant. It also allows for interactive validation methods like WebAuthn (including integration with Windows Hello, Apple TouchID / FaceID and Android Biometric Screen Lock), or sending 6-digit authentication codes by email. Both of these interactive, convenient methods are now available as plugins shipped with Joomla! itself.

" +COM_USERS_POSTINSTALL_MULTIFACTORAUTH_TITLE="Improved Multi-factor Authentication" COM_USERS_REQUIRE_PASSWORD_RESET="Require Password Reset" COM_USERS_REVIEW_HEADING="Review Date" COM_USERS_SEARCH_ACCESS_LEVELS="Search Viewing Access Levels" @@ -313,8 +372,6 @@ COM_USERS_SUBMENU_LEVELS="Viewing Access Levels" COM_USERS_SUBMENU_NOTES="User Notes" COM_USERS_SUBMENU_NOTE_CATEGORIES="User Note Categories" COM_USERS_SUBMENU_USERS="Users" -COM_USERS_TFA_ACTIVE="Uses Two Factor Authentication" -COM_USERS_TFA_NOTACTIVE="Does not use Two Factor Authentication" COM_USERS_TOOLBAR_ACTIVATE="Activate" COM_USERS_TOOLBAR_BLOCK="Block" COM_USERS_TOOLBAR_MAIL_SEND_MAIL="Send Email" @@ -333,6 +390,9 @@ COM_USERS_USERS_N_ITEMS_DELETED="%d users deleted." COM_USERS_USERS_N_ITEMS_DELETED_1="User deleted." COM_USERS_USERS_TABLE_CAPTION="Table of Users" COM_USERS_USER_ACCOUNT_DETAILS="Account Details" +COM_USERS_USER_BACKUPCODE="Backup Code" +COM_USERS_USER_BACKUPCODES="Backup Codes" +COM_USERS_USER_BACKUPCODES_DESC="Lets you access the site if all other Multi-factor Authentication methods you have set up fail." COM_USERS_USER_BATCH_FAILED="An error was encountered while performing the batch operation: %s." COM_USERS_USER_BATCH_SUCCESS="Batch operation completed." COM_USERS_USER_FIELD_BACKEND_LANGUAGE_LABEL="Backend Language" @@ -353,19 +413,15 @@ COM_USERS_USER_FIELD_REQUIRERESET_LABEL="Require Password Reset" COM_USERS_USER_FIELD_RESETCOUNT_LABEL="Password Reset Count" COM_USERS_USER_FIELD_SENDEMAIL_LABEL="Receive System Emails" COM_USERS_USER_FIELD_TIMEZONE_LABEL="Time Zone" -COM_USERS_USER_FIELD_TWOFACTOR_LABEL="Authentication Method" COM_USERS_USER_FIELD_USERNAME_LABEL="Login Name (Username)" COM_USERS_USER_FORM_EDIT="Edit User" COM_USERS_USER_FORM_NEW="New User" COM_USERS_USER_GROUPS_HAVING_ACCESS="User Groups With Viewing Access" COM_USERS_USER_HEADING="User" +COM_USERS_USER_MULTIFACTOR_AUTH="Multi-factor Authentication" COM_USERS_USER_NEW_USER_TITLE="New User Details" -COM_USERS_USER_OTEPS="One time emergency passwords" -COM_USERS_USER_OTEPS_DESC="If you do not have access to your two factor authentication device you can use any of the following passwords instead of a regular security code. Each one of these emergency passwords is immediately destroyed upon use. We recommend printing these passwords out and keeping the printout in a safe and accessible location, eg your wallet or a safety deposit box." -COM_USERS_USER_OTEPS_WAIT_DESC="There are no emergency one time passwords generated in your account. The passwords will be generated automatically and displayed here as soon as you activate two factor authentication." COM_USERS_USER_SAVE_FAILED="An error was encountered while saving the member: %s." COM_USERS_USER_SAVE_SUCCESS="User saved." -COM_USERS_USER_TWO_FACTOR_AUTH="Two Factor Authentication" COM_USERS_VIEW_DEBUG_GROUP_TITLE="Permissions for Group #%d, %s" COM_USERS_VIEW_DEBUG_USER_TITLE="Permissions for User #%d, %s" COM_USERS_VIEW_EDIT_GROUP_TITLE="Users: Edit Group" @@ -385,3 +441,20 @@ JLIB_RULES_SETTING_NOTES_COM_USERS="Changes apply to this component only.
+; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_EMAIL="Multi-factor Authentication - Authentication Code by Email" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_FORCE_ENABLE_DESC="Should I automatically add the Authentication Code by Email as an option for all users? Useful to provide a fallback to users who have lost access to their main authenticator and haven't kept a copy of the backup codes at the expense of some degree of control and security." +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_FORCE_ENABLE_LABEL="Force Enable" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_120="Two minutes (recommended)" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_180="Three minutes" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_300="Five minutes" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_30="Half a minute" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_60="One minute" +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_DESC="A new code is generated every this many minutes. Do note that a generated code is valid for at least this much time and at most twice as much time. The higher this period is the more likely it is for the code to be brute forced, therefore the least secure your site is. A period of 2 minutes is a good trade-off between usability and security." +PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_LABEL="Code Generation Period" +PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY="Multi-factor Authentication on {SITENAME}. Your authentication code is {CODE}." +PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT="Your {SITENAME} authentication code is -{CODE}-" +PLG_MULTIFACTORAUTH_EMAIL_ERR_INVALID_CODE="Invalid or expired code. Please reload the page to send yourself a new code. Make sure to enter the code within two minutes since you requested the code." +PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS="Code by Email" +PLG_MULTIFACTORAUTH_EMAIL_LBL_LABEL="Authentication Code" +PLG_MULTIFACTORAUTH_EMAIL_LBL_PRE_MESSAGE="You have received a six digit Multi-factor Authentication code in your email. Please enter it below." +PLG_MULTIFACTORAUTH_EMAIL_LBL_SETUP_PLACEHOLDER="Six Digit Authentication Code" +PLG_MULTIFACTORAUTH_EMAIL_LBL_SHORTINFO="Receive six digit codes by email." +PLG_MULTIFACTORAUTH_EMAIL_MAIL_LBL="Joomla: Authentication Code by Email" +PLG_MULTIFACTORAUTH_EMAIL_MAIL_MAIL_DESC="Sent to users from the Multi-factor Authentication page when using the “Authentication Code by Email” option." +PLG_MULTIFACTORAUTH_EMAIL_MAIL_MAIL_TITLE="Code sent by email" +PLG_MULTIFACTORAUTH_EMAIL_XML_DESCRIPTION="Use time limited, six digit security codes sent to you by email." diff --git a/administrator/language/en-GB/plg_multifactorauth_email.sys.ini b/administrator/language/en-GB/plg_multifactorauth_email.sys.ini new file mode 100644 index 0000000000000..a7713525bf375 --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_email.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_EMAIL="Multi-factor Authentication - Authentication Code by Email" +PLG_MULTIFACTORAUTH_EMAIL_XML_DESCRIPTION="Use time limited, six digit security codes sent to you by email." diff --git a/administrator/language/en-GB/plg_multifactorauth_fixed.ini b/administrator/language/en-GB/plg_multifactorauth_fixed.ini new file mode 100644 index 0000000000000..47b43cac0dc6b --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_fixed.ini @@ -0,0 +1,17 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_FIXED="Multi-factor Authentication - Fixed Code" +PLG_MULTIFACTORAUTH_FIXED_ERR_EMPTYCODE="Your fixed code cannot be empty." +PLG_MULTIFACTORAUTH_FIXED_LBL_DEFAULTTITLE="Fixed Code" +PLG_MULTIFACTORAUTH_FIXED_LBL_DISPLAYEDAS="Fixed Code" +PLG_MULTIFACTORAUTH_FIXED_LBL_LABEL="Fixed Code" +PLG_MULTIFACTORAUTH_FIXED_LBL_PLACEHOLDER="Enter your Fixed Code" +PLG_MULTIFACTORAUTH_FIXED_LBL_POSTMESSAGE="

The messages appearing above and below the code area can be customized by overriding the language strings PLG_MULTIFACTORAUTH_FIXED_LBL_PREMESSAGE and PLG_MULTIFACTORAUTH_FIXED_LBL_POSTMESSAGE.

" +PLG_MULTIFACTORAUTH_FIXED_LBL_PREMESSAGE="

This is a demonstration Multi-factor Authentication plugin for Joomla. You need to enter the fixed code you configured when enabling the Multi-factor Authentication for this user. It effectively works as a second password.

" +PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_POSTMESSAGE="

The messages appearing above and below the setup area can be customized by overriding the language strings PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_PREMESSAGE and PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_POSTMESSAGE

" +PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_PREMESSAGE="

Enter a Fixed Code below. This Fixed Code will be required to be entered after logging in before you're able to use the site.

" +PLG_MULTIFACTORAUTH_FIXED_LBL_SHORTINFO="Choose your own preset code. For demonstration purposes only." +PLG_MULTIFACTORAUTH_FIXED_XML_DESCRIPTION="A demonstration Multi-factor Authentication plugin using a fixed code (a “second password”). Do not use on live sites, it is not secure. This plugin is only meant to be used as an example for developers interested in creating their own plugins." diff --git a/administrator/language/en-GB/plg_multifactorauth_fixed.sys.ini b/administrator/language/en-GB/plg_multifactorauth_fixed.sys.ini new file mode 100644 index 0000000000000..d816b7d6af7ef --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_fixed.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_FIXED="Multi-factor Authentication - Fixed Code" +PLG_MULTIFACTORAUTH_FIXED_XML_DESCRIPTION="A demonstration Multi-factor Authentication plugin using a fixed code (a “second password”). Do not use on live sites, it is not secure. This plugin is only meant to be used as an example for developers interested in creating their own plugins." diff --git a/administrator/language/en-GB/plg_multifactorauth_totp.ini b/administrator/language/en-GB/plg_multifactorauth_totp.ini new file mode 100644 index 0000000000000..cc7272f744cde --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_totp.ini @@ -0,0 +1,20 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_TOTP="Multi-factor Authentication - Verification Code" +PLG_MULTIFACTORAUTH_TOTP_ERR_VALIDATIONFAILED="You did not enter a valid verification code. Please check your authenticator app setup, and make sure that the time and time zone on your device is set correctly." +PLG_MULTIFACTORAUTH_TOTP_LBL_LABEL="Enter the six digit verification code" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_INSTRUCTIONS="Set up your verification code (also known as an “authenticator code”) using the information below. You can use an authenticator app (such Google Authenticator, Authy, LastPass Authenticator, etc), your favorite password manager (1Password, BitWarden, Keeper, KeePassXC, Strongbox, etc) or, in some cases, your browser." +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_PLACEHOLDER="Six Digit Code" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_HEADING="Authenticator app setup" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_KEY="Enter this key" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK="Click this link" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_NOTE="Only works on supported browsers, e.g. Safari." +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_TEXT="Set up your verification code" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_QR="Scan or right click / long tap this QR code" +PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_SUBHEAD="Use one of the following alternative methods to set up the verification code in your authenticator application, password manager or browser." +PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE="Verification code" +PLG_MULTIFACTORAUTH_TOTP_SHORTINFO="Use 6-digit codes generated by an app every 30 seconds." +PLG_MULTIFACTORAUTH_TOTP_XML_DESCRIPTION="Multi-factor Authentication for your site's users using six digit verification codes generated by an authenticator app (Google Authenticator, Authy, LastPass Authenticator, etc), a password manager (1Password, BitWarden, Keeper, KeePassXC, Strongbox, etc) or, in some cases, their browser." diff --git a/administrator/language/en-GB/plg_multifactorauth_totp.sys.ini b/administrator/language/en-GB/plg_multifactorauth_totp.sys.ini new file mode 100644 index 0000000000000..ae3971cd6ef8e --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_totp.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_TOTP="Multi-factor Authentication - Verification Code" +PLG_MULTIFACTORAUTH_TOTP_XML_DESCRIPTION="Multi-factor Authentication for your site's users using six digit verification codes generated by an authenticator app (Google Authenticator, Authy, LastPass Authenticator, etc), a password manager (1Password, BitWarden, Keeper, KeePassXC, Strongbox, etc) or, in some cases, their browser." diff --git a/administrator/language/en-GB/plg_multifactorauth_webauthn.ini b/administrator/language/en-GB/plg_multifactorauth_webauthn.ini new file mode 100644 index 0000000000000..5da84c024a777 --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_webauthn.ini @@ -0,0 +1,23 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_WEBAUTHN="Multi-factor Authentication - Web Authentication" +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST="Invalid authentication request." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_PK="The authenticator registration has failed. The authenticator response received from the browser does not match the Public Key issued by the server. This means that someone tried to hack you or something is broken." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_USER="For security reasons you are not allowed to register authenticators on behalf of another user." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_ATTESTED_DATA="Something went wrong but no further information about the error is available at this time. Please retry registering your authenticator." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_PK="The server has not issued a Public Key for authenticator registration but somehow received an authenticator registration request from the browser. This means that someone tried to hack you or something is broken." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_BODY="Your browser doesn't support the WebAuthn standard. Not all browsers are compatible with WebAuthn on all devices just yet." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD="Your browser lacks support for WebAuthn" +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTHTTPS_BODY="Please access the site over HTTPS to enable Multi-factor Authentication with WebAuthn." +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTHTTPS_HEAD="WebAuthn is only available on HTTPS" +PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NO_STORED_CREDENTIAL="You have not configured an Authenticator yet or the Authenticator you are trying to use is ineligible." +PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_CONFIGURED="You have already configured your Authenticator. Please note that you can only modify its title from this page." +PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_DISPLAYEDAS="Web Authentication" +PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_INSTRUCTIONS="Use the “%s” button on this page to start the Web Authentication process. Then please follow the instructions given to you by your browser to complete Web Authentication with your preferred Authenticator." +PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_REGISTERKEY="Register your Authenticator" +PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_SHORTINFO="Use WebAuthn with any hardware or software security key." +PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_VALIDATEKEY="Validate with your Authenticator" +PLG_MULTIFACTORAUTH_WEBAUTHN_XML_DESCRIPTION="Use W3C Web Authentication (Webauthn) as a Multi-factor Authentication method. All modern browsers support it. Most browsers offer device-specific authentication protected by a password and/or biometrics (fingerprint sensor, face scan, ...)." diff --git a/administrator/language/en-GB/plg_multifactorauth_webauthn.sys.ini b/administrator/language/en-GB/plg_multifactorauth_webauthn.sys.ini new file mode 100644 index 0000000000000..c78c32fc96456 --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_webauthn.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_WEBAUTHN="Multi-factor Authentication - Web Authentication" +PLG_MULTIFACTORAUTH_WEBAUTHN_XML_DESCRIPTION="Use W3C Web Authentication (Webauthn) as a Multi-factor Authentication method. All modern browsers support it. Most browsers offer device-specific authentication protected by a password and/or biometrics (fingerprint sensor, face scan, ...)." diff --git a/administrator/language/en-GB/plg_multifactorauth_yubikey.ini b/administrator/language/en-GB/plg_multifactorauth_yubikey.ini new file mode 100644 index 0000000000000..cec3a600eef67 --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_yubikey.ini @@ -0,0 +1,15 @@ +; Joomla! Project +; (C) 2022 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_YUBIKEY="Multi-factor Authentication - YubiKey" +PLG_MULTIFACTORAUTH_YUBIKEY_CODE_LABEL="YubiKey code" +PLG_MULTIFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED="You did not enter a valid YubiKey secret code or the YubiCloud servers are unreachable at this time." +PLG_MULTIFACTORAUTH_YUBIKEY_LBL_AFTERSETUP_INSTRUCTIONS="You have already set up your Yubikey (the one generating codes starting with %s). You can only change its title from this page." +PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_INSTRUCTIONS="Please provide a code generated by your YubiKey below and then click or touch the Confirm button. The first twelve characters, which are the unique identification code for your YubiKey, will be saved." +PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_LABEL="Yubikey Identification" +PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_PLACEHOLDER="Enter a Yubikey code" +PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE="YubiKey" +PLG_MULTIFACTORAUTH_YUBIKEY_SHORTINFO="Use YubiKey secure hardware tokens." +PLG_MULTIFACTORAUTH_YUBIKEY_XML_DESCRIPTION="Allows users on your site to use Multi-factor Authentication using a YubiKey secure hardware token. Users need their own Yubikey available from https://www.yubico.com/. To use Multi-factor Authentication users have to edit their user profile and enable Multi-factor Authentication." diff --git a/administrator/language/en-GB/plg_multifactorauth_yubikey.sys.ini b/administrator/language/en-GB/plg_multifactorauth_yubikey.sys.ini new file mode 100644 index 0000000000000..f3f8008fe310c --- /dev/null +++ b/administrator/language/en-GB/plg_multifactorauth_yubikey.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2013 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_MULTIFACTORAUTH_YUBIKEY="Multi-factor Authentication - YubiKey" +PLG_MULTIFACTORAUTH_YUBIKEY_XML_DESCRIPTION="Allows users on your site to use Multi-factor Authentication using a YubiKey secure hardware token. Users need their own Yubikey available from https://www.yubico.com/. To use Multi-factor Authentication users have to edit their user profile and enable Multi-factor Authentication." diff --git a/administrator/language/en-GB/plg_twofactorauth_totp.ini b/administrator/language/en-GB/plg_twofactorauth_totp.ini index 96ccc600b4755..50060c3b66ce5 100644 --- a/administrator/language/en-GB/plg_twofactorauth_totp.ini +++ b/administrator/language/en-GB/plg_twofactorauth_totp.ini @@ -2,6 +2,7 @@ ; (C) 2013 Open Source Matters, Inc. ; License GNU General Public License version 2 or later; see LICENSE.txt ; Note : All ini files need to be saved as UTF-8 +; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0 PLG_TWOFACTORAUTH_TOTP="Two Factor Authentication - Google Authenticator" PLG_TWOFACTORAUTH_TOTP_ERR_VALIDATIONFAILED="You did not enter a valid security code. Please check your Google Authenticator setup and make sure that the time on your device matches the time on the site." diff --git a/administrator/language/en-GB/plg_twofactorauth_totp.sys.ini b/administrator/language/en-GB/plg_twofactorauth_totp.sys.ini index 7d97867315edb..877d6144ae529 100644 --- a/administrator/language/en-GB/plg_twofactorauth_totp.sys.ini +++ b/administrator/language/en-GB/plg_twofactorauth_totp.sys.ini @@ -2,6 +2,7 @@ ; (C) 2013 Open Source Matters, Inc. ; License GNU General Public License version 2 or later; see LICENSE.txt ; Note : All ini files need to be saved as UTF-8 +; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0 PLG_TWOFACTORAUTH_TOTP="Two Factor Authentication - Google Authenticator" PLG_TWOFACTORAUTH_TOTP_XML_DESCRIPTION="Allows users on your site to use two factor authentication using Google Authenticator or other compatible time-based One Time Password generators such as FreeOTP. To use two factor authentication please edit the user profile and enable two factor authentication." diff --git a/administrator/language/en-GB/plg_twofactorauth_yubikey.ini b/administrator/language/en-GB/plg_twofactorauth_yubikey.ini index eee788a9e6d15..d44e8d73eaaa2 100644 --- a/administrator/language/en-GB/plg_twofactorauth_yubikey.ini +++ b/administrator/language/en-GB/plg_twofactorauth_yubikey.ini @@ -2,6 +2,7 @@ ; (C) 2013 Open Source Matters, Inc. ; License GNU General Public License version 2 or later; see LICENSE.txt ; Note : All ini files need to be saved as UTF-8 +; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0 PLG_TWOFACTORAUTH_TOTP_RESET_HEAD="Your YubiKey is already linked to your user account." PLG_TWOFACTORAUTH_TOTP_RESET_TEXT="If you want to unlink your YubiKey from your user account or use another YubiKey, please first disable two factor authentication and save your user profile. Then come back to this user profile page and re-activate two factor authentication with the YubiKey method." diff --git a/administrator/language/en-GB/plg_twofactorauth_yubikey.sys.ini b/administrator/language/en-GB/plg_twofactorauth_yubikey.sys.ini index eab577fe780fb..8dc0a6d0fcbb4 100644 --- a/administrator/language/en-GB/plg_twofactorauth_yubikey.sys.ini +++ b/administrator/language/en-GB/plg_twofactorauth_yubikey.sys.ini @@ -2,6 +2,7 @@ ; (C) 2013 Open Source Matters, Inc. ; License GNU General Public License version 2 or later; see LICENSE.txt ; Note : All ini files need to be saved as UTF-8 +; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0 PLG_TWOFACTORAUTH_YUBIKEY="Two Factor Authentication - YubiKey" PLG_TWOFACTORAUTH_YUBIKEY_XML_DESCRIPTION="Allows users on your site to use two factor authentication using a YubiKey secure hardware token. Users need their own Yubikey available from https://www.yubico.com/. To use two factor authentication users have to edit their user profile and enable two factor authentication." diff --git a/administrator/modules/mod_login/mod_login.php b/administrator/modules/mod_login/mod_login.php index 5667db51332bd..39759064dd82c 100644 --- a/administrator/modules/mod_login/mod_login.php +++ b/administrator/modules/mod_login/mod_login.php @@ -14,7 +14,6 @@ use Joomla\Module\Login\Administrator\Helper\LoginHelper; $langs = LoginHelper::getLanguageList(); -$twofactormethods = AuthenticationHelper::getTwoFactorMethods(); $extraButtons = AuthenticationHelper::getLoginButtons('form-login'); $return = LoginHelper::getReturnUri(); diff --git a/administrator/modules/mod_login/tmpl/default.php b/administrator/modules/mod_login/tmpl/default.php index f2df18832594b..9249ecbaf6439 100644 --- a/administrator/modules/mod_login/tmpl/default.php +++ b/administrator/modules/mod_login/tmpl/default.php @@ -65,26 +65,6 @@ class="form-control input-full"
- 1): ?> -
- -
- - -
-
-