diff --git a/.appveyor.yml b/.appveyor.yml index 40911f2de72a9..a0f9e6b32ef4e 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -44,7 +44,7 @@ install: - choco install composer - cd C:\projects\joomla-cms - refreshenv - - composer install --no-progress --profile + - composer install --no-progress --profile --ignore-platform-req=ext-sodium before_test: # Database setup for MySQL via PowerShell tools - > diff --git a/.drone.yml b/.drone.yml index 4a705b7900139..b4d18d14d509a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -59,7 +59,6 @@ steps: - name: php81-unit depends_on: [ phpcs ] image: joomlaprojects/docker-images:php8.1 - failure: ignore commands: - php -v - ./libraries/vendor/bin/phpunit --testsuite Unit @@ -88,7 +87,6 @@ steps: - name: php80-integration depends_on: [ npm ] image: joomlaprojects/docker-images:php8.0 - failure: ignore commands: - php -v - ./libraries/vendor/bin/phpunit --testsuite Integration @@ -131,7 +129,6 @@ steps: - name: php80-integration-pgsql depends_on: [ npm ] image: joomlaprojects/docker-images:php8.0 - failure: ignore commands: - php -v - ./libraries/vendor/bin/phpunit --testsuite Integration --configuration phpunit-pgsql.xml.dist @@ -478,6 +475,6 @@ trigger: --- kind: signature -hmac: 234ae9e7e2fbfa114ba754c68056dec518c76a93de2f5b098f569e355b50cc1b +hmac: d5db8148323f0205a8c0cd165da3934f5a77b25f73862d09ead95d3c42f1df01 ... diff --git a/.github/workflows/create-translation-pull-request-v4.yml b/.github/workflows/create-translation-pull-request-v4.yml index 06a8e840861c3..f41b6a236cb8b 100644 --- a/.github/workflows/create-translation-pull-request-v4.yml +++ b/.github/workflows/create-translation-pull-request-v4.yml @@ -10,8 +10,13 @@ on: # Run daily at 7:26 - cron: '26 7 * * *' +permissions: + contents: read + jobs: build: + permissions: + contents: write # for Git to git push runs-on: ubuntu-latest # Only run this action the translation-bot repository in the translation branch if: ${{ github.repository == 'joomla-translation-bot/joomla-cms' && github.ref == 'refs/heads/translation' }} diff --git a/administrator/components/com_admin/script.php b/administrator/components/com_admin/script.php index fccc68572099c..70bc1ae6788e4 100644 --- a/administrator/components/com_admin/script.php +++ b/administrator/components/com_admin/script.php @@ -6477,6 +6477,14 @@ public function deleteUnexistingFiles($dryRun = false, $suppressOutput = false) '/plugins/twofactorauth/yubikey/tmpl/form.php', '/plugins/twofactorauth/yubikey/yubikey.php', '/plugins/twofactorauth/yubikey/yubikey.xml', + // From 4.2.0-beta1 to 4.2.0-beta2 + '/layouts/plugins/user/profile/fields/dob.php', + '/modules/mod_articles_latest/mod_articles_latest.php', + '/plugins/behaviour/taggable/taggable.php', + '/plugins/behaviour/versionable/versionable.php', + '/plugins/task/requests/requests.php', + '/plugins/task/sitestatus/sitestatus.php', + '/plugins/user/profile/src/Field/DobField.php', ); $folders = array( @@ -7838,6 +7846,9 @@ public function deleteUnexistingFiles($dryRun = false, $suppressOutput = false) '/plugins/twofactorauth/totp', '/plugins/twofactorauth', '/libraries/vendor/nyholm/psr7/doc', + // From 4.2.0-beta1 to 4.2.0-beta2 + '/layouts/plugins/user/profile/fields', + '/layouts/plugins/user/profile', ); $status['files_checked'] = $files; diff --git a/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-06-19.sql b/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-06-19.sql new file mode 100644 index 0000000000000..55aa01c3968c6 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-06-19.sql @@ -0,0 +1,3 @@ +-- See https://github.com/joomla/joomla-cms/pull/38092 +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_system_shortcut', 'plugin', 'shortcut', 'system', 0, 1, 1, 0, 1, '', '', '', 0, 0); diff --git a/administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-06-19.sql b/administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-06-19.sql new file mode 100644 index 0000000000000..6a2a3b2049604 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-06-19.sql @@ -0,0 +1,3 @@ +-- See https://github.com/joomla/joomla-cms/pull/38092 +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_system_shortcut', 'plugin', 'shortcut', 'system', 0, 1, 1, 0, 1, '', '', '', 0, 0); diff --git a/administrator/components/com_banners/forms/filter_tracks.xml b/administrator/components/com_banners/forms/filter_tracks.xml index 28d33929a04dd..50c734aa9c92d 100644 --- a/administrator/components/com_banners/forms/filter_tracks.xml +++ b/administrator/components/com_banners/forms/filter_tracks.xml @@ -63,6 +63,7 @@ hint="COM_BANNERS_BEGIN_HINT" format="%Y-%m-%d" filter="user_utc" + onchange="this.form.submit();" /> diff --git a/administrator/components/com_contact/src/Table/ContactTable.php b/administrator/components/com_contact/src/Table/ContactTable.php index b1f86ee8cf205..4748f0ed52217 100644 --- a/administrator/components/com_contact/src/Table/ContactTable.php +++ b/administrator/components/com_contact/src/Table/ContactTable.php @@ -110,10 +110,16 @@ public function store($updateNulls = true) } // Store utf8 email as punycode - $this->email_to = PunycodeHelper::emailToPunycode($this->email_to); + if ($this->email_to !== null) + { + $this->email_to = PunycodeHelper::emailToPunycode($this->email_to); + } // Convert IDN urls to punycode - $this->webpage = PunycodeHelper::urlToPunycode($this->webpage); + if ($this->webpage !== null) + { + $this->webpage = PunycodeHelper::urlToPunycode($this->webpage); + } // Verify that the alias is unique $table = Table::getInstance('ContactTable', __NAMESPACE__ . '\\', array('dbo' => $this->getDbo())); @@ -151,7 +157,7 @@ public function check() $this->default_con = (int) $this->default_con; - if (InputFilter::checkAttribute(array('href', $this->webpage))) + if ($this->webpage !== null && InputFilter::checkAttribute(array('href', $this->webpage))) { $this->setError(Text::_('COM_CONTACT_WARNING_PROVIDE_VALID_URL')); diff --git a/administrator/components/com_cpanel/src/Controller/DisplayController.php b/administrator/components/com_cpanel/src/Controller/DisplayController.php index d49c4635b923a..9a68c3f4ef416 100644 --- a/administrator/components/com_cpanel/src/Controller/DisplayController.php +++ b/administrator/components/com_cpanel/src/Controller/DisplayController.php @@ -11,7 +11,6 @@ \defined('_JEXEC') or die; -use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Router\Route; @@ -78,8 +77,8 @@ public function addModule() $position = 'cpanel'; } - Factory::getApplication()->setUserState('com_modules.modules.filter.position', $position); - Factory::getApplication()->setUserState('com_modules.modules.client_id', '1'); + $this->app->setUserState('com_modules.modules.filter.position', $position); + $this->app->setUserState('com_modules.modules.client_id', '1'); $this->setRedirect(Route::_('index.php?option=com_modules&view=select&tmpl=component&layout=modal' . $appendLink, false)); } diff --git a/administrator/components/com_finder/src/Controller/IndexController.php b/administrator/components/com_finder/src/Controller/IndexController.php index cd93c9bb16fdc..2c620bf8863ac 100644 --- a/administrator/components/com_finder/src/Controller/IndexController.php +++ b/administrator/components/com_finder/src/Controller/IndexController.php @@ -11,7 +11,6 @@ \defined('_JEXEC') or die; -use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Plugin\PluginHelper; diff --git a/administrator/components/com_finder/src/Controller/IndexerController.php b/administrator/components/com_finder/src/Controller/IndexerController.php index 2852d7393cda7..28ef045c49fdd 100644 --- a/administrator/components/com_finder/src/Controller/IndexerController.php +++ b/administrator/components/com_finder/src/Controller/IndexerController.php @@ -178,10 +178,10 @@ public function batch() try { // Trigger the onBeforeIndex event. - Factory::getApplication()->triggerEvent('onBeforeIndex'); + $this->app->triggerEvent('onBeforeIndex'); // Trigger the onBuildIndex event. - Factory::getApplication()->triggerEvent('onBuildIndex'); + $this->app->triggerEvent('onBuildIndex'); // Get the indexer state. $state = Indexer::getState(); diff --git a/administrator/components/com_finder/src/Indexer/Indexer.php b/administrator/components/com_finder/src/Indexer/Indexer.php index 79a3aa8b667b4..9625c424b94b2 100644 --- a/administrator/components/com_finder/src/Indexer/Indexer.php +++ b/administrator/components/com_finder/src/Indexer/Indexer.php @@ -315,7 +315,7 @@ public function index($item, $format = 'html') $item->end_date = (int) $item->end_date != 0 ? $item->end_date : null; // Prepare the item description. - $item->description = Helper::parse($item->summary); + $item->description = Helper::parse($item->summary ?? ''); /* * Now, we need to enter the item into the links table. If the item diff --git a/administrator/components/com_installer/src/Model/LanguagesModel.php b/administrator/components/com_installer/src/Model/LanguagesModel.php index d97dccc0a3196..12bdde329517f 100644 --- a/administrator/components/com_installer/src/Model/LanguagesModel.php +++ b/administrator/components/com_installer/src/Model/LanguagesModel.php @@ -152,6 +152,14 @@ protected function getLanguages() } $updateSiteXML = simplexml_load_string($response->body); + + if (!$updateSiteXML) + { + Factory::getApplication()->enqueueMessage(Text::sprintf('COM_INSTALLER_MSG_ERROR_CANT_RETRIEVE_XML', $updateSite), 'error'); + + return; + } + $languages = array(); $search = strtolower($this->getState('filter.search')); diff --git a/administrator/components/com_joomlaupdate/extract.php b/administrator/components/com_joomlaupdate/extract.php index 9328a57beea0c..1ea4d93c8a799 100644 --- a/administrator/components/com_joomlaupdate/extract.php +++ b/administrator/components/com_joomlaupdate/extract.php @@ -1946,7 +1946,7 @@ function getConfiguration(): ?array * Sets the PHP timeout to 3600 seconds * * @return void - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ function setLongTimeout() { @@ -1962,7 +1962,7 @@ function setLongTimeout() * Sets the memory limit to 1GiB * * @return void - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ function setHugeMemoryLimit() { diff --git a/administrator/components/com_joomlaupdate/src/Controller/UpdateController.php b/administrator/components/com_joomlaupdate/src/Controller/UpdateController.php index 87a9f5912aa99..5aa701148261d 100644 --- a/administrator/components/com_joomlaupdate/src/Controller/UpdateController.php +++ b/administrator/components/com_joomlaupdate/src/Controller/UpdateController.php @@ -588,7 +588,7 @@ public function fetchExtensionCompatibility() * Called by the Joomla Update JavaScript (PreUpdateChecker.checkNextChunk). * * @return void - * @since __DEPLOY_VERSION__ + * @since 4.2.0 * */ public function batchextensioncompatibility() diff --git a/administrator/components/com_joomlaupdate/src/View/Joomlaupdate/HtmlView.php b/administrator/components/com_joomlaupdate/src/View/Joomlaupdate/HtmlView.php index e24e607386d2f..554c8c01968fc 100644 --- a/administrator/components/com_joomlaupdate/src/View/Joomlaupdate/HtmlView.php +++ b/administrator/components/com_joomlaupdate/src/View/Joomlaupdate/HtmlView.php @@ -119,7 +119,7 @@ class HtmlView extends BaseHtmlView * Should I disable the confirmation checkbox for pre-update extension version checks? * * @var boolean - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ protected $noVersionCheck = false; @@ -127,7 +127,7 @@ class HtmlView extends BaseHtmlView * Should I disable the confirmation checkbox for taking a backup before updating? * * @var boolean - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ protected $noBackupCheck = false; diff --git a/administrator/components/com_joomlaupdate/src/View/Upload/HtmlView.php b/administrator/components/com_joomlaupdate/src/View/Upload/HtmlView.php index 3ca0a5fb4ba25..f172fc7900273 100644 --- a/administrator/components/com_joomlaupdate/src/View/Upload/HtmlView.php +++ b/administrator/components/com_joomlaupdate/src/View/Upload/HtmlView.php @@ -55,7 +55,7 @@ class HtmlView extends BaseHtmlView * Should I disable the confirmation checkbox for taking a backup before updating? * * @var boolean - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ protected $noBackupCheck = false; diff --git a/administrator/components/com_languages/src/Controller/InstalledController.php b/administrator/components/com_languages/src/Controller/InstalledController.php index 67b26265b7bfb..d565f0e89dcf2 100644 --- a/administrator/components/com_languages/src/Controller/InstalledController.php +++ b/administrator/components/com_languages/src/Controller/InstalledController.php @@ -46,7 +46,7 @@ public function setDefault() $language = Factory::getLanguage(); $newLang = Language::getInstance($cid); Factory::$language = $newLang; - Factory::getApplication()->loadLanguage($language = $newLang); + $this->app->loadLanguage($language = $newLang); $newLang->load('com_languages', JPATH_ADMINISTRATOR); } @@ -101,7 +101,7 @@ public function switchAdminLanguage() $language = Factory::getLanguage(); $newLang = Language::getInstance($cid); Factory::$language = $newLang; - Factory::getApplication()->loadLanguage($language = $newLang); + $this->app->loadLanguage($language = $newLang); $newLang->load('com_languages', JPATH_ADMINISTRATOR); $msg = Text::sprintf('COM_LANGUAGES_MSG_SWITCH_ADMIN_LANGUAGE_SUCCESS', $languageName); diff --git a/administrator/components/com_media/resources/scripts/components/browser/actionItems/actionItemsContainer.vue b/administrator/components/com_media/resources/scripts/components/browser/actionItems/actionItemsContainer.vue index 630322a1ce93b..f22bb8daa2662 100644 --- a/administrator/components/com_media/resources/scripts/components/browser/actionItems/actionItemsContainer.vue +++ b/administrator/components/com_media/resources/scripts/components/browser/actionItems/actionItemsContainer.vue @@ -3,6 +3,9 @@ class="media-browser-select" :aria-label="translate('COM_MEDIA_TOGGLE_SELECT_ITEM')" :title="translate('COM_MEDIA_TOGGLE_SELECT_ITEM')" + tabindex="0" + @focusin="focused(true)" + @focusout="focused(false)" />
@@ -29,6 +32,7 @@ :closing-action="hideActions" @keyup.up="$refs.actionDelete.$el.focus()" @keyup.down="$refs.actionDelete.$el.previousElementSibling.focus()" + @keyup.esc="hideActions" />
  • @@ -40,6 +44,7 @@ :closing-action="hideActions" @keyup.up="$refs.actionPreview.$el.focus()" @keyup.down="$refs.actionPreview.$el.previousElementSibling.focus()" + @keyup.esc="hideActions" />
  • @@ -61,6 +66,7 @@ ? $refs.actionShare.$el.focus() : $refs.actionShare.$el.previousElementSibling.focus() " + @keyup.esc="hideActions" />
  • @@ -72,6 +78,7 @@ :closing-action="hideActions" @keyup.up="$refs.actionRename.$el.focus()" @keyup.down="$refs.actionRename.$el.previousElementSibling.focus()" + @keyup.esc="hideActions" />
  • @@ -87,6 +94,7 @@ : $refs.actionEdit.$el.previousElementSibling.focus() " @keyup.down="$refs.actionDelete.$el.focus()" + @keyup.esc="hideActions" />
  • @@ -106,6 +114,7 @@ ? $refs.actionPreview.$el.focus() : $refs.actionPreview.$el.previousElementSibling.focus() " + @keyup.esc="hideActions" />
  • @@ -121,12 +130,12 @@ export default { name: 'MediaBrowserActionItemsContainer', props: { item: { type: Object, default: () => {} }, - onFocused: { type: Function, default: () => {} }, edit: { type: Function, default: () => {} }, previewable: { type: Boolean, default: false }, downloadable: { type: Boolean, default: false }, shareable: { type: Boolean, default: false }, }, + emits: ['toggle-settings'], data() { return { showActions: false, @@ -207,6 +216,9 @@ export default { editItem() { this.edit(); }, + focused(bool) { + this.$emit('toggle-settings', bool); + }, }, }; diff --git a/administrator/components/com_media/resources/scripts/components/browser/actionItems/toggle.vue b/administrator/components/com_media/resources/scripts/components/browser/actionItems/toggle.vue index e5a028f7c94c8..45850a1d4a421 100644 --- a/administrator/components/com_media/resources/scripts/components/browser/actionItems/toggle.vue +++ b/administrator/components/com_media/resources/scripts/components/browser/actionItems/toggle.vue @@ -2,6 +2,7 @@
    @@ -34,6 +34,7 @@ export default { name: 'MediaBrowserItemFile', // eslint-disable-next-line vue/require-prop-types props: ['item', 'focused'], + emits: ['toggle-settings'], data() { return { showActions: false, @@ -48,6 +49,9 @@ export default { openPreview() { this.$refs.container.openPreview(); }, + toggleSettings(bool) { + this.$emit('toggle-settings', bool); + }, }, }; diff --git a/administrator/components/com_media/resources/scripts/components/browser/items/image.vue b/administrator/components/com_media/resources/scripts/components/browser/items/image.vue index 98822dd030013..0a9569566195f 100644 --- a/administrator/components/com_media/resources/scripts/components/browser/items/image.vue +++ b/administrator/components/com_media/resources/scripts/components/browser/items/image.vue @@ -1,8 +1,10 @@ @@ -53,6 +55,7 @@ export default { item: { type: Object, required: true }, focused: { type: Boolean, required: true, default: false }, }, + emits: ['toggle-settings'], data() { return { showActions: { type: Boolean, default: false }, @@ -98,6 +101,9 @@ export default { window.location.href = fileBaseUrl + this.item.path; }, + toggleSettings(bool) { + this.$emit('toggle-settings', bool); + }, }, }; diff --git a/administrator/components/com_media/resources/scripts/components/browser/items/item.es6.js b/administrator/components/com_media/resources/scripts/components/browser/items/item.es6.js index 53366fbaaba74..990a9665dbabc 100644 --- a/administrator/components/com_media/resources/scripts/components/browser/items/item.es6.js +++ b/administrator/components/com_media/resources/scripts/components/browser/items/item.es6.js @@ -162,11 +162,11 @@ export default { /** * Handle the when an element is focused in the child to display the layover for a11y - * @param value + * @param active */ - focused(value) { + toggleSettings(active) { // eslint-disable-next-line no-unused-expressions - value ? this.mouseover() : this.mouseleave(); + active ? this.mouseover() : this.mouseleave(); }, }, render() { @@ -181,12 +181,11 @@ export default { onClick: this.handleClick, onMouseover: this.mouseover, onMouseleave: this.mouseleave, - onFocused: this.focused, }, [ h(this.itemType(), { item: this.item, - focused: this.focused, + onToggleSettings: this.toggleSettings, }), ], ); diff --git a/administrator/components/com_media/resources/scripts/components/browser/items/video.vue b/administrator/components/com_media/resources/scripts/components/browser/items/video.vue index 4ba3978ed5e33..9e7af2222b106 100644 --- a/administrator/components/com_media/resources/scripts/components/browser/items/video.vue +++ b/administrator/components/com_media/resources/scripts/components/browser/items/video.vue @@ -16,11 +16,11 @@ @@ -30,6 +30,7 @@ export default { name: 'MediaBrowserItemVideo', // eslint-disable-next-line vue/require-prop-types props: ['item', 'focused'], + emits: ['toggle-settings'], data() { return { showActions: false, @@ -44,6 +45,9 @@ export default { openPreview() { this.$refs.container.openPreview(); }, + toggleSettings(bool) { + this.$emit('toggle-settings', bool); + }, }, }; diff --git a/administrator/components/com_media/resources/scripts/components/tree/drive.vue b/administrator/components/com_media/resources/scripts/components/tree/drive.vue index fcd0aa88ee175..96a30b01e1331 100644 --- a/administrator/components/com_media/resources/scripts/components/tree/drive.vue +++ b/administrator/components/com_media/resources/scripts/components/tree/drive.vue @@ -10,18 +10,26 @@ >
  • - + {{ drive.displayName }}
  • @@ -50,6 +58,12 @@ export default { onDriveClick() { this.navigateTo(this.drive.root); }, + moveFocusToChildElement(nextRoot) { + this.$refs[nextRoot].setFocusToFirstChild(); + }, + restoreFocus() { + this.$refs['drive-root'].focus(); + }, }, }; diff --git a/administrator/components/com_media/resources/scripts/components/tree/item.vue b/administrator/components/com_media/resources/scripts/components/tree/item.vue deleted file mode 100644 index 0794d69384788..0000000000000 --- a/administrator/components/com_media/resources/scripts/components/tree/item.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - diff --git a/administrator/components/com_media/resources/scripts/components/tree/tree.vue b/administrator/components/com_media/resources/scripts/components/tree/tree.vue index 21740eb62edf5..5f3555700f1af 100644 --- a/administrator/components/com_media/resources/scripts/components/tree/tree.vue +++ b/administrator/components/com_media/resources/scripts/components/tree/tree.vue @@ -3,20 +3,52 @@ class="media-tree" role="group" > - + class="media-tree-item" + :class="{active: isActive(item)}" + role="none" + > + + + {{ item.name }} + + + + + diff --git a/administrator/components/com_media/resources/scripts/mediamanager.es6.js b/administrator/components/com_media/resources/scripts/mediamanager.es6.js index 52b763d2ec326..5eac760e70196 100644 --- a/administrator/components/com_media/resources/scripts/mediamanager.es6.js +++ b/administrator/components/com_media/resources/scripts/mediamanager.es6.js @@ -4,7 +4,6 @@ import App from './components/app.vue'; import Disk from './components/tree/disk.vue'; import Drive from './components/tree/drive.vue'; import Tree from './components/tree/tree.vue'; -import TreeItem from './components/tree/item.vue'; import Toolbar from './components/toolbar/toolbar.vue'; import Breadcrumb from './components/breadcrumb/breadcrumb.vue'; import Browser from './components/browser/browser.vue'; @@ -38,7 +37,6 @@ app.use(translate); app.component('MediaDrive', Drive); app.component('MediaDisk', Disk); app.component('MediaTree', Tree); -app.component('MediaTreeItem', TreeItem); app.component('MediaToolbar', Toolbar); app.component('MediaBreadcrumb', Breadcrumb); app.component('MediaBrowser', Browser); diff --git a/administrator/components/com_messages/src/Model/MessagesModel.php b/administrator/components/com_messages/src/Model/MessagesModel.php index 722929eede357..0077db55d982d 100644 --- a/administrator/components/com_messages/src/Model/MessagesModel.php +++ b/administrator/components/com_messages/src/Model/MessagesModel.php @@ -174,7 +174,7 @@ protected function getListQuery() * * @return void * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function purge(int $userId): void { diff --git a/administrator/components/com_postinstall/src/Controller/MessageController.php b/administrator/components/com_postinstall/src/Controller/MessageController.php index ff2fc8e82453e..1342187f6713c 100644 --- a/administrator/components/com_postinstall/src/Controller/MessageController.php +++ b/administrator/components/com_postinstall/src/Controller/MessageController.php @@ -35,7 +35,7 @@ public function reset() $this->checkToken('get'); /** @var MessagesModel $model */ - $model = $this->getModel('Messages', '', array('ignore_request' => true)); + $model = $this->getModel('Messages', '', ['ignore_request' => true]); $eid = $this->input->getInt('eid'); if (empty($eid)) @@ -57,7 +57,7 @@ public function reset() */ public function unpublish() { - $model = $this->getModel('Messages', '', array('ignore_request' => true)); + $model = $this->getModel('Messages', '', ['ignore_request' => true]); $id = $this->input->get('id'); @@ -79,11 +79,11 @@ public function unpublish() * * @return void * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function republish() { - $model = $this->getModel('Messages', '', array('ignore_request' => true)); + $model = $this->getModel('Messages', '', ['ignore_request' => true]); $id = $this->input->get('id'); @@ -105,11 +105,11 @@ public function republish() * * @return void * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function archive() { - $model = $this->getModel('Messages', '', array('ignore_request' => true)); + $model = $this->getModel('Messages', '', ['ignore_request' => true]); $id = $this->input->get('id'); @@ -137,7 +137,7 @@ public function action() { $this->checkToken('get'); - $model = $this->getModel('Messages', '', array('ignore_request' => true)); + $model = $this->getModel('Messages', '', ['ignore_request' => true]); $id = $this->input->get('id'); @@ -178,7 +178,7 @@ public function hideAll() $this->checkToken(); /** @var MessagesModel $model */ - $model = $this->getModel('Messages', '', array('ignore_request' => true)); + $model = $this->getModel('Messages', '', ['ignore_request' => true]); $eid = $this->input->getInt('eid'); if (empty($eid)) diff --git a/administrator/components/com_postinstall/src/Model/MessagesModel.php b/administrator/components/com_postinstall/src/Model/MessagesModel.php index 99fcfd0218c02..5dd094febeb61 100644 --- a/administrator/components/com_postinstall/src/Model/MessagesModel.php +++ b/administrator/components/com_postinstall/src/Model/MessagesModel.php @@ -127,11 +127,11 @@ public function unpublishMessage($id) * * @return void * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function archiveMessage($id) { - $db = $this->getDbo(); + $db = $this->getDatabase(); $id = (int) $id; $query = $db->getQuery(true); @@ -152,11 +152,11 @@ public function archiveMessage($id) * * @return void * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function republishMessage($id) { - $db = $this->getDbo(); + $db = $this->getDatabase(); $id = (int) $id; $query = $db->getQuery(true); @@ -210,7 +210,7 @@ public function getItems() ->bind(':eid', $eid, ParameterType::INTEGER); // Force filter only enabled messages - $query->where($db->quoteName('enabled') . ' IN (1,2)'); + $query->whereIn($db->quoteName('enabled'), [1, 2]); $db->setQuery($query); try diff --git a/administrator/components/com_scheduler/src/Controller/TasksController.php b/administrator/components/com_scheduler/src/Controller/TasksController.php index 635e169d520ee..ba2252dddf16c 100644 --- a/administrator/components/com_scheduler/src/Controller/TasksController.php +++ b/administrator/components/com_scheduler/src/Controller/TasksController.php @@ -12,7 +12,6 @@ // Restrict direct access \defined('_JEXEC') or die; -use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\MVC\Model\BaseDatabaseModel; @@ -84,8 +83,7 @@ public function unlock(): void if ($errors) { - Factory::getApplication() - ->enqueueMessage(Text::plural($this->text_prefix . '_N_ITEMS_FAILED_UNLOCKING', \count($cid)), 'error'); + $this->app->enqueueMessage(Text::plural($this->text_prefix . '_N_ITEMS_FAILED_UNLOCKING', \count($cid)), 'error'); } else { diff --git a/administrator/components/com_templates/src/Model/StyleModel.php b/administrator/components/com_templates/src/Model/StyleModel.php index 4acffdbb9c1ed..427101683fa8a 100644 --- a/administrator/components/com_templates/src/Model/StyleModel.php +++ b/administrator/components/com_templates/src/Model/StyleModel.php @@ -743,7 +743,7 @@ public function getHelp() * * @return stdClass * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function getAdminTemplate(int $styleId): stdClass { @@ -789,7 +789,7 @@ public function getAdminTemplate(int $styleId): stdClass * * @return array * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ public function getSiteTemplates(): array { diff --git a/administrator/language/en-GB/com_installer.ini b/administrator/language/en-GB/com_installer.ini index 90c1d9895ab44..245f636c802db 100644 --- a/administrator/language/en-GB/com_installer.ini +++ b/administrator/language/en-GB/com_installer.ini @@ -144,6 +144,7 @@ COM_INSTALLER_MSG_DISCOVER_NOEXTENSION="No extensions have been discover COM_INSTALLER_MSG_DISCOVER_NOEXTENSIONSELECTED="No extension selected." COM_INSTALLER_MSG_DISCOVER_PURGEDDISCOVEREDEXTENSIONS="Cleared discovered extensions." COM_INSTALLER_MSG_ERROR_CANT_CONNECT_TO_UPDATESERVER="Can't connect to %s" +COM_INSTALLER_MSG_ERROR_CANT_RETRIEVE_XML="Can't retrieve XML from %s" COM_INSTALLER_MSG_INSTALL_ENTER_A_URL="Please enter a URL" COM_INSTALLER_MSG_INSTALL_INVALID_URL="Invalid URL" COM_INSTALLER_MSG_INSTALL_INVALID_URL_SCHEME="Please enter a valid URL starting with http or https." diff --git a/administrator/language/en-GB/lib_joomla.ini b/administrator/language/en-GB/lib_joomla.ini index 178145886bce9..e424927663bd4 100644 --- a/administrator/language/en-GB/lib_joomla.ini +++ b/administrator/language/en-GB/lib_joomla.ini @@ -758,3 +758,11 @@ JLIB_SIZE_PB="PiB" JLIB_SIZE_EB="EiB" JLIB_SIZE_ZB="ZiB" JLIB_SIZE_YB="YiB" + +; Database server technology types in human readable terms. Used in the Updater package. +JLIB_DB_SERVER_TYPE_MARIADB="MariaDB" +JLIB_DB_SERVER_TYPE_MSSQL="Microsoft SQL Server" +JLIB_DB_SERVER_TYPE_MYSQL="MySQL" +JLIB_DB_SERVER_TYPE_ORACLE="Oracle" +JLIB_DB_SERVER_TYPE_POSTGRESQL="PostgreSQL" +JLIB_DB_SERVER_TYPE_SQLITE="SQLite" diff --git a/administrator/language/en-GB/plg_system_shortcut.ini b/administrator/language/en-GB/plg_system_shortcut.ini new file mode 100644 index 0000000000000..b1f63f712db3b --- /dev/null +++ b/administrator/language/en-GB/plg_system_shortcut.ini @@ -0,0 +1,12 @@ +; 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_SYSTEM_SHORTCUT="System - Keyboard Shortcuts" +PLG_SYSTEM_SHORTCUT_OVERVIEW_DESC="Press J to access the shortcut mode followed by the shortcut." +PLG_SYSTEM_SHORTCUT_OVERVIEW_HINT=" J + X Keyboard Shortcuts" +PLG_SYSTEM_SHORTCUT_OVERVIEW_TITLE="Joomla Keyboard Shortcuts" +PLG_SYSTEM_SHORTCUT_TIMEOUT_DESC="Maximum time that a shortcut can be pressed after pressing J." +PLG_SYSTEM_SHORTCUT_TIMEOUT_LABEL="Timeout (in milliseconds)" +PLG_SYSTEM_SHORTCUT_XML_DESCRIPTION="

    Enables keyboard shortcuts on the administrator site, which can be provided by other plugins and includes directly the following list of shortcuts:

    • J A Save
    • J S Save & Close
    • J Q Cancel
    • J N New
    • J F Search
    • J O Options
    • J H Help
    • J X Overview
    • J D Home Dashboard
    " diff --git a/administrator/language/en-GB/plg_system_shortcut.sys.ini b/administrator/language/en-GB/plg_system_shortcut.sys.ini new file mode 100644 index 0000000000000..365aa9ea155b1 --- /dev/null +++ b/administrator/language/en-GB/plg_system_shortcut.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_SYSTEM_SHORTCUT="System - Keyboard Shortcuts" +PLG_SYSTEM_SHORTCUT_XML_DESCRIPTION="

    Enables keyboard shortcuts on the administrator site, which can be provided by other plugins and includes directly the following list of shortcuts:

    • J A Save
    • J S Save & Close
    • J Q Cancel
    • J N New
    • J F Search
    • J O Options
    • J H Help
    • J X Overview
    • J D Home Dashboard
    " diff --git a/administrator/language/en-GB/plg_system_webauthn.ini b/administrator/language/en-GB/plg_system_webauthn.ini index af0b5a61f1595..1f1f7dcf8fba5 100644 --- a/administrator/language/en-GB/plg_system_webauthn.ini +++ b/administrator/language/en-GB/plg_system_webauthn.ini @@ -18,17 +18,21 @@ PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE="Cannot save credentials. T PLG_SYSTEM_WEBAUTHN_ERR_EMPTY_USERNAME="You need to enter your username (but NOT your password) before selecting the Web Authentication login button." PLG_SYSTEM_WEBAUTHN_ERR_INVALID_USERNAME="The specified username does not correspond to a user account that has enabled passwordless login on this site." PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED="Could not save the new label" +PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED="Could not remove the authenticator" PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT="Sorry, your browser does not support the W3C Web Authentication standard for passwordless logins or your site is not being served over HTTPS with a valid certificate, signed by a Certificate Authority your browser trusts. You will need to log into this site using your username and password." PLG_SYSTEM_WEBAUTHN_ERR_NO_STORED_CREDENTIAL="Cannot find the stored credentials for your login authenticator." -PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED="Could not remove the authenticator" PLG_SYSTEM_WEBAUTHN_ERR_USER_REMOVED="The user for this authenticator seems to no longer exist on this site." +PLG_SYSTEM_WEBAUTHN_ERR_XHR_INITCREATE="Cannot get the authenticator registration information from your site." +PLG_SYSTEM_WEBAUTHN_FIELD_ATTESTATION_SUPPORT_DESC="Only allow authenticators with verifiable cryptographic signatures to be used for WebAuthn logins. Strongly recommended for high security environments. Requires your site to be able to access https://mds.fidoalliance.org/ directly, a writeable cache directory, the system temporary directory being writeable by PHP, and the OpenSSL extension. May prevent some cheaper, non-certified authenticators from working at all. Disabling it also prevents Joomla from identifying the make and model of the authenticator you are using (no icon will be displayed next to the Authenticator Name).
    Pro tip: If you are behind a firewall you can place the data downloaded from the FIDO Alliance into the file administrator/cache/fido.jwt for this feature to work properly." +PLG_SYSTEM_WEBAUTHN_FIELD_ATTESTATION_SUPPORT_LABEL="Attestation Support" PLG_SYSTEM_WEBAUTHN_FIELD_DESC="Lets you manage passwordless login methods using the W3C Web Authentication standard. You need a supported browser and authenticator (eg Google Chrome or Firefox with a FIDO2 certified security key).

    MacOS/iOS/watchOS: Touch/Face ID.
    Windows: Hello (Fingerprint / Facial Recognition / PIN).
    Android: Biometric screen lock.

    You can find more details in the WebAuthn Passwordless Login documentation." PLG_SYSTEM_WEBAUTHN_FIELD_LABEL="W3C Web Authentication (WebAuthn) Login" PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED="%d WebAuthn authenticators already set up: %s" PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED_0="No WebAuthn authenticator has been set up yet" PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED_1="One WebAuthn authenticator already set up: %2$s" PLG_SYSTEM_WEBAUTHN_HEADER="W3C Web Authentication (WebAuthn) Login" -PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL="Authenticator added on %s" +PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR="Generic Authenticator" +PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL="%s added on %s" PLG_SYSTEM_WEBAUTHN_LOGIN_DESC="Login without a password using the W3C Web Authentication (WebAuthn) standard in compatible browsers. You need to have already set up WebAuthn authentication in your user profile." PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL="Web Authentication" PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_ADD_LABEL="Add New Authenticator" diff --git a/administrator/manifests/files/joomla.xml b/administrator/manifests/files/joomla.xml index bd2a7002d4a2b..f011e3cc09fa7 100644 --- a/administrator/manifests/files/joomla.xml +++ b/administrator/manifests/files/joomla.xml @@ -6,7 +6,7 @@ www.joomla.org (C) 2019 Open Source Matters, Inc. GNU General Public License version 2 or later; see LICENSE.txt - 4.2.0-beta2-dev + 4.2.0-dev 2022-06 FILES_JOOMLA_XML_DESCRIPTION diff --git a/build/build-modules-js/settings.json b/build/build-modules-js/settings.json index 8924c2334562b..f6c5f39d67e92 100644 --- a/build/build-modules-js/settings.json +++ b/build/build-modules-js/settings.json @@ -386,6 +386,24 @@ "dependencies": [], "licenseFilename": "LICENSE.txt" }, + "hotkeys-js": { + "name": "hotkeys.js", + "licenseFilename": "LICENSE", + "js" : { + "dist/hotkeys.js": "js/hotkeys.js", + "dist/hotkeys.min.js": "js/hotkeys.min.js" + }, + "provideAssets": [ + { + "name": "hotkeys.js", + "type": "script", + "uri": "hotkeys.min.js", + "attributes": { + "defer": true + } + } + ] + }, "jquery": { "name": "jquery", "js": { diff --git a/build/media_source/plg_system_shortcut/js/shortcut.es6.js b/build/media_source/plg_system_shortcut/js/shortcut.es6.js new file mode 100644 index 0000000000000..61cbc8b27ca8c --- /dev/null +++ b/build/media_source/plg_system_shortcut/js/shortcut.es6.js @@ -0,0 +1,168 @@ +((document, Joomla) => { + 'use strict'; + + if (!Joomla) { + throw new Error('Joomla API is not properly initialised'); + } + + /* global hotkeys */ + Joomla.addShortcut = (hotkey, callback) => { + hotkeys(hotkey, 'joomla', (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + callback.call(); + }); + }; + + Joomla.addClickShortcut = (hotkey, selector) => { + Joomla.addShortcut(hotkey, () => { + const element = document.querySelector(selector); + if (element) { + element.click(); + } + }); + }; + + Joomla.addFocusShortcut = (hotkey, selector) => { + Joomla.addShortcut(hotkey, () => { + const element = document.querySelector(selector); + if (element) { + element.focus(); + } + }); + }; + + Joomla.addLinkShortcut = (hotkey, selector) => { + Joomla.addShortcut(hotkey, () => { + window.location.href = selector; + }); + }; + + const setShortcutFilter = () => { + hotkeys.filter = (event) => { + const target = event.target || event.srcElement; + const { tagName } = target; + + // Checkboxes should not block a shortcut event + if (target.type === 'checkbox') { + return true; + } + // Default hotkeys filter behavior + return !(target.isContentEditable || tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA'); + }; + }; + + const startupShortcuts = () => { + hotkeys('J', (event) => { + // If we're already in the scope, it's a normal shortkey + if (hotkeys.getScope() === 'joomla') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + hotkeys.setScope('joomla'); + + // Leave the scope after x milliseconds + setTimeout(() => { + hotkeys.setScope(false); + }, Joomla.getOptions('plg_system_shortcut.timeout', 2000)); + }); + }; + + const addOverviewHint = () => { + const mainContainer = document.querySelector('.com_cpanel .container-main'); + if (mainContainer) { + const containerElement = document.createElement('section'); + containerElement.className = 'content pt-4'; + containerElement.insertAdjacentHTML('beforeend', Joomla.Text._('PLG_SYSTEM_SHORTCUT_OVERVIEW_HINT')); + mainContainer.appendChild(containerElement); + } + }; + + const initOverviewModal = (options) => { + const dlItems = new Map(); + Object.values(options).forEach((value) => { + if (!value.shortcut || !value.title) { + return; + } + let titles = []; + if (dlItems.has(value.shortcut)) { + titles = dlItems.get(value.shortcut); + titles.push(value.title); + } else { + titles = [value.title]; + } + dlItems.set(value.shortcut, titles); + }); + + let dl = '
    '; + dlItems.forEach((titles, shortcut) => { + dl += '
    J'; + shortcut.split('+').forEach((key) => { + dl += ` ${key.trim()}`; + }); + dl += '
    '; + titles.forEach((title) => { + dl += `
    ${title}
    `; + }); + }); + dl += '
    '; + + const modal = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modal); + + const bootstrapModal = new bootstrap.Modal(document.getElementById('shortcutOverviewModal'), { + keyboard: true, + backdrop: true, + }); + hotkeys('X', 'joomla', () => bootstrapModal.show()); + }; + + document.addEventListener('DOMContentLoaded', () => { + const options = Joomla.getOptions('plg_system_shortcut.shortcuts'); + Object.values(options).forEach((value) => { + if (!value.shortcut || !value.selector) { + return; + } + if (value.selector.startsWith('/') || value.selector.startsWith('http://') || value.selector.startsWith('www.')) { + Joomla.addLinkShortcut(value.shortcut, value.selector); + } else if (value.selector.includes('input')) { + Joomla.addFocusShortcut(value.shortcut, value.selector); + } else { + Joomla.addClickShortcut(value.shortcut, value.selector); + } + }); + // Show hint and overview on logged in backend only (not login page) + if (document.querySelector('nav')) { + initOverviewModal(options); + addOverviewHint(); + } + setShortcutFilter(); + startupShortcuts(); + }); +})(document, Joomla); diff --git a/build/media_source/plg_system_webauthn/images/fido.png b/build/media_source/plg_system_webauthn/images/fido.png new file mode 100644 index 0000000000000..444bab5f4c117 Binary files /dev/null and b/build/media_source/plg_system_webauthn/images/fido.png differ diff --git a/build/media_source/plg_system_webauthn/js/login.es6.js b/build/media_source/plg_system_webauthn/js/login.es6.js index 97047b50c7344..ee98feee2409c 100644 --- a/build/media_source/plg_system_webauthn/js/login.es6.js +++ b/build/media_source/plg_system_webauthn/js/login.es6.js @@ -120,10 +120,8 @@ window.Joomla = window.Joomla || {}; * internal page which handles the login server-side. * * @param { Object} publicKey Public key request options, returned from the server - * @param {String} callbackUrl The URL we will use to post back to the server. Must include - * the anti-CSRF token. */ - const handleLoginChallenge = (publicKey, callbackUrl) => { + const handleLoginChallenge = (publicKey) => { const arrayToBase64String = (a) => btoa(String.fromCharCode(...a)); const base64url2base64 = (input) => { @@ -172,7 +170,8 @@ window.Joomla = window.Joomla || {}; }; // Send the response to your server - window.location = `${callbackUrl}&option=com_ajax&group=system&plugin=webauthn&` + const paths = Joomla.getOptions('system.paths'); + window.location = `${paths ? `${paths.base}/index.php` : window.location.pathname}?${Joomla.getOptions('csrf.token')}=1&option=com_ajax&group=system&plugin=webauthn&` + `format=raw&akaction=login&encoding=redirect&data=${ btoa(JSON.stringify(publicKeyCredential))}`; }) @@ -187,13 +186,11 @@ window.Joomla = window.Joomla || {}; * for the user. * * @param {string} formId The login form's or login module's HTML ID - * @param {string} callbackUrl The URL we will use to post back to the server. Must include - * the anti-CSRF token. * * @returns {boolean} Always FALSE to prevent BUTTON elements from reloading the page. */ // eslint-disable-next-line no-unused-vars - Joomla.plgSystemWebauthnLogin = (formId, callbackUrl) => { + Joomla.plgSystemWebauthnLogin = (formId) => { // Get the username const elFormContainer = document.getElementById(formId); const elUsername = lookForField(elFormContainer, 'input[name=username]'); @@ -226,9 +223,14 @@ window.Joomla = window.Joomla || {}; username, returnUrl, }; + postBackData[Joomla.getOptions('csrf.token')] = 1; + + const paths = Joomla.getOptions('system.paths'); Joomla.request({ - url: callbackUrl, + url: `${paths ? `${paths.base}/index.php` : window.location.pathname}?${Joomla.getOptions( + 'csrf.token', + )}=1`, method: 'POST', data: interpolateParameters(postBackData), onSuccess(rawResponse) { @@ -243,7 +245,7 @@ window.Joomla = window.Joomla || {}; */ } - handleLoginChallenge(jsonData, callbackUrl); + handleLoginChallenge(jsonData); }, onError: (xhr) => { handleLoginError(`${xhr.status} ${xhr.statusText}`); @@ -258,7 +260,7 @@ window.Joomla = window.Joomla || {}; if (loginButtons.length) { loginButtons.forEach((button) => { button.addEventListener('click', ({ currentTarget }) => { - Joomla.plgSystemWebauthnLogin(currentTarget.getAttribute('data-webauthn-form'), currentTarget.getAttribute('data-webauthn-url')); + Joomla.plgSystemWebauthnLogin(currentTarget.getAttribute('data-webauthn-form')); }); }); } diff --git a/build/media_source/plg_system_webauthn/js/management.es6.js b/build/media_source/plg_system_webauthn/js/management.es6.js index 58cdbb4d12873..d4abb618bcdea 100644 --- a/build/media_source/plg_system_webauthn/js/management.es6.js +++ b/build/media_source/plg_system_webauthn/js/management.es6.js @@ -62,13 +62,9 @@ window.Joomla = window.Joomla || {}; * Posts the credentials to the URL defined in post_url using AJAX. * That URL must re-render the management interface. * These contents will replace the element identified by the interface_selector CSS selector. - * - * @param {String} storeID CSS ID for the element storing the configuration in its - * data properties - * @param {String} interfaceSelector CSS selector for the GUI container */ // eslint-disable-next-line no-unused-vars - Joomla.plgSystemWebauthnCreateCredentials = (storeID, interfaceSelector) => { + Joomla.plgSystemWebauthnInitCreateCredentials = () => { // Make sure the browser supports Webauthn if (!('credentials' in navigator)) { Joomla.renderMessages({ error: [Joomla.Text._('PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT')] }); @@ -76,15 +72,42 @@ window.Joomla = window.Joomla || {}; return; } - // Extract the configuration from the store - const elStore = document.getElementById(storeID); + // Get the public key creation options through AJAX. + const paths = Joomla.getOptions('system.paths'); + const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`; - if (!elStore) { - return; - } + const postBackData = { + option: 'com_ajax', + group: 'system', + plugin: 'webauthn', + format: 'json', + akaction: 'initcreate', + encoding: 'json', + }; + postBackData[Joomla.getOptions('csrf.token')] = 1; + + Joomla.request({ + url: postURL, + method: 'POST', + data: interpolateParameters(postBackData), + onSuccess(response) { + try { + const publicKey = JSON.parse(response); + + Joomla.plgSystemWebauthnCreateCredentials(publicKey); + } catch (exception) { + handleCreationError(Joomla.Text._('PLG_SYSTEM_WEBAUTHN_ERR_XHR_INITCREATE')); + } + }, + onError: (xhr) => { + handleCreationError(`${xhr.status} ${xhr.statusText}`); + }, + }); + }; - const publicKey = JSON.parse(atob(elStore.dataset.public_key)); - const postURL = atob(elStore.dataset.postback_url); + Joomla.plgSystemWebauthnCreateCredentials = (publicKey) => { + const paths = Joomla.getOptions('system.paths'); + const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`; const arrayToBase64String = (a) => btoa(String.fromCharCode(...a)); @@ -137,13 +160,14 @@ window.Joomla = window.Joomla || {}; encoding: 'raw', data: btoa(JSON.stringify(publicKeyCredential)), }; + postBackData[Joomla.getOptions('csrf.token')] = 1; Joomla.request({ url: postURL, method: 'POST', data: interpolateParameters(postBackData), onSuccess(responseHTML) { - const elements = document.querySelectorAll(interfaceSelector); + const elements = document.querySelectorAll('#plg_system_webauthn-management-interface'); if (!elements) { return; @@ -154,6 +178,7 @@ window.Joomla = window.Joomla || {}; elContainer.outerHTML = responseHTML; Joomla.plgSystemWebauthnInitialize(); + Joomla.plgSystemWebauthnReactivateTooltips(); }, onError: (xhr) => { handleCreationError(`${xhr.status} ${xhr.statusText}`); @@ -175,15 +200,9 @@ window.Joomla = window.Joomla || {}; * properties */ // eslint-disable-next-line no-unused-vars - Joomla.plgSystemWebauthnEditLabel = (that, storeID) => { - // Extract the configuration from the store - const elStore = document.getElementById(storeID); - - if (!elStore) { - return false; - } - - const postURL = atob(elStore.dataset.postback_url); + Joomla.plgSystemWebauthnEditLabel = (that) => { + const paths = Joomla.getOptions('system.paths'); + const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`; // Find the UI elements const elTR = that.parentElement.parentElement; @@ -198,10 +217,14 @@ window.Joomla = window.Joomla || {}; // Show the editor const oldLabel = elLabelTD.innerText; + const elContainer = document.createElement('div'); + elContainer.className = 'webauthnManagementEditorRow d-flex gap-2'; + const elInput = document.createElement('input'); elInput.type = 'text'; elInput.name = 'label'; elInput.defaultValue = oldLabel; + elInput.className = 'form-control'; const elSave = document.createElement('button'); elSave.className = 'btn btn-success btn-sm'; @@ -220,6 +243,7 @@ window.Joomla = window.Joomla || {}; credential_id: credentialId, new_label: elNewLabel, }; + postBackData[Joomla.getOptions('csrf.token')] = 1; Joomla.request({ url: postURL, @@ -268,9 +292,10 @@ window.Joomla = window.Joomla || {}; }, false); elLabelTD.innerHTML = ''; - elLabelTD.appendChild(elInput); - elLabelTD.appendChild(elSave); - elLabelTD.appendChild(elCancel); + elContainer.appendChild(elInput); + elContainer.appendChild(elSave); + elContainer.appendChild(elCancel); + elLabelTD.appendChild(elContainer); elEdit.disabled = true; elDelete.disabled = true; @@ -281,19 +306,15 @@ window.Joomla = window.Joomla || {}; * Delete button * * @param {Element} that The button being clicked - * @param {String} storeID CSS ID for the element storing the configuration in its data - * properties */ // eslint-disable-next-line no-unused-vars - Joomla.plgSystemWebauthnDelete = (that, storeID) => { - // Extract the configuration from the store - const elStore = document.getElementById(storeID); - - if (!elStore) { + Joomla.plgSystemWebauthnDelete = (that) => { + if (!window.confirm(Joomla.Text._('JGLOBAL_CONFIRM_DELETE'))) { return false; } - const postURL = atob(elStore.dataset.postback_url); + const paths = Joomla.getOptions('system.paths'); + const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`; // Find the UI elements const elTR = that.parentElement.parentElement; @@ -317,6 +338,7 @@ window.Joomla = window.Joomla || {}; akaction: 'delete', credential_id: credentialId, }; + postBackData[Joomla.getOptions('csrf.token')] = 1; Joomla.request({ url: postURL, @@ -354,6 +376,45 @@ window.Joomla = window.Joomla || {}; return false; }; + Joomla.plgSystemWebauthnReactivateTooltips = () => { + const tooltips = Joomla.getOptions('bootstrap.tooltip'); + if (typeof tooltips === 'object' && tooltips !== null) { + Object.keys(tooltips).forEach((tooltip) => { + const opt = tooltips[tooltip]; + const options = { + animation: opt.animation ? opt.animation : true, + container: opt.container ? opt.container : false, + delay: opt.delay ? opt.delay : 0, + html: opt.html ? opt.html : false, + selector: opt.selector ? opt.selector : false, + trigger: opt.trigger ? opt.trigger : 'hover focus', + fallbackPlacement: opt.fallbackPlacement ? opt.fallbackPlacement : null, + boundary: opt.boundary ? opt.boundary : 'clippingParents', + title: opt.title ? opt.title : '', + customClass: opt.customClass ? opt.customClass : '', + sanitize: opt.sanitize ? opt.sanitize : true, + sanitizeFn: opt.sanitizeFn ? opt.sanitizeFn : null, + popperConfig: opt.popperConfig ? opt.popperConfig : null, + }; + + if (opt.placement) { + options.placement = opt.placement; + } + if (opt.template) { + options.template = opt.template; + } + if (opt.allowList) { + options.allowList = opt.allowList; + } + + const elements = Array.from(document.querySelectorAll(tooltip)); + if (elements.length) { + elements.map((el) => new window.bootstrap.Tooltip(el, options)); + } + }); + } + }; + /** * Add New Authenticator button click handler * @@ -364,7 +425,7 @@ window.Joomla = window.Joomla || {}; Joomla.plgSystemWebauthnAddOnClick = (event) => { event.preventDefault(); - Joomla.plgSystemWebauthnCreateCredentials(event.currentTarget.getAttribute('data-random-id'), '#plg_system_webauthn-management-interface'); + Joomla.plgSystemWebauthnInitCreateCredentials(); return false; }; @@ -379,7 +440,7 @@ window.Joomla = window.Joomla || {}; Joomla.plgSystemWebauthnEditOnClick = (event) => { event.preventDefault(); - Joomla.plgSystemWebauthnEditLabel(event.currentTarget, event.currentTarget.getAttribute('data-random-id')); + Joomla.plgSystemWebauthnEditLabel(event.currentTarget); return false; }; @@ -394,7 +455,7 @@ window.Joomla = window.Joomla || {}; Joomla.plgSystemWebauthnDeleteOnClick = (event) => { event.preventDefault(); - Joomla.plgSystemWebauthnDelete(event.currentTarget, event.currentTarget.getAttribute('data-random-id')); + Joomla.plgSystemWebauthnDelete(event.currentTarget); return false; }; diff --git a/build/media_source/system/js/core.es6.js b/build/media_source/system/js/core.es6.js index d0dd7bc06e8cf..6967fff737c80 100644 --- a/build/media_source/system/js/core.es6.js +++ b/build/media_source/system/js/core.es6.js @@ -573,7 +573,7 @@ window.Joomla.Modal = window.Joomla.Modal || { * * @type {Array} * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ const requestQueue = []; @@ -582,7 +582,7 @@ window.Joomla.Modal = window.Joomla.Modal || { * * @type {boolean} * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ let performingQueuedRequest = false; @@ -624,7 +624,7 @@ window.Joomla.Modal = window.Joomla.Modal || { /** * Processes queued Request objects. * - * @since __DEPLOY_VERSION__ + * @since 4.2.0 */ const processQueuedRequests = () => { if (performingQueuedRequest || requestQueue.length === 0) { diff --git a/build/media_source/system/js/multiselect.es6.js b/build/media_source/system/js/multiselect.es6.js index 5ac631e6db782..58003123513f2 100644 --- a/build/media_source/system/js/multiselect.es6.js +++ b/build/media_source/system/js/multiselect.es6.js @@ -66,12 +66,14 @@ return; } - const currentRowNum = this.rows.indexOf(target.closest('tr')); - const currentCheckBox = this.checkallToggle ? currentRowNum + 1 : currentRowNum; - let isChecked = this.boxes[currentCheckBox].checked; + const closestRow = target.closest('tr'); + const currentRowNum = this.rows.indexOf(closestRow); + const currentCheckBox = closestRow.querySelector('td input[type=checkbox]'); - if (currentCheckBox >= 0) { - if (!(target.id === this.boxes[currentCheckBox].id)) { + if (currentCheckBox) { + let isChecked = currentCheckBox.checked; + + if (!(target.id === currentCheckBox.id)) { // We will prevent selecting text to prevent artifacts if (shiftKey) { document.body.style['-webkit-user-select'] = 'none'; @@ -80,12 +82,12 @@ document.body.style['user-select'] = 'none'; } - this.boxes[currentCheckBox].checked = !this.boxes[currentCheckBox].checked; - isChecked = this.boxes[currentCheckBox].checked; - Joomla.isChecked(this.boxes[currentCheckBox].checked, this.tableEl.id); + currentCheckBox.checked = !currentCheckBox.checked; + isChecked = currentCheckBox.checked; + Joomla.isChecked(isChecked, this.tableEl.id); } - this.changeBg(this.rows[currentCheckBox - 1], isChecked); + this.changeBg(this.rows[currentRowNum], isChecked); // Restore normality if (shiftKey) { diff --git a/components/com_contact/src/Controller/ContactController.php b/components/com_contact/src/Controller/ContactController.php index 48b1130edb0e6..5b1ee4dcbb45f 100644 --- a/components/com_contact/src/Controller/ContactController.php +++ b/components/com_contact/src/Controller/ContactController.php @@ -80,7 +80,7 @@ public function submit() // Check for request forgeries. $this->checkToken(); - $app = Factory::getApplication(); + $app = $this->app; $model = $this->getModel('contact'); $stub = $this->input->getString('id'); $id = (int) $stub; @@ -301,7 +301,7 @@ private function _sendEmail($data, $contact, $emailCopyToSender) } catch (\RuntimeException $exception) { - Factory::getApplication()->enqueueMessage(Text::_($exception->errorMessage()), 'warning'); + $this->app->enqueueMessage(Text::_($exception->errorMessage()), 'warning'); $sent = false; } diff --git a/components/com_contact/src/Controller/DisplayController.php b/components/com_contact/src/Controller/DisplayController.php index 6c1788b5988f1..477d27adfaaca 100644 --- a/components/com_contact/src/Controller/DisplayController.php +++ b/components/com_contact/src/Controller/DisplayController.php @@ -58,7 +58,7 @@ public function __construct($config = array(), MVCFactoryInterface $factory = nu */ public function display($cachable = false, $urlparams = array()) { - if (Factory::getApplication()->getUserState('com_contact.contact.data') === null) + if ($this->app->getUserState('com_contact.contact.data') === null) { $cachable = true; } diff --git a/components/com_content/src/Controller/ArticleController.php b/components/com_content/src/Controller/ArticleController.php index b6005eb5b9a98..0c8f6c5ed70cc 100644 --- a/components/com_content/src/Controller/ArticleController.php +++ b/components/com_content/src/Controller/ArticleController.php @@ -12,7 +12,6 @@ \defined('_JEXEC') or die; use Joomla\CMS\Application\SiteApplication; -use Joomla\CMS\Factory; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\FormController; @@ -173,7 +172,7 @@ public function cancel($key = 'a_id') $result = parent::cancel($key); /** @var SiteApplication $app */ - $app = Factory::getApplication(); + $app = $this->app; // Load the parameters. $params = $app->getParams(); @@ -373,7 +372,7 @@ public function save($key = null, $urlVar = 'a_id') return $result; } - $app = Factory::getApplication(); + $app = $this->app; $articleId = $app->input->getInt('a_id'); // Load the parameters. diff --git a/components/com_finder/src/Controller/DisplayController.php b/components/com_finder/src/Controller/DisplayController.php index 5552cd63f6adb..90d56ab5014c6 100644 --- a/components/com_finder/src/Controller/DisplayController.php +++ b/components/com_finder/src/Controller/DisplayController.php @@ -11,7 +11,6 @@ \defined('_JEXEC') or die; -use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\Component\Finder\Administrator\Helper\LanguageHelper; @@ -35,7 +34,7 @@ class DisplayController extends BaseController */ public function display($cachable = false, $urlparams = array()) { - $input = Factory::getApplication()->input; + $input = $this->app->input; $cachable = true; // Load plugin language files. diff --git a/components/com_privacy/src/Controller/RequestController.php b/components/com_privacy/src/Controller/RequestController.php index 8147528aa6b66..1611fe222346d 100644 --- a/components/com_privacy/src/Controller/RequestController.php +++ b/components/com_privacy/src/Controller/RequestController.php @@ -11,7 +11,6 @@ \defined('_JEXEC') or die; -use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Router\Route; @@ -48,7 +47,7 @@ public function confirm() if ($return instanceof \Exception) { // Get the error message to display. - if (Factory::getApplication()->get('error_reporting')) + if ($this->app->get('error_reporting')) { $message = $return->getMessage(); } @@ -102,7 +101,7 @@ public function submit() if ($return instanceof \Exception) { // Get the error message to display. - if (Factory::getApplication()->get('error_reporting')) + if ($this->app->get('error_reporting')) { $message = $return->getMessage(); } @@ -156,7 +155,7 @@ public function remind() if ($return instanceof \Exception) { // Get the error message to display. - if (Factory::getApplication()->get('error_reporting')) + if ($this->app->get('error_reporting')) { $message = $return->getMessage(); } diff --git a/components/com_tags/src/Helper/RouteHelper.php b/components/com_tags/src/Helper/RouteHelper.php index 981ac0f0dcb2f..99354fcb481bd 100644 --- a/components/com_tags/src/Helper/RouteHelper.php +++ b/components/com_tags/src/Helper/RouteHelper.php @@ -11,6 +11,7 @@ \defined('_JEXEC') or die; +use Exception; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Helper\RouteHelper as CMSRouteHelper; use Joomla\CMS\Menu\AbstractMenu; @@ -81,17 +82,38 @@ public static function getItemRoute($contentItemId, $contentItemAlias, $contentC /** * Tries to load the router for the component and calls it. Otherwise calls getRoute. * - * @param integer $id The ID of the tag + * @param integer $id The ID of the tag * * @return string URL link to pass to the router * - * @since 3.1 + * @since 3.1 + * @throws Exception + * @deprecated 5.0.0 Use getComponentTagRoute() instead */ public static function getTagRoute($id) { - $needles = array( - 'tag' => array((int) $id) - ); + @trigger_error('This function is replaced by the getComponentTagRoute()', E_USER_DEPRECATED); + + return self::getComponentTagRoute($id); + } + + /** + * Tries to load the router for the component and calls it. Otherwise calls getRoute. + * + * @param string $id The ID of the tag in the format TAG_ID:TAG_ALIAS + * @param string $language The language of the tag + * + * @return string URL link to pass to the router + * + * @since 4.2.0 + * @throws Exception + */ + public static function getComponentTagRoute(string $id, string $language = '*'): string + { + $needles = [ + 'tag' => [(int) $id], + 'language' => $language, + ]; if ($id < 1) { @@ -107,7 +129,10 @@ public static function getTagRoute($id) } else { - $needles = array('tags' => array(1, 0)); + $needles = [ + 'tags' => [1, 0], + 'language' => $language, + ]; if ($item = self::_findItem($needles)) { @@ -124,13 +149,33 @@ public static function getTagRoute($id) * * @return string URL link to pass to the router * - * @since 3.7 + * @since 3.7 + * @throws Exception + * @deprecated 5.0.0 */ public static function getTagsRoute() { - $needles = array( - 'tags' => array(0) - ); + @trigger_error('This function is replaced by the getComponentTagsRoute()', E_USER_DEPRECATED); + + return self::getComponentTagsRoute(); + } + + /** + * Tries to load the router for the tags view. + * + * @param string $language The language of the tag + * + * @return string URL link to pass to the router + * + * @since 4.2.0 + * @throws Exception + */ + public static function getComponentTagsRoute(string $language = '*'): string + { + $needles = [ + 'tags' => [0], + 'language' => $language, + ]; $link = 'index.php?option=com_tags&view=tags'; @@ -149,7 +194,7 @@ public static function getTagsRoute() * * @return null * - * @throws \Exception + * @throws Exception */ protected static function _findItem($needles = null) { diff --git a/components/com_tags/tmpl/tags/default_items.php b/components/com_tags/tmpl/tags/default_items.php index ff99c487e2509..a54d944d9cd54 100644 --- a/components/com_tags/tmpl/tags/default_items.php +++ b/components/com_tags/tmpl/tags/default_items.php @@ -95,7 +95,7 @@ class="inputbox" onchange="document.adminForm.submit();"
  • access)) && in_array($item->access, $this->user->getAuthorisedViewLevels())) : ?>

    - + escape($item->title); ?>

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