From 39145a4691a7f632ac871905761b73b3b6c2a2fe Mon Sep 17 00:00:00 2001 From: Oliver Bartsch <bo@cedev.de> Date: Fri, 29 Jan 2021 01:35:26 +0100 Subject: [PATCH] [FEATURE] Introduce MFA in Core A new API is introduced, providing multi-factor authentication for the Core. The API is furthermore directly used to add two MFA providers by default: * TOTP (time-based one-time passwords) * Recovery codes Even if the API is designed to allow MFA in both, backend and frontend, it is currently only implemented into the backend. Users can therefore configure their available MFA providers in a new backend module, accessible via their user settings. There are also some configuration options for administrators to e.g. define a recommended provider or to disallow available providers for specific users or user groups. Administration of the users' MFA providers is possible for administrators in the corresponding user records. New providers can be introduced by implementing the MfaProviderInterface and tagging the service with the `mfa.provider` tag. Note that the API is currently marked as internal since changes in upcoming patches are to be expected. Following dependencies are introduced: * bacon/bacon-qr-code "^2.0" * christian-riesen/base32 "^1.5" Possible features that could follow later-on: * MFA frontend integration * Webauthn core provider for FIDO2 and U2F. * Forcing users to set up MFA on login * Password-recovery with active MFA Resolves: #93526 Releases: master Change-Id: I4e902be624c80295c9c0c3286c90a6a680feeb5d Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67548 Reviewed-by: Benjamin Franzke <bfr@qbus.de> Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch> Reviewed-by: Benni Mack <benni@typo3.org> Tested-by: TYPO3com <noreply@typo3.com> Tested-by: core-ci <typo3@b13.com> Tested-by: Benjamin Franzke <bfr@qbus.de> Tested-by: Benni Mack <benni@typo3.org> --- .../FormEngine/Element/MfaInfoElement.ts | 167 +++++++ .../Resources/Public/TypeScript/Viewport.ts | 4 +- composer.json | 2 + composer.lock | 161 ++++++- .../Controller/AbstractMfaController.php | 128 ++++++ .../ElementInformationController.php | 5 + .../Classes/Controller/MfaAjaxController.php | 203 ++++++++ .../Controller/MfaConfigurationController.php | 432 ++++++++++++++++++ .../Classes/Controller/MfaController.php | 201 ++++++++ .../Classes/Form/Element/MfaInfoElement.php | 181 ++++++++ .../backend/Classes/Form/NodeFactory.php | 1 + .../Middleware/BackendUserAuthenticator.php | 37 +- .../ViewHelpers/Mfa/IfHasStateViewHelper.php | 47 ++ .../Configuration/Backend/AjaxRoutes.php | 6 + .../backend/Configuration/Backend/Routes.php | 12 + .../backend/Configuration/Services.yaml | 9 + .../Private/Language/locallang_mfa.xlf | 170 +++++++ .../Resources/Private/Templates/Mfa/Auth.html | 69 +++ .../Resources/Private/Templates/Mfa/Edit.html | 48 ++ .../Private/Templates/Mfa/Overview.html | 100 ++++ .../Private/Templates/Mfa/Setup.html | 30 ++ .../FormEngine/Element/MfaInfoElement.js | 13 + .../Resources/Public/JavaScript/Viewport.js | 2 +- .../ViewHelpers/MfaStatusViewHelper.php | 77 ++++ .../Resources/Private/Language/locallang.xlf | 6 + .../Partials/BackendUser/IndexListRow.html | 1 + .../Partials/BackendUser/OnlineListRow.html | 3 +- .../AbstractUserAuthentication.php | 43 +- .../BackendUserAuthentication.php | 6 +- .../Mfa/MfaProviderInterface.php | 122 +++++ .../Mfa/MfaProviderManifest.php | 149 ++++++ .../Mfa/MfaProviderManifestInterface.php | 68 +++ .../Mfa/MfaProviderPropertyManager.php | 220 +++++++++ .../Mfa/MfaProviderRegistry.php | 149 ++++++ .../Mfa/MfaRequiredException.php | 42 ++ .../Authentication/Mfa/MfaViewType.php | 32 ++ .../Mfa/Provider/RecoveryCodes.php | 136 ++++++ .../Mfa/Provider/RecoveryCodesProvider.php | 407 +++++++++++++++++ .../Authentication/Mfa/Provider/Totp.php | 220 +++++++++ .../Mfa/Provider/TotpProvider.php | 345 ++++++++++++++ .../DependencyInjection/MfaProviderPass.php | 89 ++++ .../Configuration/DefaultConfiguration.php | 2 + .../DefaultConfigurationDescription.yaml | 11 + typo3/sysext/core/Configuration/Services.php | 1 + typo3/sysext/core/Configuration/Services.yaml | 25 + .../core/Configuration/TCA/be_groups.php | 12 +- .../core/Configuration/TCA/be_users.php | 12 +- ...eature-93526-MultiFactorAuthentication.rst | 276 +++++++++++ .../Private/Language/locallang_core.xlf | 27 ++ .../Language/locallang_mfa_provider.xlf | 157 +++++++ .../Private/Language/locallang_tca.xlf | 6 + .../MfaProvider/RecoveryCodes/Auth.html | 26 ++ .../MfaProvider/RecoveryCodes/Edit.html | 82 ++++ .../MfaProvider/RecoveryCodes/Setup.html | 37 ++ .../Authentication/MfaProvider/Totp/Auth.html | 26 ++ .../Authentication/MfaProvider/Totp/Edit.html | 53 +++ .../MfaProvider/Totp/Setup.html | 59 +++ .../Provider/RecoveryCodesProviderTest.php | 88 ++++ .../Mfa/Provider/TotpProviderTest.php | 58 +++ .../Mfa/Provider/RecoveryCodesTest.php | 152 ++++++ .../Authentication/Mfa/Provider/TotpTest.php | 199 ++++++++ typo3/sysext/core/composer.json | 2 + typo3/sysext/core/ext_tables.sql | 2 + .../Middleware/BackendUserAuthenticator.php | 9 +- typo3/sysext/frontend/ext_tables.sql | 1 + .../MfaProvidersProvider.php | 44 ++ .../lowlevel/Configuration/Services.yaml | 8 + .../Resources/Private/Language/locallang.xlf | 3 + .../Controller/SetupModuleController.php | 27 +- .../Resources/Private/Language/locallang.xlf | 25 +- typo3/sysext/setup/ext_tables.php | 6 +- 71 files changed, 5564 insertions(+), 15 deletions(-) create mode 100644 Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts create mode 100644 typo3/sysext/backend/Classes/Controller/AbstractMfaController.php create mode 100644 typo3/sysext/backend/Classes/Controller/MfaAjaxController.php create mode 100644 typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php create mode 100644 typo3/sysext/backend/Classes/Controller/MfaController.php create mode 100644 typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php create mode 100644 typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php create mode 100644 typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf create mode 100644 typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html create mode 100644 typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html create mode 100644 typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html create mode 100644 typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js create mode 100644 typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php create mode 100644 typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php create mode 100644 typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst create mode 100644 typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf create mode 100644 typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html create mode 100644 typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html create mode 100644 typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html create mode 100644 typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html create mode 100644 typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html create mode 100644 typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html create mode 100644 typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php create mode 100644 typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php create mode 100644 typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts new file mode 100644 index 000000000000..1dc62390ffc5 --- /dev/null +++ b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts @@ -0,0 +1,167 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse'; +import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest'); +import DocumentService = require('TYPO3/CMS/Core/DocumentService'); +import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent'); +import Notification = require('TYPO3/CMS/Backend/Notification'); +import Modal = require('TYPO3/CMS/Backend/Modal'); +import {SeverityEnum} from 'TYPO3/CMS/Backend/Enum/Severity'; + +interface FieldOptions { + userId: number, + tableName: string +} + +interface Response { + success: boolean; + status: Array<Status>; + remaining: number; +} + +interface Status { + title: string; + message: string; +} + +enum Selectors { + deactivteProviderButton = '.t3js-deactivate-provider-button', + deactivteMfaButton = '.t3js-deactivate-mfa-button', + providerslist = '.t3js-mfa-active-providers-list', + mfaStatusLabel = '.t3js-mfa-status-label', +} + +class MfaInfoElement { + private options: FieldOptions = null; + private fullElement: HTMLElement = null; + private deactivteProviderButtons: NodeListOf<HTMLButtonElement> = null; + private deactivteMfaButton: HTMLButtonElement = null; + private providersList: HTMLUListElement = null; + private mfaStatusLabel: HTMLSpanElement = null; + private request: AjaxRequest = null; + + constructor(selector: string, options: FieldOptions) { + this.options = options; + DocumentService.ready().then((document: Document): void => { + this.fullElement = document.querySelector(selector); + this.deactivteProviderButtons = this.fullElement.querySelectorAll(Selectors.deactivteProviderButton); + this.deactivteMfaButton = this.fullElement.querySelector(Selectors.deactivteMfaButton); + this.providersList = this.fullElement.querySelector(Selectors.providerslist); + this.mfaStatusLabel = this.fullElement.parentElement.querySelector(Selectors.mfaStatusLabel); + this.registerEvents(); + }); + } + + private registerEvents(): void { + new RegularEvent('click', (e: Event): void => { + e.preventDefault(); + this.prepareDeactivateRequest(this.deactivteMfaButton); + }).bindTo(this.deactivteMfaButton); + + this.deactivteProviderButtons.forEach((buttonElement: HTMLButtonElement): void => { + new RegularEvent('click', (e: Event): void => { + e.preventDefault(); + this.prepareDeactivateRequest(buttonElement); + }).bindTo(buttonElement); + }); + } + + private prepareDeactivateRequest(button: HTMLButtonElement): void { + const $modal = Modal.show( + button.dataset.confirmationTitle || button.getAttribute('title') || 'Deactivate provider(s)', + button.dataset.confirmationContent || 'Are you sure you want to continue? This action cannot be undone and will be applied immediately!', + SeverityEnum.warning, + [ + { + text: button.dataset.confirmationCancelText || 'Cancel', + active: true, + btnClass: 'btn-default', + name: 'cancel' + }, + { + text: button.dataset.confirmationDeactivateText || 'Deactivate', + btnClass: 'btn-warning', + name: 'deactivate', + trigger: (): void => { + this.sendDeactivateRequest(button.dataset.provider); + } + } + ] + ); + + $modal.on('button.clicked', (): void => { + $modal.modal('hide'); + }); + } + + private sendDeactivateRequest(provider?: string): void { + if (this.request instanceof AjaxRequest) { + this.request.abort(); + } + this.request = (new AjaxRequest(TYPO3.settings.ajaxUrls.mfa)); + this.request.post({ + action: 'deactivate', + provider: provider, + userId: this.options.userId, + tableName: this.options.tableName + }).then(async (response: AjaxResponse): Promise<any> => { + const data: Response = await response.resolve(); + if (data.status.length > 0) { + data.status.forEach((status: Status): void => { + if (data.success) { + Notification.success(status.title, status.message); + } else { + Notification.error(status.title, status.message); + } + }); + } + if (!data.success) { + return; + } + if (provider === undefined || data.remaining === 0) { + this.deactivateMfa(); + return; + } + if (this.providersList === null) { + return; + } + const providerEntry: HTMLLIElement = this.providersList.querySelector('li#provider-' + provider); + if (providerEntry === null) { + return; + } + providerEntry.remove(); + const providerEntries: NodeListOf<HTMLLIElement> = this.providersList.querySelectorAll('li'); + if (providerEntries.length === 0){ + this.deactivateMfa(); + } + }).finally((): void => { + this.request = null; + }); + } + + private deactivateMfa(): void { + this.deactivteMfaButton.classList.add('disabled'); + this.deactivteMfaButton.setAttribute('disabled', 'disabled'); + if (this.providersList !== null) { + this.providersList.remove(); + } + if (this.mfaStatusLabel !== null) { + this.mfaStatusLabel.innerText = this.mfaStatusLabel.dataset.alternativeLabel; + this.mfaStatusLabel.classList.remove('label-success'); + this.mfaStatusLabel.classList.add('label-danger'); + } + } +} + +export = MfaInfoElement; diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts index 7901567a0167..b7b40bd16611 100644 --- a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts +++ b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts @@ -36,7 +36,9 @@ class Viewport { this.Topbar = new Topbar(); this.NavigationContainer = new NavigationContainer(this.consumerScope, navigationSwitcher); this.ContentContainer = new ContentContainer(this.consumerScope); - this.NavigationContainer.setWidth(<number>Persistent.get('navigation.width')); + if (document.querySelector(ScaffoldIdentifierEnum.contentNavigation)) { + this.NavigationContainer.setWidth(<number>Persistent.get('navigation.width')); + } window.addEventListener('resize', this.fallbackNavigationSizeIfNeeded, {passive: true}); if (navigationSwitcher) { navigationSwitcher.addEventListener('mouseup', this.toggleNavigation, {passive: true}); diff --git a/composer.json b/composer.json index 8471a49a26cd..3f791a9740c7 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,8 @@ "ext-pcre": "*", "ext-session": "*", "ext-xml": "*", + "bacon/bacon-qr-code": "^2.0", + "christian-riesen/base32": "^1.5", "cogpowered/finediff": "~0.3.1", "doctrine/annotations": "^1.11", "doctrine/dbal": "^2.12", diff --git a/composer.lock b/composer.lock index 0988f524d560..ecdaeff89dac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,120 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a99ee20025ffbdb217cbcff8bedf3871", + "content-hash": "b4c3074ccc16c2e62a19f8ccba3f0998", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "3e9d791b67d0a2912922b7b7c7312f4b37af41e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3e9d791b67d0a2912922b7b7c7312f4b37af41e4", + "reference": "3e9d791b67d0a2912922b7b7c7312f4b37af41e4", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^1.4", + "phpunit/phpunit": "^7 | ^8 | ^9", + "squizlabs/php_codesniffer": "^3.4" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.3" + }, + "time": "2020-10-30T02:02:47+00:00" + }, + { + "name": "christian-riesen/base32", + "version": "1.5.2", + "source": { + "type": "git", + "url": "https://github.com/ChristianRiesen/base32.git", + "reference": "a1cac38d50adb5ce9337a62019a0697cc5da3ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristianRiesen/base32/zipball/a1cac38d50adb5ce9337a62019a0697cc5da3ca1", + "reference": "a1cac38d50adb5ce9337a62019a0697cc5da3ca1", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.17", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.5.13 || ^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Base32\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Riesen", + "email": "chris.riesen@gmail.com", + "homepage": "http://christianriesen.com", + "role": "Developer" + } + ], + "description": "Base32 encoder/decoder according to RFC 4648", + "homepage": "https://github.com/ChristianRiesen/base32", + "keywords": [ + "base32", + "decode", + "encode", + "rfc4648" + ], + "support": { + "issues": "https://github.com/ChristianRiesen/base32/issues", + "source": "https://github.com/ChristianRiesen/base32/tree/1.5.2" + }, + "time": "2021-01-11T22:44:02+00:00" + }, { "name": "cogpowered/finediff", "version": "0.3.1", @@ -61,6 +173,53 @@ }, "time": "2014-05-19T10:25:02+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/5abf82f213618696dda8e3bf6f64dd042d8542b2", + "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^7 | ^8 | ^9", + "squizlabs/php_codesniffer": "^3.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.3" + }, + "time": "2020-10-02T16:03:48+00:00" + }, { "name": "doctrine/annotations", "version": "1.11.1", diff --git a/typo3/sysext/backend/Classes/Controller/AbstractMfaController.php b/typo3/sysext/backend/Classes/Controller/AbstractMfaController.php new file mode 100644 index 000000000000..120f0876a19f --- /dev/null +++ b/typo3/sysext/backend/Classes/Controller/AbstractMfaController.php @@ -0,0 +1,128 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Backend\Routing\UriBuilder; +use TYPO3\CMS\Backend\Template\ModuleTemplate; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; + +/** + * Abstract class for mfa controllers (configuration and authentication) + * + * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API. + */ +abstract class AbstractMfaController +{ + protected ModuleTemplate $moduleTemplate; + protected UriBuilder $uriBuilder; + protected MfaProviderRegistry $mfaProviderRegistry; + protected array $mfaTsConfig; + protected bool $mfaRequired; + protected array $allowedProviders; + protected array $allowedActions = []; + protected ?MfaProviderManifestInterface $mfaProvider = null; + protected ?ViewInterface $view = null; + + public function __construct( + ModuleTemplate $moduleTemplate, + UriBuilder $uriBuilder, + MfaProviderRegistry $mfaProviderRegistry + ) { + $this->moduleTemplate = $moduleTemplate; + $this->uriBuilder = $uriBuilder; + $this->mfaProviderRegistry = $mfaProviderRegistry; + $this->initializeMfaConfiguration(); + } + + /** + * Main action for handling the request and returning the response + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + abstract public function handleRequest(ServerRequestInterface $request): ResponseInterface; + + protected function isActionAllowed(string $action): bool + { + return in_array($action, $this->allowedActions, true); + } + + protected function isProviderAllowed(string $identifier): bool + { + return isset($this->allowedProviders[$identifier]); + } + + protected function isValidIdentifier(string $identifier): bool + { + return $identifier !== '' + && $this->isProviderAllowed($identifier) + && $this->mfaProviderRegistry->hasProvider($identifier); + } + + public function getLocalizedProviderTitle(): string + { + return $this->mfaProvider !== null ? $this->getLanguageService()->sL($this->mfaProvider->getTitle()) : ''; + } + + /** + * Initialize MFA configuration based on TSconfig and global configuration + */ + protected function initializeMfaConfiguration(): void + { + $this->mfaTsConfig = $this->getBackendUser()->getTSConfig()['auth.']['mfa.'] ?? []; + + // Set up required state based on user TSconfig and global configuration + if (isset($this->mfaTsConfig['required'])) { + // user TSconfig overrules global configuration + $this->mfaRequired = (bool)($this->mfaTsConfig['required'] ?? false); + } else { + $globalConfig = (int)($GLOBALS['TYPO3_CONF_VARS']['BE']['requireMfa'] ?? 0); + if ($globalConfig <= 1) { + // 0 and 1 can directly be used by type-casting to boolean + $this->mfaRequired = (bool)$globalConfig; + } else { + // check the admin / non-admin options + $isAdmin = $this->getBackendUser()->isAdmin(); + $this->mfaRequired = ($globalConfig === 2 && !$isAdmin) || ($globalConfig === 3 && $isAdmin); + } + } + + // Set up allowed providers based on user TSconfig and user groupData + $this->allowedProviders = array_filter($this->mfaProviderRegistry->getProviders(), function ($identifier) { + return $this->getBackendUser()->check('mfa_providers', $identifier) + && !GeneralUtility::inList(($this->mfaTsConfig['disableProviders'] ?? ''), $identifier); + }, ARRAY_FILTER_USE_KEY); + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php b/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php index c2e3ceccf090..12e59c03eaca 100644 --- a/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php +++ b/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php @@ -351,6 +351,11 @@ class ElementInformationController continue; } + // @todo Add meaningful information for mfa field. For the time being we don't display anything at all. + if ($this->type === 'db' && $name === 'mfa' && in_array($this->table, ['be_users', 'fe_users'], true)) { + continue; + } + // not a real field -> skip if ($this->type === 'file' && $name === 'fileinfo') { continue; diff --git a/typo3/sysext/backend/Classes/Controller/MfaAjaxController.php b/typo3/sysext/backend/Classes/Controller/MfaAjaxController.php new file mode 100644 index 000000000000..9466a4afcf2b --- /dev/null +++ b/typo3/sysext/backend/Classes/Controller/MfaAjaxController.php @@ -0,0 +1,203 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Http\JsonResponse; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication; + +/** + * Controller to manipulate MFA providers via AJAX in the backend + * + * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API. + */ +class MfaAjaxController +{ + private const ALLOWED_ACTIONS = ['deactivate']; + + protected MfaProviderRegistry $mfaProviderRegistry; + protected ?AbstractUserAuthentication $user = null; + + public function __construct(MfaProviderRegistry $mfaProviderRegistry) + { + $this->mfaProviderRegistry = $mfaProviderRegistry; + } + + /** + * Main entry point, checking prerequisite and dispatching to the requested action + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? ''); + + if (!in_array($action, self::ALLOWED_ACTIONS, true)) { + return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.invalidRequest'))); + } + + $userId = (int)($request->getParsedBody()['userId'] ?? 0); + $tableName = (string)($request->getParsedBody()['tableName'] ?? ''); + + if (!$userId || !in_array($tableName, ['be_users', 'fe_users'], true)) { + return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.invalidRequest'))); + } + + $this->user = $this->initializeUser($userId, $tableName); + + if (!$this->isAllowedToPerformAction($action)) { + return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.insufficientPermissions'))); + } + + return new JsonResponse($this->{$action . 'Action'}($request)); + } + + /** + * Deactivate MFA providers + * If the request contains a provider, it will be deactivated. + * Otherwise all active providers are deactivated. + * + * @param ServerRequestInterface $request + * @return array + */ + protected function deactivateAction(ServerRequestInterface $request): array + { + $lang = $this->getLanguageService(); + $userName = (string)($this->user->user[$this->user->username_column] ?? ''); + $providerToDeactivate = (string)($request->getParsedBody()['provider'] ?? ''); + + if ($providerToDeactivate === '') { + // In case no provider is given, try to deactivate all active providers + $providersToDeactivate = $this->mfaProviderRegistry->getActiveProviders($this->user); + if ($providersToDeactivate === []) { + return $this->getResponseData(false, $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providersNotDeactivated')); + } + foreach ($providersToDeactivate as $identifier => $provider) { + $propertyManager = MfaProviderPropertyManager::create($provider, $this->user); + if (!$provider->deactivate($request, $propertyManager)) { + return $this->getResponseData(false, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotDeactivated'), $lang->sL($provider->getTitle()))); + } + } + return $this->getResponseData(true, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providersDeactivated'), $userName)); + } + + if (!$this->mfaProviderRegistry->hasProvider($providerToDeactivate)) { + return $this->getResponseData(false, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotFound'), $providerToDeactivate)); + } + + $provider = $this->mfaProviderRegistry->getProvider($providerToDeactivate); + $propertyManager = MfaProviderPropertyManager::create($provider, $this->user); + + if (!$provider->deactivate($request, $propertyManager)) { + return $this->getResponseData(false, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotDeactivated'), $lang->sL($provider->getTitle()))); + } + + return $this->getResponseData(true, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerDeactivated'), $lang->sL($provider->getTitle()), $userName)); + } + + /** + * Initialize a user based on the table name + * + * @param int $userId + * @param string $tableName + * @return AbstractUserAuthentication + */ + protected function initializeUser(int $userId, string $tableName): AbstractUserAuthentication + { + $user = $tableName === 'be_users' + ? GeneralUtility::makeInstance(BackendUserAuthentication::class) + : GeneralUtility::makeInstance(FrontendUserAuthentication::class); + + $user->enablecolumns = ['deleted' => true]; + $user->setBeUserByUid($userId); + + return $user; + } + + /** + * Prepare response data for a JSON response + * + * @param bool $success + * @param string $message + * @return array + */ + protected function getResponseData(bool $success, string $message): array + { + return [ + 'success' => $success, + 'status' => (new FlashMessageQueue('backend'))->enqueue( + new FlashMessage( + $message, + $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.' . ($success ? 'success' : 'error')) + ) + ), + 'remaining' => count($this->mfaProviderRegistry->getActiveProviders($this->user)) + ]; + } + + /** + * Check if the current logged in user is allowed to perform + * the requested action on the selected user. + * + * @param string $action + * @return bool + */ + protected function isAllowedToPerformAction(string $action): bool + { + if ($action === 'deactivate') { + $currentBackendUser = $this->getBackendUser(); + // Only admins are allowed to deactivate providers + if (!$currentBackendUser->isAdmin()) { + return false; + } + // System maintainer checks are only required for backend users + if ($this->user instanceof BackendUserAuthentication) { + $systemMaintainer = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []); + $isCurrentBackendUserSystemMaintainer = in_array((int)$currentBackendUser->user[$currentBackendUser->userid_column], $systemMaintainer, true); + $isTargetUserSystemMaintainer = in_array((int)$this->user->user[$this->user->userid_column], $systemMaintainer, true); + // Providers from system maintainers can only be deactivated by system maintainers + if ($isTargetUserSystemMaintainer && !$isCurrentBackendUserSystemMaintainer) { + return false; + } + } + return true; + } + + return false; + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php b/typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php new file mode 100644 index 000000000000..dc688f1b16d2 --- /dev/null +++ b/typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php @@ -0,0 +1,432 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Backend\Template\Components\ButtonBar; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; +use TYPO3\CMS\Core\Http\HtmlResponse; +use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Core\Imaging\Icon; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Fluid\View\StandaloneView; + +/** + * Controller to configure MFA providers in the backend + * + * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API. + */ +class MfaConfigurationController extends AbstractMfaController +{ + protected array $allowedActions = ['overview', 'setup', 'activate', 'deactivate', 'unlock', 'edit', 'save']; + + /** + * Main entry point, checking prerequisite, initializing and setting + * up the view and finally dispatching to the requested action. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'overview'); + + if (!$this->isActionAllowed($action)) { + return new HtmlResponse('Action not allowed', 400); + } + + $this->initializeAction($request); + // All actions expect "overview" require a provider to deal with. + // If non is found at this point, initiate a redirect to the overview. + if ($this->mfaProvider === null && $action !== 'overview') { + $this->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:providerNotFound'), '', FlashMessage::ERROR); + return new RedirectResponse($this->getActionUri('overview')); + } + $this->initializeView($action); + + $result = $this->{$action . 'Action'}($request); + if ($result instanceof ResponseInterface) { + return $result; + } + $this->moduleTemplate->setContent($this->view->render()); + return new HtmlResponse($this->moduleTemplate->renderContent()); + } + + /** + * Setup the overview with all available MFA providers + * + * @param ServerRequestInterface $request + */ + public function overviewAction(ServerRequestInterface $request): void + { + $this->addOverviewButtons($request); + $this->view->assignMultiple([ + 'providers' => $this->allowedProviders, + 'defaultProvider' => $this->getDefaultProviderIdentifier(), + 'recommendedProvider' => $this->getRecommendedProviderIdentifier(), + 'setupRequired' => $this->mfaRequired && !$this->mfaProviderRegistry->hasActiveProviders($this->getBackendUser()) + ]); + } + + /** + * Render form to setup a provider by using provider specific content + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function setupAction(ServerRequestInterface $request): ResponseInterface + { + $this->addFormButtons(); + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + $providerResponse = $this->mfaProvider->handleRequest($request, $propertyManager, MfaViewType::SETUP); + $this->view->assignMultiple([ + 'provider' => $this->mfaProvider, + 'providerContent' => $providerResponse->getBody() + ]); + $this->moduleTemplate->setContent($this->view->render()); + return new HtmlResponse($this->moduleTemplate->renderContent()); + } + + /** + * Handle activate request, receiving from the setup view + * by forwarding the request to the appropriate provider. + * Furthermore, add the provider as default provider in case + * it is the recommended provider for this user, or no default + * provider is yet defined the newly activated provider is allowed + * to be a default provider and there are no other providers which + * would suite as default provider. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function activateAction(ServerRequestInterface $request): ResponseInterface + { + $backendUser = $this->getBackendUser(); + $isRecommendedProvider = $this->getRecommendedProviderIdentifier() === $this->mfaProvider->getIdentifier(); + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $backendUser); + if (!$this->mfaProvider->activate($request, $propertyManager)) { + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:activate.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR); + return new RedirectResponse($this->getActionUri('setup', ['identifier' => $this->mfaProvider->getIdentifier()])); + } + if ($isRecommendedProvider + || ( + $this->getDefaultProviderIdentifier() === '' + && $this->mfaProvider->isDefaultProviderAllowed() + && !$this->hasSuitableDefaultProviders([$this->mfaProvider->getIdentifier()]) + ) + ) { + $this->setDefaultProvider(); + } + // If this is the first activated provider, the user has logged in without being required + // to pass the MFA challenge. Therefore no session entry exists. To prevent the challenge + // from showing up after the activation we need to set the session data here. + if (!(bool)($backendUser->getSessionData('mfa') ?? false)) { + $backendUser->setSessionData('mfa', true); + } + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:activate.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK); + return new RedirectResponse($this->getActionUri('overview')); + } + + /** + * Handle deactivate request by forwarding the request to the + * appropriate provider. Also remove the provider as default + * provider from user UC, if set. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function deactivateAction(ServerRequestInterface $request): ResponseInterface + { + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + if (!$this->mfaProvider->deactivate($request, $propertyManager)) { + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:deactivate.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR); + } else { + if ($this->isDefaultProvider()) { + $this->removeDefaultProvider(); + } + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:deactivate.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK); + } + return new RedirectResponse($this->getActionUri('overview')); + } + + /** + * Handle unlock request by forwarding the request to the appropriate provider + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function unlockAction(ServerRequestInterface $request): ResponseInterface + { + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + if (!$this->mfaProvider->unlock($request, $propertyManager)) { + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:unlock.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR); + } else { + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:unlock.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK); + } + return new RedirectResponse($this->getActionUri('overview')); + } + + /** + * Render form to edit a provider by using provider specific content + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function editAction(ServerRequestInterface $request): ResponseInterface + { + $this->addFormButtons(); + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + $providerResponse = $this->mfaProvider->handleRequest($request, $propertyManager, MfaViewType::EDIT); + $this->view->assignMultiple([ + 'provider' => $this->mfaProvider, + 'providerContent' => $providerResponse->getBody(), + 'isDefaultProvider' => $this->isDefaultProvider() + ]); + $this->moduleTemplate->setContent($this->view->render()); + return new HtmlResponse($this->moduleTemplate->renderContent()); + } + + /** + * Handle save request, receiving from the edit view by + * forwarding the request to the appropriate provider. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function saveAction(ServerRequestInterface $request): ResponseInterface + { + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + if (!$this->mfaProvider->update($request, $propertyManager)) { + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:save.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR); + } else { + if ((bool)($request->getParsedBody()['defaultProvider'] ?? false)) { + $this->setDefaultProvider(); + } elseif ($this->isDefaultProvider()) { + $this->removeDefaultProvider(); + } + $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:save.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK); + } + return new RedirectResponse($this->getActionUri('edit', ['identifier' => $this->mfaProvider->getIdentifier()])); + } + + /** + * Initialize the action by fetching the requested provider by its identifier + * + * @param ServerRequestInterface $request + */ + protected function initializeAction(ServerRequestInterface $request): void + { + $identifier = (string)($request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? ''); + // Check if given identifier is valid + if ($this->isValidIdentifier($identifier)) { + $this->mfaProvider = $this->mfaProviderRegistry->getProvider($identifier); + } + } + + /** + * Initialize the standalone view and set the template name + * + * @param string $templateName + */ + protected function initializeView(string $templateName): void + { + $this->view = GeneralUtility::makeInstance(StandaloneView::class); + $this->view->setTemplateRootPaths(['EXT:backend/Resources/Private/Templates/Mfa']); + $this->view->setPartialRootPaths(['EXT:backend/Resources/Private/Partials']); + $this->view->setLayoutRootPaths(['EXT:backend/Resources/Private/Layouts']); + $this->view->setTemplate($templateName); + } + + /** + * Build a uri for the current controller based on the + * given action, respecting additional parameters. + * + * @param string $action + * @param array $additionalParameters + * + * @return UriInterface + */ + protected function getActionUri(string $action, array $additionalParameters = []): UriInterface + { + if (!$this->isActionAllowed($action)) { + $action = 'overview'; + } + return $this->uriBuilder->buildUriFromRoute('mfa', array_merge(['action' => $action], $additionalParameters)); + } + + /** + * Check if there are more suitable default providers for the current user + * + * @param array $excludedProviders + * @return bool + */ + protected function hasSuitableDefaultProviders(array $excludedProviders = []): bool + { + foreach ($this->allowedProviders as $identifier => $provider) { + if (!in_array($identifier, $excludedProviders, true) + && $provider->isDefaultProviderAllowed() + && $provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser())) + ) { + return true; + } + } + return false; + } + + /** + * Get the default provider + * + * @return string The identifier of the default provider + */ + protected function getDefaultProviderIdentifier(): string + { + $defaultProviderIdentifier = (string)($this->getBackendUser()->uc['mfa']['defaultProvider'] ?? ''); + // The default provider value is only valid, if the corresponding provider exist and is allowed + if ($this->isValidIdentifier($defaultProviderIdentifier)) { + $defaultProvider = $this->mfaProviderRegistry->getProvider($defaultProviderIdentifier); + $propertyManager = MfaProviderPropertyManager::create($defaultProvider, $this->getBackendUser()); + // Also check if the provider is activated for the user + if ($defaultProvider->isActive($propertyManager)) { + return $defaultProviderIdentifier; + } + } + + // If the stored provider is not valid, clean up the UC + $this->removeDefaultProvider(); + return ''; + } + + /** + * Get the recommended provider + * + * @return string The identifier of the recommended provider + */ + protected function getRecommendedProviderIdentifier(): string + { + $recommendedProviderIdentifier = (string)($this->mfaTsConfig['recommendedProvider'] ?? ''); + // Check if valid and allowed to be default provider, which is obviously a prerequisite + if (!$this->isValidIdentifier($recommendedProviderIdentifier) + || !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed() + ) { + // If the provider, defined in user TSconfig is not valid or is not set, check the globally defined + $recommendedProviderIdentifier = (string)($GLOBALS['TYPO3_CONF_VARS']['BE']['recommendedMfaProvider'] ?? ''); + if (!$this->isValidIdentifier($recommendedProviderIdentifier) + || !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed() + ) { + // If also not valid or not set, return + return ''; + } + } + + $provider = $this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier); + $propertyManager = MfaProviderPropertyManager::create($provider, $this->getBackendUser()); + // If the defined recommended provider is valid, check if it is not yet activated + return !$provider->isActive($propertyManager) ? $recommendedProviderIdentifier : ''; + } + + protected function isDefaultProvider(): bool + { + return $this->getDefaultProviderIdentifier() === $this->mfaProvider->getIdentifier(); + } + + protected function setDefaultProvider(): void + { + $this->getBackendUser()->uc['mfa']['defaultProvider'] = $this->mfaProvider->getIdentifier(); + $this->getBackendUser()->writeUC(); + } + + protected function removeDefaultProvider(): void + { + $this->getBackendUser()->uc['mfa']['defaultProvider'] = ''; + $this->getBackendUser()->writeUC(); + } + + protected function addFlashMessage(string $message, string $title = '', int $severity = FlashMessage::INFO): void + { + $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $message, $title, $severity, true); + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + + protected function addOverviewButtons(ServerRequestInterface $request): void + { + $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar(); + + if (($returnUrl = $this->getReturnUrl($request)) !== '') { + $button = $buttonBar + ->makeLinkButton() + ->setHref($returnUrl) + ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-view-go-back', Icon::SIZE_SMALL)) + ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.goBack')) + ->setShowLabelText(true); + $buttonBar->addButton($button); + } + + $reloadButton = $buttonBar + ->makeLinkButton() + ->setHref($request->getAttribute('normalizedParams')->getRequestUri()) + ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload')) + ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-refresh', Icon::SIZE_SMALL)); + $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT); + } + + protected function addFormButtons(): void + { + $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $lang = $this->getLanguageService(); + + $closeButton = $buttonBar + ->makeLinkButton() + ->setHref($this->uriBuilder->buildUriFromRoute('mfa', ['action' => 'overview'])) + ->setClasses('t3js-editform-close') + ->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc')) + ->setShowLabelText(true) + ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-close', Icon::SIZE_SMALL)); + $buttonBar->addButton($closeButton); + + $saveButton = $buttonBar + ->makeInputButton() + ->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.saveDoc')) + ->setName('save') + ->setValue('1') + ->setShowLabelText(true) + ->setForm('mfaConfigurationController') + ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-document-save', Icon::SIZE_SMALL)); + $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 2); + } + + protected function getReturnUrl(ServerRequestInterface $request): string + { + $returnUrl = GeneralUtility::sanitizeLocalUrl( + $request->getQueryParams()['returnUrl'] ?? $request->getParsedBody()['returnUrl'] ?? '' + ); + + if ($returnUrl === '' && ExtensionManagementUtility::isLoaded('setup')) { + $returnUrl = (string)$this->uriBuilder->buildUriFromRoute('user_setup'); + } + + return $returnUrl; + } +} diff --git a/typo3/sysext/backend/Classes/Controller/MfaController.php b/typo3/sysext/backend/Classes/Controller/MfaController.php new file mode 100644 index 000000000000..2256dfdb4199 --- /dev/null +++ b/typo3/sysext/backend/Classes/Controller/MfaController.php @@ -0,0 +1,201 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface; +use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; +use TYPO3\CMS\Core\Http\HtmlResponse; +use TYPO3\CMS\Core\Http\RedirectResponse; + +/** + * Controller to provide a multi-factor authentication endpoint + * + * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API. + */ +class MfaController extends AbstractMfaController implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + protected array $allowedActions = ['auth', 'verify', 'cancel']; + + /** + * Main entry point, checking prerequisite, initializing and setting + * up the view and finally dispatching to the requested action. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'auth'); + + if (!$this->isActionAllowed($action)) { + throw new \InvalidArgumentException('Action not allowed', 1611879243); + } + + $this->initializeAction($request); + // All actions expect "cancel" require a provider to deal with. + // If non is found at this point, throw an exception since this should never happen. + if ($this->mfaProvider === null && $action !== 'cancel') { + throw new \InvalidArgumentException('No active MFA provider was found!', 1611879242); + } + + $this->view = $this->moduleTemplate->getView(); + $this->view->setTemplateRootPaths(['EXT:backend/Resources/Private/Templates/Mfa']); + $this->view->setTemplate('Auth'); + $this->view->assign('hasAuthError', (bool)($request->getQueryParams()['failure'] ?? false)); + + $result = $this->{$action . 'Action'}($request); + if ($result instanceof ResponseInterface) { + return $result; + } + $this->moduleTemplate->setTitle('TYPO3 CMS Login: ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']); + return new HtmlResponse($this->moduleTemplate->renderContent()); + } + + /** + * Setup the authentication view for the provider by using provider specific content + * + * @param ServerRequestInterface $request + */ + public function authAction(ServerRequestInterface $request): void + { + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + $providerResponse = $this->mfaProvider->handleRequest($request, $propertyManager, MfaViewType::AUTH); + $this->view->assignMultiple([ + 'provider' => $this->mfaProvider, + 'alternativeProviders' => $this->getAlternativeProviders(), + 'isLocked' => $this->mfaProvider->isLocked($propertyManager), + 'providerContent' => $providerResponse->getBody() + ]); + } + + /** + * Handle verification request, receiving from the auth view + * by forwarding the request to the appropriate provider. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + * @throws RouteNotFoundException + */ + public function verifyAction(ServerRequestInterface $request): ResponseInterface + { + $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser()); + + // Check if the provider can process the request and is not temporarily blocked + if (!$this->mfaProvider->canProcess($request) || $this->mfaProvider->isLocked($propertyManager)) { + // If this fails, cancel the authentication + return $this->cancelAction($request); + } + // Call the provider to verify the request + if (!$this->mfaProvider->verify($request, $propertyManager)) { + $this->log('Multi-factor authentication failed'); + // If failed, initiate a redirect back to the auth view + return new RedirectResponse($this->uriBuilder->buildUriFromRoute( + 'auth_mfa', + [ + 'identifier' => $this->mfaProvider->getIdentifier(), + 'failure' => true + ] + )); + } + $this->log('Multi-factor authentication successfull'); + // If verified, store this information in the session + // and initiate a redirect back to the login view. + $this->getBackendUser()->setAndSaveSessionData('mfa', true); + return new RedirectResponse($this->uriBuilder->buildUriFromRoute('login')); + } + + /** + * Allow the user to cancel the multi-factor authentication by + * calling logoff on the user object, to destroy the session and + * other already gathered information and finally initiate a + * redirect back to the login. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + * @throws RouteNotFoundException + */ + public function cancelAction(ServerRequestInterface $request): ResponseInterface + { + $this->log('Multi-factor authentication canceled'); + $this->getBackendUser()->logoff(); + return new RedirectResponse($this->uriBuilder->buildUriFromRoute('login')); + } + + /** + * Initialize the action by fetching the requested provider by its identifier + * + * @param ServerRequestInterface $request + */ + protected function initializeAction(ServerRequestInterface $request): void + { + $identifier = (string)($request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? ''); + // Check if given identifier is valid + if ($this->isValidIdentifier($identifier)) { + $provider = $this->mfaProviderRegistry->getProvider($identifier); + // Only add provider if it was activated by the current user + if ($provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()))) { + $this->mfaProvider = $provider; + } + } + } + + /** + * Fetch alternative (activated and allowed) providers for the user to chose from + * + * @return ProviderInterface[] + */ + protected function getAlternativeProviders(): array + { + return array_filter($this->allowedProviders, function ($provider) { + return $provider !== $this->mfaProvider + && $provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser())); + }); + } + + /** + * Log debug information for MFA events + * + * @param string $message + * @param array $additionalData + */ + protected function log(string $message, array $additionalData = []): void + { + $user = $this->getBackendUser(); + $context = [ + 'user' => [ + 'uid' => $user->user[$user->userid_column], + 'username' => $user->user[$user->username_column] + ] + ]; + if ($this->mfaProvider !== null) { + $context['provider'] = $this->mfaProvider->getIdentifier(); + $context['isProviderLocked'] = $this->mfaProvider->isLocked( + MfaProviderPropertyManager::create($this->mfaProvider, $user) + ); + } + $this->logger->debug($message, array_replace_recursive($context, $additionalData)); + } +} diff --git a/typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php b/typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php new file mode 100644 index 000000000000..d4e931b266d1 --- /dev/null +++ b/typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php @@ -0,0 +1,181 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Form\Element; + +use TYPO3\CMS\Backend\Form\NodeFactory; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Imaging\Icon; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\StringUtility; +use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication; + +/** + * Renders an element, displaying MFA related information and providing + * interactions like deactivation of active providers and MFA in general. + * + * @internal + */ +class MfaInfoElement extends AbstractFormElement +{ + private const ALLOWED_TABLES = ['be_users', 'fe_users']; + + protected MfaProviderRegistry $mfaProviderRegistry; + + public function __construct(NodeFactory $nodeFactory, array $data) + { + parent::__construct($nodeFactory, $data); + $this->mfaProviderRegistry = GeneralUtility::makeInstance(MfaProviderRegistry::class); + } + + public function render(): array + { + $resultArray = $this->initializeResultArray(); + $currentBackendUser = $this->getBackendUser(); + $tableName = $this->data['tableName']; + + // This renderType only works for user tables: be_users, fe_users + if (!in_array($tableName, self::ALLOWED_TABLES, true)) { + return $resultArray; + } + + // Initialize a user based on the current table name + $targetUser = $tableName === 'be_users' + ? GeneralUtility::makeInstance(BackendUserAuthentication::class) + : GeneralUtility::makeInstance(FrontendUserAuthentication::class); + + $userId = (int)($this->data['databaseRow'][$targetUser->userid_column] ?? 0); + $targetUser->enablecolumns = ['deleted' => true]; + $targetUser->setBeUserByUid($userId); + + $isDeactivationAllowed = true; + // System maintainer checks are only required for backend users + if ($targetUser instanceof BackendUserAuthentication) { + $systemMaintainer = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []); + $isCurrentBackendUserSystemMaintainer = in_array((int)$currentBackendUser->user[$currentBackendUser->userid_column], $systemMaintainer, true); + $isTargetUserSystemMaintainer = in_array((int)$targetUser->user[$targetUser->userid_column], $systemMaintainer, true); + // Providers from system maintainers can only be deactivated by system maintainers + if ($isTargetUserSystemMaintainer && !$isCurrentBackendUserSystemMaintainer) { + $isDeactivationAllowed = false; + } + } + + // Fetch providers from the mfa field + $mfaProviders = json_decode($this->data['parameterArray']['itemFormElValue'] ?? '', true) ?? []; + + // Initialize variables + $html = $childHtml = $activeProviders = $lockedProviders = []; + $lang = $this->getLanguageService(); + $enabledLabel = htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.mfa.enabled')); + $disabledLabel = htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.mfa.disabled')); + $status = '<span class="label label-danger label-space-right t3js-mfa-status-label" data-alternative-label="' . $enabledLabel . '">' . $disabledLabel . '</span>'; + + // Unset invalid providers + foreach ($mfaProviders as $identifier => $providerSettings) { + if (!$this->mfaProviderRegistry->hasProvider($identifier)) { + unset($mfaProviders[$identifier]); + } + } + + if ($mfaProviders !== []) { + // Check if remaining providers are active and/or locked for the user + foreach ($mfaProviders as $identifier => $providerSettings) { + $provider = $this->mfaProviderRegistry->getProvider($identifier); + $propertyManager = MfaProviderPropertyManager::create($provider, $targetUser); + if (!$provider->isActive($propertyManager)) { + continue; + } + $activeProviders[$identifier] = $provider; + if ($provider->isLocked($propertyManager)) { + $lockedProviders[] = $identifier; + } + } + + if ($activeProviders !== []) { + // Change status label to MFA being enabled + $status = '<span class="label label-success label-space-right t3js-mfa-status-label"' . ' data-alternative-label="' . $disabledLabel . '">' . $enabledLabel . '</span>'; + + // Add providers list + $childHtml[] = '<ul class="list-group t3js-mfa-active-providers-list">'; + foreach ($activeProviders as $identifier => $activeProvider) { + $childHtml[] = '<li class="list-group-item" id="provider-' . htmlspecialchars($identifier) . '" style="line-height: 2.1em;">'; + $childHtml[] = $this->iconFactory->getIcon($activeProvider->getIconIdentifier(), Icon::SIZE_SMALL); + $childHtml[] = htmlspecialchars($lang->sL($activeProvider->getTitle())); + if (in_array($identifier, $lockedProviders, true)) { + $childHtml[] = '<span class="label label-danger">' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.locked')) . '</span>'; + } else { + $childHtml[] = '<span class="label label-success">' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.active')) . '</span>'; + } + if ($isDeactivationAllowed) { + $childHtml[] = '<button type="button"'; + $childHtml[] = ' class="btn btn-default btn-sm pull-right t3js-deactivate-provider-button"'; + $childHtml[] = ' data-confirmation-title="' . htmlspecialchars(sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfaProvider'), $lang->sL($activeProvider->getTitle()))) . '"'; + $childHtml[] = ' data-confirmation-content="' . htmlspecialchars(sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfaProvider.confirmation.text'), $lang->sL($activeProvider->getTitle()))) . '"'; + $childHtml[] = ' data-confirmation-cancel-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel')) . '"'; + $childHtml[] = ' data-confirmation-deactivate-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.deactivate')) . '"'; + $childHtml[] = ' data-provider="' . htmlspecialchars($identifier) . '"'; + $childHtml[] = ' title="' . htmlspecialchars(sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfaProvider'), $lang->sL($activeProvider->getTitle()))) . '"'; + $childHtml[] = '>'; + $childHtml[] = $this->iconFactory->getIcon('actions-delete', Icon::SIZE_SMALL)->render('inline'); + $childHtml[] = '</button>'; + } + $childHtml[] = '</li>'; + } + $childHtml[] = '</ul>'; + } + } + + $fieldId = 't3js-form-field-mfa-id' . StringUtility::getUniqueId('-'); + + $html[] = '<div class="formengine-field-item t3js-formengine-field-item" id="' . htmlspecialchars($fieldId) . '">'; + $html[] = '<div class="form-control-wrap" style="max-width: ' . (int)$this->formMaxWidth($this->defaultInputWidth) . 'px">'; + $html[] = '<div class="form-wizards-wrap">'; + $html[] = '<div class="form-wizards-element">'; + $html[] = implode(PHP_EOL, $childHtml); + if ($isDeactivationAllowed) { + $html[] = '<div class="form-wizards-items-bottom">'; + $html[] = '<div class="help-block">'; + $html[] = '<button type="button"'; + $html[] = ' class="t3js-deactivate-mfa-button btn btn-danger ' . ($activeProviders === [] ? 'disabled" disabled="disabled' : '') . '"'; + $html[] = ' data-confirmation-title="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfa')) . '"'; + $html[] = ' data-confirmation-content="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfa.confirmation.text')) . '"'; + $html[] = ' data-confirmation-cancel-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel')) . '"'; + $html[] = ' data-confirmation-deactivate-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.deactivate')) . '"'; + $html[] = '>'; + $html[] = $this->iconFactory->getIcon('actions-toggle-off', Icon::SIZE_SMALL)->render('inline'); + $html[] = htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfa')); + $html[] = '</button>'; + $html[] = '</div>'; + $html[] = '</div>'; + } + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + + $resultArray['requireJsModules'][] = ['TYPO3/CMS/Backend/FormEngine/Element/MfaInfoElement' => ' + function(MfaInfoElement) { + new MfaInfoElement(' . GeneralUtility::quoteJSvalue('#' . $fieldId) . ', ' . json_encode(['userId' => $userId, 'tableName' => $tableName]) . '); + }' + ]; + + $resultArray['html'] = $status . implode(PHP_EOL, $html); + return $resultArray; + } +} diff --git a/typo3/sysext/backend/Classes/Form/NodeFactory.php b/typo3/sysext/backend/Classes/Form/NodeFactory.php index e4bf7a72a76c..4ca65ab47f60 100644 --- a/typo3/sysext/backend/Classes/Form/NodeFactory.php +++ b/typo3/sysext/backend/Classes/Form/NodeFactory.php @@ -93,6 +93,7 @@ class NodeFactory // special renderType for type="user" on sys_file_storage is_public column 'userSysFileStorageIsPublic' => Element\UserSysFileStorageIsPublicElement::class, 'fileInfo' => Element\FileInfoElement::class, + 'mfaInfo' => Element\MfaInfoElement::class, 'slug' => Element\InputSlugElement::class, 'passthrough' => Element\PassThroughElement::class, diff --git a/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php b/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php index 2ca5bd0e778c..c204b1be20fc 100644 --- a/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php +++ b/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php @@ -23,6 +23,8 @@ use Psr\Http\Server\RequestHandlerInterface; use TYPO3\CMS\Backend\Routing\Route; use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface; +use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException; use TYPO3\CMS\Core\Controller\ErrorPageController; use TYPO3\CMS\Core\Http\HtmlResponse; use TYPO3\CMS\Core\Http\RedirectResponse; @@ -72,7 +74,16 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut // The global must be available very early, because methods below // might trigger code which relies on it. See: #45625 $GLOBALS['BE_USER'] = GeneralUtility::makeInstance(BackendUserAuthentication::class); - $GLOBALS['BE_USER']->start(); + try { + $GLOBALS['BE_USER']->start(); + } catch (MfaRequiredException $mfaRequiredException) { + // If MFA is required and we are not already on the "auth_mfa" + // route, force the user to it for further authentication + if ($route->getOption('_identifier') !== 'auth_mfa') { + return $this->redirectToMfaAuthProcess($GLOBALS['BE_USER'], $mfaRequiredException->getProvider()); + } + } + // Register the backend user as aspect and initializing workspace once for TSconfig conditions $this->setBackendUserAspect($GLOBALS['BE_USER'], (int)$GLOBALS['BE_USER']->user['workspace_id']); if ($this->isLoggedInBackendUserRequired($route)) { @@ -138,6 +149,30 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut UserSessionManager::create('BE')->collectGarbage(); } + /** + * Initiate a redirect to the auth_mfa route with the given + * provider and necessary cookies and headers appended. + * + * @param BackendUserAuthentication $user + * @param MfaProviderManifestInterface $provider + * @return ResponseInterface + */ + protected function redirectToMfaAuthProcess( + BackendUserAuthentication $user, + MfaProviderManifestInterface $provider + ): ResponseInterface { + // GLOBALS[LANG] needs to be set up, because the UriBuilder is generating a token, which in turn + // needs the FormProtectionFactory, which then builds a Message Closure with GLOBALS[LANG] (hacky, yes!) + $GLOBALS['LANG'] = LanguageService::createFromUserPreferences($user); + $uri = GeneralUtility::makeInstance(UriBuilder::class) + ->buildUriFromRoute('auth_mfa', ['identifier' => $provider->getIdentifier()]); + $response = new RedirectResponse($uri); + // Add necessary cookies and headers to the response so + // the already passed authentication step is not lost. + $response = $user->appendCookieToResponse($response); + $response = $this->applyHeadersToResponse($response); + return $response; + } /** * Check if the user is required for the request. * If we're trying to do a login or an ajax login, don't require a user. diff --git a/typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php b/typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php new file mode 100644 index 000000000000..c0d02e72780b --- /dev/null +++ b/typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\ViewHelpers\Mfa; + +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractConditionViewHelper; + +/** + * Check if the given provider for the current user has the requested state set + * + * @internal + */ +class IfHasStateViewHelper extends AbstractConditionViewHelper +{ + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('state', 'string', 'The state to check for (e.g. active or locked)', true); + $this->registerArgument('provider', MfaProviderManifestInterface::class, 'The provider in question', true); + } + + public static function verdict(array $arguments, RenderingContextInterface $renderingContext): bool + { + $stateMethod = 'is' . ucfirst($arguments['state']); + $provider = $arguments['provider']; + + $propertyManager = MfaProviderPropertyManager::create($provider, $GLOBALS['BE_USER']); + return is_callable([$provider, $stateMethod]) && $provider->{$stateMethod}($propertyManager); + } +} diff --git a/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php b/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php index 83e309256556..8667641d1bf4 100644 --- a/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php +++ b/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php @@ -206,6 +206,12 @@ return [ ] ], + // Multi-factor authentication configuration + 'mfa' => [ + 'path' => '/mfa', + 'target' => Controller\MfaAjaxController::class . '::handleRequest' + ], + // Render flash messages 'flashmessages_render' => [ 'path' => '/flashmessages/render', diff --git a/typo3/sysext/backend/Configuration/Backend/Routes.php b/typo3/sysext/backend/Configuration/Backend/Routes.php index f4ee172ed72e..fef158bda195 100644 --- a/typo3/sysext/backend/Configuration/Backend/Routes.php +++ b/typo3/sysext/backend/Configuration/Backend/Routes.php @@ -64,6 +64,18 @@ return [ 'target' => Controller\LoginController::class . '::refreshAction' ], + // Authentication endpoint for Multi-factor authentication + 'auth_mfa' => [ + 'path' => '/auth/mfa', + 'target' => Controller\MfaController::class . '::handleRequest' + ], + + // Multi-factor authentication configuration + 'mfa' => [ + 'path' => '/mfa', + 'target' => Controller\MfaConfigurationController::class . '::handleRequest' + ], + /** Wizards */ // Register table wizard 'wizard_table' => [ diff --git a/typo3/sysext/backend/Configuration/Services.yaml b/typo3/sysext/backend/Configuration/Services.yaml index 66711bbca1f9..e62462705e1f 100644 --- a/typo3/sysext/backend/Configuration/Services.yaml +++ b/typo3/sysext/backend/Configuration/Services.yaml @@ -53,6 +53,15 @@ services: TYPO3\CMS\Backend\Controller\HelpController: tags: ['backend.controller'] + TYPO3\CMS\Backend\Controller\MfaConfigurationController: + tags: ['backend.controller'] + + TYPO3\CMS\Backend\Controller\MfaController: + tags: ['backend.controller'] + + TYPO3\CMS\Backend\Controller\MfaAjaxController: + tags: ['backend.controller'] + TYPO3\CMS\Backend\Form\FormDataProvider\SiteDatabaseEditRow: public: true diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf new file mode 100644 index 000000000000..489d3862d10a --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf @@ -0,0 +1,170 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff"> + <file t3:id="1611662325" source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_mfa.xlf" date="2021-01-26T11:58:45Z" product-name="cms"> + <header/> + <body> + <trans-unit id="overview.title" resname="overview.title"> + <source>Multi-factor Authentication Overview</source> + </trans-unit> + <trans-unit id="overview.badge.active" resname="overview.badge.active"> + <source>Active</source> + </trans-unit> + <trans-unit id="overview.badge.locked" resname="overview.badge.locked"> + <source>Locked</source> + </trans-unit> + <trans-unit id="overview.defaultProvider" resname="overview.defaultProvider"> + <source>Default provider</source> + </trans-unit> + <trans-unit id="overview.unlockLinkTitle" resname="overview.unlockLinkTitle"> + <source>Unlock %s</source> + </trans-unit> + <trans-unit id="overview.unlockLinkLabel" resname="overview.unlockLinkLabel"> + <source>Unlock</source> + </trans-unit> + <trans-unit id="overview.editLinkTitle" resname="overview.editLinkTitle"> + <source>Edit %s</source> + </trans-unit> + <trans-unit id="overview.editLinkLabel" resname="overview.editLinkLabel"> + <source>Edit / Change</source> + </trans-unit> + <trans-unit id="overview.deactivateLinkTitle" resname="overview.deactivateLinkTitle"> + <source>Deactivate %s</source> + </trans-unit> + <trans-unit id="overview.deactivateLinkLabel" resname="overview.deactivateLinkLabel"> + <source>Deactivate</source> + </trans-unit> + <trans-unit id="overview.setupLinkTitle" resname="overview.setupLinkTitle"> + <source>Setup %s</source> + </trans-unit> + <trans-unit id="overview.setupLinkLabel" resname="overview.setupLinkLabel"> + <source>Setup</source> + </trans-unit> + <trans-unit id="overview.setupRequired.title" resname="overview.setupRequired.title"> + <source>Multi-factor authentication required</source> + </trans-unit> + <trans-unit id="overview.setupRequired.message" resname="overview.setupRequired.message"> + <source> + Your installation requires MFA. Therefore please setup one of the following providers to be + able to continue to use the backend. + </source> + </trans-unit> + <trans-unit id="overview.noProviders.title" resname="overview.noProviders.title"> + <source>No multi-factor authentication providers</source> + </trans-unit> + <trans-unit id="overview.noProviders.message" resname="overview.noProviders.message"> + <source>There are currently no multi-factor authentication providers available.</source> + </trans-unit> + <trans-unit id="overview.noProviders.errorMessage" resname="overview.noProviders.errorMessage"> + <source> + You are required to setup MFA but there are no providers to activate available. You should contact + your administrator to solve this. + </source> + </trans-unit> + <trans-unit id="providerNotFound" resname="providerNotFound"> + <source>Selected MFA provider was not found!</source> + </trans-unit> + <trans-unit id="setup.instructions" resname="setup.instructions"> + <source>Setup instructions</source> + </trans-unit> + <trans-unit id="setup.instructions.close" resname="setup.instructions.close"> + <source>Close setup instructions</source> + </trans-unit> + <trans-unit id="setup.title" resname="setup.title"> + <source>Set up %s</source> + </trans-unit> + <trans-unit id="edit.title" resname="edit.title"> + <source>Edit %s</source> + </trans-unit> + <trans-unit id="edit.defaultProvider" resname="edit.defaultProvider"> + <source>Default provider</source> + </trans-unit> + <trans-unit id="edit.defaultProvider.description" resname="edit.defaultProvider.description"> + <source>Select this as your default provider for login into the TYPO3 backend.</source> + </trans-unit> + <trans-unit id="edit.defaultProvider.inputLabel" resname="edit.defaultProvider.inputLabel"> + <source>Use as default provider</source> + </trans-unit> + <trans-unit id="edit.deactivateProvider" resname="edit.deactivateProvider"> + <source>Deactivate provider</source> + </trans-unit> + <trans-unit id="edit.deactivateProvider.description" resname="edit.deactivateProvider.description"> + <source>If you don't want to use this provider anymore, you can deactivate it here.</source> + </trans-unit> + <trans-unit id="edit.deactivateProvider.linkTitle" resname="edit.deactivateProvider.linkTitle"> + <source>Deactivate %s</source> + </trans-unit> + <trans-unit id="edit.deactivateProvider.linkText" resname="edit.deactivateProvider.linkText"> + <source>Deactivate this provider</source> + </trans-unit> + <trans-unit id="activate.failure" resname="activate.failure"> + <source>Could not activate MFA provider %s. Please try again.</source> + </trans-unit> + <trans-unit id="activate.success" resname="activate.success"> + <source>Successfully activated MFA provider %s.</source> + </trans-unit> + <trans-unit id="deactivate.failure" resname="deactivate.failure"> + <source>Could not deactivate MFA provider %s. Please try again.</source> + </trans-unit> + <trans-unit id="deactivate.success" resname="deactivate.success"> + <source>Successfully deactivated MFA provider %s.</source> + </trans-unit> + <trans-unit id="unlock.failure" resname="unlock.failure"> + <source>Could not unlock MFA provider %s. Please try again.</source> + </trans-unit> + <trans-unit id="unlock.success" resname="unlock.success"> + <source>Successfully unlocked MFA provider %s.</source> + </trans-unit> + <trans-unit id="save.failure" resname="save.failure"> + <source>Could not update MFA provider %s. Please try again.</source> + </trans-unit> + <trans-unit id="save.success" resname="save.success"> + <source>Successfully updated MFA provider %s.</source> + </trans-unit> + <trans-unit id="auth.authError" resname="auth.authError"> + <source>Authentication was not succesful</source> + </trans-unit> + <trans-unit id="auth.submit" resname="auth.submit"> + <source>Verify</source> + </trans-unit> + <trans-unit id="auth.cancel" resname="auth.cancel"> + <source>Go back</source> + </trans-unit> + <trans-unit id="auth.locked" resname="auth.locked"> + <source>This provider is temporarily locked!</source> + </trans-unit> + <trans-unit id="auth.alternativeProviders" resname="auth.alternativeProviders"> + <source>Alternative providers</source> + </trans-unit> + <trans-unit id="auth.alternativeProviders.use" resname="auth.alternativeProviders.use"> + <source>Use %s</source> + </trans-unit> + <trans-unit id="ajax.success"> + <source>Success</source> + </trans-unit> + <trans-unit id="ajax.error"> + <source>An error occurred</source> + </trans-unit> + <trans-unit id="ajax.invalidRequest"> + <source>Invalid request could not be processed</source> + </trans-unit> + <trans-unit id="ajax.insufficientPermissions"> + <source>Your are not allowed to perfom this action</source> + </trans-unit> + <trans-unit id="ajax.deactivate.providersNotDeactivated"> + <source>No provider was deactivated</source> + </trans-unit> + <trans-unit id="ajax.deactivate.providersDeactivated"> + <source>Successfully deactivated all active providers for user %s</source> + </trans-unit> + <trans-unit id="ajax.deactivate.providerNotFound"> + <source>Provider %s could not be found</source> + </trans-unit> + <trans-unit id="ajax.deactivate.providerNotDeactivated"> + <source>Could not deactivate provider %s</source> + </trans-unit> + <trans-unit id="ajax.deactivate.providerDeactivated"> + <source>Successfully deactivated provider %s for user %s</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html new file mode 100644 index 000000000000..3ea9301ba2df --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html @@ -0,0 +1,69 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true"> + +<div class="typo3-login"> + <div class="typo3-login-inner"> + <div class="typo3-login-container"> + <div class="typo3-login-wrap"> + <div class="card card-login"> + <header class="card-heading"> + <h2 class="text-center">{provider.title -> f:translate(id: provider.title)}</h2> + </header> + <main class="card-body"> + <f:if condition="{isLocked} || {hasAuthError}"> + <div id="t3-login-error"> + <div class="alert alert-danger"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.{f:if(condition: isLocked, then: 'locked', else: 'authError')}"/> + </div> + </div> + </f:if> + <f:if condition="!{isLocked}"> + <f:then> + <form + action="{f:be.uri(route:'auth_mfa', parameters:'{action: \'verify\'}')}" + method="post" + enctype="multipart/form-data" + name="verify" + id="mfaController"> + <input type="hidden" name="identifier" value="{provider.identifier}" /> + {providerContent -> f:format.raw()} + <div class="form-group" id="t3-login-submit-section"> + <button class="btn btn-block btn-login" type="submit" name="submit" autocomplete="off"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.submit"/> + </button> + </div> + </form> + </f:then> + <f:else> + <f:comment>In case, the provider is temporarily locked, still allow to render provider specific content.</f:comment> + {providerContent -> f:format.raw()} + </f:else> + </f:if> + <div class="cancel-authentication"> + <div class="text-right"> + <f:be.link route="auth_mfa" parameters="{action: 'cancel'}" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.cancel')}"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.cancel"/> + </f:be.link> + </div> + </div> + </main> + <f:if condition="{alternativeProviders}"> + <footer class="card-footer"> + <h5><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.alternativeProviders"/></h5> + <ul class="list-group px-lg-4"> + <f:for each="{alternativeProviders}" as="alternativeProvider"> + <li> + <f:be.link route="auth_mfa" parameters="{identifier: alternativeProvider.identifier}" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.alternativeProviders.use', arguments: {0: '{alternativeProvider.title -> f:translate(id: alternativeProvider.title)}'})}"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.alternativeProviders.use" arguments="{0: '{alternativeProvider.title -> f:translate(id: alternativeProvider.title)}'}"/> + </f:be.link> + </li> + </f:for> + </ul> + </footer> + </f:if> + </div> + </div> + </div> + </div> +</div> + +</html> diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html new file mode 100644 index 000000000000..74916e77c400 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html @@ -0,0 +1,48 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true"> + +<f:variable name="providerTitle" value="{provider.title -> f:translate(id: provider.title)}"/> + +<h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.title" arguments="{0: providerTitle}"/></h1> +<p><f:format.nl2br>{provider.description -> f:translate(id: provider.description)}</f:format.nl2br></p> + +<form + action="{f:be.uri(route:'mfa', parameters:'{action: \'save\'}')}" + method="post" + enctype="multipart/form-data" + name="edit" + id="mfaConfigurationController"> + <input type="hidden" name="identifier" value="{provider.identifier}" /> + <div class="mb-3"> + {providerContent -> f:format.raw()} + </div> + <div class="mfa-provider-settings"> + <f:if condition="{provider.defaultProviderAllowed}"> + <div class="row mb-3"> + <div class="col-md-5"> + <h4><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.defaultProvider"/></h4> + <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.defaultProvider.description"/></p> + <div class="form-check"> + <input type="checkbox" name="defaultProvider" id="defaultProvider" class="form-check-input" value="1" {f:if(condition: isDefaultProvider, then: 'checked="checked"')} /> + <label class="form-check-label" for="defaultProvider"> + <span class="form-check-label-text"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.defaultProvider.inputLabel"/> + </span> + </label> + </div> + </div> + </div> + </f:if> + <div class="row"> + <div class="col"> + <h4><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider"/></h4> + <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider.description"/></p> + <f:be.link route="mfa" parameters="{action: 'deactivate', identifier: provider.identifier}" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider.linkTitle', arguments: '{0: providerTitle}')}" class="btn btn-danger"> + <core:icon identifier="actions-toggle-off" /> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider.linkText"/> + </f:be.link> + </div> + </div> + </div> +</form> + +</html> diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html new file mode 100644 index 000000000000..18064d1d9be2 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html @@ -0,0 +1,100 @@ +<html + xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true"> + +<h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.title" /></h1> + +<f:if condition="{setupRequired}"> + <f:then> + <f:if condition="{providers}"> + <f:then> + <f:be.infobox state="1" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupRequired.title')}"> + <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupRequired.message"/></p> + </f:be.infobox> + </f:then> + <f:else> + <f:be.infobox state="2" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.title')}"> + <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.errorMessage"/></p> + </f:be.infobox> + </f:else> + </f:if> + </f:then> + <f:else if="!{providers}"> + <f:be.infobox state="-1" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.title')}"> + <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.message"/></p> + </f:be.infobox> + </f:else> +</f:if> + +<f:if condition="{providers}"> + <f:then> + <div class="card-container"> + <f:for each="{providers}" as="provider"> + <f:render section="Item" arguments="{provider: provider, defaultProvider: defaultProvider, recommendedProvider: recommendedProvider}"/> + </f:for> + </div> + </f:then> +</f:if> + +<f:section name="Item"> + <f:variable name="providerTitle" value="{provider.title -> f:translate(id: provider.title)}"/> + <div class="card card-size-fixed-small {f:if(condition: '{recommendedProvider} == {provider.identifier}', then: 'border-success shadow')}" id="{provider.identifier}-provider"> + <div class="card-header"> + <div class="card-icon"> + <core:icon identifier="{provider.iconIdentifier}" size="large"/> + </div> + <div class="card-header-body"> + <h1 class="card-title"> + {providerTitle} + <be:mfa.ifHasState state="active" provider="{provider}"> + <span class="badge badge-{be:mfa.ifHasState(state: 'locked', provider: provider, then: 'danger', else: 'success')}"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.badge.{be:mfa.ifHasState(state: 'locked', provider: provider, then: 'locked', else: 'active')}"/> + </span> + </be:mfa.ifHasState> + <f:if condition="{defaultProvider} == {provider.identifier}"> + <span title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.defaultProvider')}"> + <core:icon identifier="actions-star" /> + </span> + </f:if> + </h1> + </div> + </div> + <div class="card-content"> + <p class="card-text">{provider.description -> f:translate(id: provider.description)}</p> + </div> + <div class="card-footer"> + <be:mfa.ifHasState state="active" provider="{provider}"> + <f:then> + <be:mfa.ifHasState state="locked" provider="{provider}"> + <f:then> + <f:be.link route="mfa" parameters="{action: 'unlock', identifier: provider.identifier}" class="btn btn-success" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.unlockLinkTitle', arguments: {0: providerTitle})}"> + <core:icon identifier="actions-unlock" /> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.unlockLinkLabel"/> + </f:be.link> + </f:then> + <f:else> + <f:be.link route="mfa" parameters="{action: 'edit', identifier: provider.identifier}" class="btn btn-default" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.editLinkTitle', arguments: {0: providerTitle})}"> + <core:icon identifier="actions-open" /> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.editLinkLabel"/> + </f:be.link> + </f:else> + </be:mfa.ifHasState> + <f:be.link route="mfa" parameters="{action: 'deactivate', identifier: provider.identifier}" class="btn btn-danger" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.deactivateLinkTitle', arguments: {0: providerTitle})}"> + <core:icon identifier="actions-toggle-off" /> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.deactivateLinkLabel"/> + </f:be.link> + </f:then> + <f:else> + <f:be.link route="mfa" parameters="{action: 'setup', identifier: provider.identifier}" class="btn btn-{f:if(condition: '{recommendedProvider} == {provider.identifier}', then: 'success', else: 'default')}" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupLinkTitle', arguments: {0: providerTitle})}"> + <core:icon identifier="actions-add" /> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupLinkLabel"/> + </f:be.link> + </f:else> + </be:mfa.ifHasState> + </div> + </div> +</f:section> + +</html> diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html new file mode 100644 index 000000000000..400325af708e --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html @@ -0,0 +1,30 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true"> + +<h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:setup.title" arguments="{0: '{provider.title -> f:translate(id: provider.title)}'}"/></h1> + +<div class="row g-6"> + <div class="col"> + <form + action="{f:be.uri(route:'mfa', parameters:'{action: \'activate\'}')}" + method="post" + enctype="multipart/form-data" + name="activate" + id="mfaConfigurationController"> + <input type="hidden" name="identifier" value="{provider.identifier}" /> + {providerContent -> f:format.raw()} + </form> + </div> + <f:if condition="{provider.setupInstructions}"> + <div class="alert alert-info alert-dismissible col-md-4"> + <div class="alert-heading"> + <h4><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:setup.instructions"/></h4> + <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:setup.instructions.close')}"></button> + </div> + <div class="alert-body"> + <f:format.nl2br>{provider.setupInstructions -> f:translate(id: provider.setupInstructions)}</f:format.nl2br> + </div> + </div> + </f:if> +</div> + +</html> diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js new file mode 100644 index 000000000000..b4b082631bcf --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +define(["require","exports","TYPO3/CMS/Core/Ajax/AjaxRequest","TYPO3/CMS/Core/DocumentService","TYPO3/CMS/Core/Event/RegularEvent","TYPO3/CMS/Backend/Notification","TYPO3/CMS/Backend/Modal","TYPO3/CMS/Backend/Enum/Severity"],(function(t,e,i,a,s,r,n,l){"use strict";var o;!function(t){t.deactivteProviderButton=".t3js-deactivate-provider-button",t.deactivteMfaButton=".t3js-deactivate-mfa-button",t.providerslist=".t3js-mfa-active-providers-list",t.mfaStatusLabel=".t3js-mfa-status-label"}(o||(o={}));return class{constructor(t,e){this.options=null,this.fullElement=null,this.deactivteProviderButtons=null,this.deactivteMfaButton=null,this.providersList=null,this.mfaStatusLabel=null,this.request=null,this.options=e,a.ready().then(e=>{this.fullElement=e.querySelector(t),this.deactivteProviderButtons=this.fullElement.querySelectorAll(o.deactivteProviderButton),this.deactivteMfaButton=this.fullElement.querySelector(o.deactivteMfaButton),this.providersList=this.fullElement.querySelector(o.providerslist),this.mfaStatusLabel=this.fullElement.parentElement.querySelector(o.mfaStatusLabel),this.registerEvents()})}registerEvents(){new s("click",t=>{t.preventDefault(),this.prepareDeactivateRequest(this.deactivteMfaButton)}).bindTo(this.deactivteMfaButton),this.deactivteProviderButtons.forEach(t=>{new s("click",e=>{e.preventDefault(),this.prepareDeactivateRequest(t)}).bindTo(t)})}prepareDeactivateRequest(t){const e=n.show(t.dataset.confirmationTitle||t.getAttribute("title")||"Deactivate provider(s)",t.dataset.confirmationContent||"Are you sure you want to continue? This action cannot be undone and will be applied immediately!",l.SeverityEnum.warning,[{text:t.dataset.confirmationCancelText||"Cancel",active:!0,btnClass:"btn-default",name:"cancel"},{text:t.dataset.confirmationDeactivateText||"Deactivate",btnClass:"btn-warning",name:"deactivate",trigger:()=>{this.sendDeactivateRequest(t.dataset.provider)}}]);e.on("button.clicked",()=>{e.modal("hide")})}sendDeactivateRequest(t){this.request instanceof i&&this.request.abort(),this.request=new i(TYPO3.settings.ajaxUrls.mfa),this.request.post({action:"deactivate",provider:t,userId:this.options.userId,tableName:this.options.tableName}).then(async e=>{const i=await e.resolve();if(i.status.length>0&&i.status.forEach(t=>{i.success?r.success(t.title,t.message):r.error(t.title,t.message)}),!i.success)return;if(void 0===t||0===i.remaining)return void this.deactivateMfa();if(null===this.providersList)return;const a=this.providersList.querySelector("li#provider-"+t);if(null===a)return;a.remove();0===this.providersList.querySelectorAll("li").length&&this.deactivateMfa()}).finally(()=>{this.request=null})}deactivateMfa(){this.deactivteMfaButton.classList.add("disabled"),this.deactivteMfaButton.setAttribute("disabled","disabled"),null!==this.providersList&&this.providersList.remove(),null!==this.mfaStatusLabel&&(this.mfaStatusLabel.innerText=this.mfaStatusLabel.dataset.alternativeLabel,this.mfaStatusLabel.classList.remove("label-success"),this.mfaStatusLabel.classList.add("label-danger"))}}})); \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js b/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js index a22c3113d2d5..15efb8e47002 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js @@ -10,4 +10,4 @@ * * The TYPO3 project - inspiring people to share! */ -define(["require","exports","./Viewport/ContentContainer","./Enum/Viewport/ScaffoldIdentifier","./Event/ConsumerScope","./Viewport/Loader","./Viewport/NavigationContainer","./Storage/Persistent","./Viewport/Topbar"],(function(t,e,i,n,o,a,s,r,h){"use strict";class d{constructor(){this.Loader=a,this.NavigationContainer=null,this.ContentContainer=null,this.consumerScope=o,this.fallbackNavigationSizeIfNeeded=t=>{let e=t.currentTarget;0!==this.NavigationContainer.getWidth()&&e.outerWidth<this.NavigationContainer.getWidth()+this.NavigationContainer.getPosition().left+300&&this.NavigationContainer.autoWidth()},this.handleMouseMove=t=>{this.resizeNavigation(t.clientX)},this.handleTouchMove=t=>{this.resizeNavigation(t.changedTouches[0].clientX)},this.resizeNavigation=t=>{let e=Math.round(t)-Math.round(this.NavigationContainer.getPosition().left);this.NavigationContainer.setWidth(e)},this.stopResizeNavigation=()=>{this.navigationDragHandler.classList.remove("resizing"),this.document.removeEventListener("mousemove",this.handleMouseMove,!1),this.document.removeEventListener("mouseup",this.stopResizeNavigation,!1),this.document.removeEventListener("touchmove",this.handleTouchMove,!1),this.document.removeEventListener("touchend",this.stopResizeNavigation,!1),r.set("navigation.width",this.NavigationContainer.getWidth())},this.startResizeNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.navigationDragHandler.classList.add("resizing"),this.document.addEventListener("mousemove",this.handleMouseMove,!1),this.document.addEventListener("mouseup",this.stopResizeNavigation,!1),this.document.addEventListener("touchmove",this.handleTouchMove,!1),this.document.addEventListener("touchend",this.stopResizeNavigation,!1))},this.toggleNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.NavigationContainer.toggle())},this.document=document,this.navigationDragHandler=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationDrag);let t=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationSwitcher);this.Topbar=new h,this.NavigationContainer=new s(this.consumerScope,t),this.ContentContainer=new i(this.consumerScope),this.NavigationContainer.setWidth(r.get("navigation.width")),window.addEventListener("resize",this.fallbackNavigationSizeIfNeeded,{passive:!0}),t&&(t.addEventListener("mouseup",this.toggleNavigation,{passive:!0}),t.addEventListener("touchstart",this.toggleNavigation,{passive:!0})),this.navigationDragHandler&&(this.navigationDragHandler.addEventListener("mousedown",this.startResizeNavigation,{passive:!0}),this.navigationDragHandler.addEventListener("touchstart",this.startResizeNavigation,{passive:!0}))}}let v;return top.TYPO3.Backend?v=top.TYPO3.Backend:(v=new d,top.TYPO3.Backend=v),v})); \ No newline at end of file +define(["require","exports","./Viewport/ContentContainer","./Enum/Viewport/ScaffoldIdentifier","./Event/ConsumerScope","./Viewport/Loader","./Viewport/NavigationContainer","./Storage/Persistent","./Viewport/Topbar"],(function(t,e,i,n,o,a,s,r,h){"use strict";class d{constructor(){this.Loader=a,this.NavigationContainer=null,this.ContentContainer=null,this.consumerScope=o,this.fallbackNavigationSizeIfNeeded=t=>{let e=t.currentTarget;0!==this.NavigationContainer.getWidth()&&e.outerWidth<this.NavigationContainer.getWidth()+this.NavigationContainer.getPosition().left+300&&this.NavigationContainer.autoWidth()},this.handleMouseMove=t=>{this.resizeNavigation(t.clientX)},this.handleTouchMove=t=>{this.resizeNavigation(t.changedTouches[0].clientX)},this.resizeNavigation=t=>{let e=Math.round(t)-Math.round(this.NavigationContainer.getPosition().left);this.NavigationContainer.setWidth(e)},this.stopResizeNavigation=()=>{this.navigationDragHandler.classList.remove("resizing"),this.document.removeEventListener("mousemove",this.handleMouseMove,!1),this.document.removeEventListener("mouseup",this.stopResizeNavigation,!1),this.document.removeEventListener("touchmove",this.handleTouchMove,!1),this.document.removeEventListener("touchend",this.stopResizeNavigation,!1),r.set("navigation.width",this.NavigationContainer.getWidth())},this.startResizeNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.navigationDragHandler.classList.add("resizing"),this.document.addEventListener("mousemove",this.handleMouseMove,!1),this.document.addEventListener("mouseup",this.stopResizeNavigation,!1),this.document.addEventListener("touchmove",this.handleTouchMove,!1),this.document.addEventListener("touchend",this.stopResizeNavigation,!1))},this.toggleNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.NavigationContainer.toggle())},this.document=document,this.navigationDragHandler=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationDrag);let t=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationSwitcher);this.Topbar=new h,this.NavigationContainer=new s(this.consumerScope,t),this.ContentContainer=new i(this.consumerScope),document.querySelector(n.ScaffoldIdentifierEnum.contentNavigation)&&this.NavigationContainer.setWidth(r.get("navigation.width")),window.addEventListener("resize",this.fallbackNavigationSizeIfNeeded,{passive:!0}),t&&(t.addEventListener("mouseup",this.toggleNavigation,{passive:!0}),t.addEventListener("touchstart",this.toggleNavigation,{passive:!0})),this.navigationDragHandler&&(this.navigationDragHandler.addEventListener("mousedown",this.startResizeNavigation,{passive:!0}),this.navigationDragHandler.addEventListener("touchstart",this.startResizeNavigation,{passive:!0}))}}let v;return top.TYPO3.Backend?v=top.TYPO3.Backend:(v=new d,top.TYPO3.Backend=v),v})); \ No newline at end of file diff --git a/typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php b/typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php new file mode 100644 index 000000000000..2a0af6a5d3aa --- /dev/null +++ b/typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Beuser\ViewHelpers; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper; + +/** + * Render MFA status information + * + * @internal + */ +class MfaStatusViewHelper extends AbstractTagBasedViewHelper +{ + protected $tagName = 'span'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerUniversalTagAttributes(); + $this->registerArgument('userUid', 'int', 'The uid of the user to check', true); + } + + public function render(): string + { + $userUid = (int)($this->arguments['userUid'] ?? 0); + if (!$userUid) { + return ''; + } + + $backendUser = GeneralUtility::makeInstance(BackendUserAuthentication::class); + $backendUser->enablecolumns = ['deleted' => true]; + $backendUser->setBeUserByUid($userUid); + + $mfaProviderRegistry = GeneralUtility::makeInstance(MfaProviderRegistry::class); + + // Check if user has active providers + if (!$mfaProviderRegistry->hasActiveProviders($backendUser)) { + return ''; + } + + // Check locked providers + if ($mfaProviderRegistry->hasLockedProviders($backendUser)) { + $this->tag->addAttribute('class', 'label label-warning'); + $this->tag->setContent(htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:lockedMfaProviders'))); + return $this->tag->render(); + } + + // Add mfa enabled label since we have active providers and non of them are locked + $this->tag->addAttribute('class', 'label label-info'); + $this->tag->setContent(htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:mfaEnabled'))); + return $this->tag->render(); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf b/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf index 5ae57abd9298..046fa74f5a84 100644 --- a/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf @@ -180,6 +180,12 @@ <trans-unit id="online" resname="online"> <source>online</source> </trans-unit> + <trans-unit id="lockedMfaProviders" resname="lockedMfaProviders"> + <source>Locked MFA providers</source> + </trans-unit> + <trans-unit id="mfaEnabled" resname="mfaEnabled"> + <source>MFA enabled</source> + </trans-unit> <trans-unit id="visibility.hide" resname="visibility.hide"> <source>Hide</source> </trans-unit> diff --git a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html index 0b5a2e821959..9ac59bf0eda8 100644 --- a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html +++ b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html @@ -17,6 +17,7 @@ <f:if condition="{onlineBackendUsers.{backendUser.uid}}"> <span class="label label-success"><f:translate key="online" /></span> </f:if> + <bu:mfaStatus userUid="{backendUser.uid}"/> <br /> <f:if condition="{backendUser.realName}"> <be:link.editRecord table="be_users" uid="{backendUser.uid}" title="edit"> diff --git a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html index 01d526409857..11e7c7ee69bb 100644 --- a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html +++ b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html @@ -11,7 +11,8 @@ </td> <td class="col-title"> <b>{onlineUser.backendUser.userName}</b> - <span class="label label-success"><f:translate key="online" /></span><br /> + <span class="label label-success"><f:translate key="online" /></span> + <bu:mfaStatus userUid="{onlineUser.backendUser.uid}"/><br /> {onlineUser.backendUser.realName} </td> </f:then> diff --git a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php index bb6c779313d0..b15b39309af2 100644 --- a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php +++ b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php @@ -19,6 +19,8 @@ use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use Symfony\Component\HttpFoundation\Cookie; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException; use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Crypto\Random; use TYPO3\CMS\Core\Database\Connection; @@ -262,7 +264,15 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface // Load user session, check to see if anyone has submitted login-information and if so authenticate // the user with the session. $this->user[uid] may be used to write log... - $this->checkAuthentication(); + try { + $this->checkAuthentication(); + } catch (MfaRequiredException $mfaRequiredException) { + // Ensure the cookie is still set to keep the user session available + if (!$this->dontSetCookie || $this->isRefreshTimeBasedCookie()) { + $this->setSessionCookie(); + } + throw $mfaRequiredException; + } // Set cookie if generally enabled or if the current session is a non-session cookie (FE permalogin) if (!$this->dontSetCookie || $this->isRefreshTimeBasedCookie()) { $this->setSessionCookie(); @@ -607,6 +617,9 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface $this->regenerateSessionId(); } + // Check if multi-factor authentication is required + $this->evaluateMfaRequirements(); + if ($activeLogin) { // User logged in - write that to the log! if ($this->writeStdLog) { @@ -636,6 +649,34 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface } } + /** + * This method checks if the user is authenticated but has not succeeded in + * passing his MFA challenge. This method can therefore only be used if a user + * has been authenticated against his first authentication method (username+password + * or any other authentication token). + * + * @throws MfaRequiredException + * @internal + */ + protected function evaluateMfaRequirements(): void + { + // MFA has been validated already, nothing to do + if ($this->getSessionData('mfa')) { + return; + } + // If the user session does not contain the 'mfa' key - indicating that MFA is already + // passed - get the first provider for authentication, which is either the default provider + // or the first active provider (based on the providers configured ordering). + $provider = GeneralUtility::makeInstance(MfaProviderRegistry::class)->getFirstAuthenticationAwareProvider($this); + // Throw an exception (hopefully called in a middleware) when an active provider for the user exists + if ($provider !== null) { + throw new MfaRequiredException($provider, 1613687097); + } + // @todo If user has no active providers, check if the user is required to + // setup MFA and redirect to a standalone registration controller. + // Currently we just let the user proceed to its original target. + } + /** * Implement functionality when there was a failed login */ diff --git a/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php index 9328def9ae41..12823c246812 100644 --- a/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php +++ b/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php @@ -588,7 +588,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication * If user is admin TRUE is also returned * Please see the document Inside TYPO3 for examples. * - * @param string $type The type value; "webmounts", "filemounts", "pagetypes_select", "tables_select", "tables_modify", "non_exclude_fields", "modules", "available_widgets" + * @param string $type The type value; "webmounts", "filemounts", "pagetypes_select", "tables_select", "tables_modify", "non_exclude_fields", "modules", "available_widgets", "mfa_providers" * @param string $value String to search for in the groupData-list * @return bool TRUE if permission is granted (that is, the value was found in the groupData list - or the BE_USER is "admin") */ @@ -1222,6 +1222,8 @@ class BackendUserAuthentication extends AbstractUserAuthentication $this->groupData['modules'] = $this->user['userMods']; // Add available widgets $this->groupData['available_widgets'] = $this->user['available_widgets'] ?? ''; + // Add allowed mfa providers + $this->groupData['mfa_providers'] = $this->user['mfa_providers'] ?? ''; // Add Allowed Languages $this->groupData['allowed_languages'] = $this->user['allowed_languages']; // Set user value for workspace permissions. @@ -1252,6 +1254,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication // Gather permission detail fields $this->groupData['modules'] .= ',' . $groupInfo['groupMods']; $this->groupData['available_widgets'] .= ',' . $groupInfo['availableWidgets']; + $this->groupData['mfa_providers'] .= ',' . $groupInfo['mfa_providers']; $this->groupData['tables_select'] .= ',' . $groupInfo['tables_select']; $this->groupData['tables_modify'] .= ',' . $groupInfo['tables_modify']; $this->groupData['pagetypes_select'] .= ',' . $groupInfo['pagetypes_select']; @@ -1291,6 +1294,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication $this->groupData['custom_options'] = StringUtility::uniqueList($this->groupData['custom_options'] ?? ''); $this->groupData['modules'] = StringUtility::uniqueList($this->groupData['modules'] ?? ''); $this->groupData['available_widgets'] = StringUtility::uniqueList($this->groupData['available_widgets'] ?? ''); + $this->groupData['mfa_providers'] = StringUtility::uniqueList($this->groupData['mfa_providers'] ?? ''); $this->groupData['file_permissions'] = StringUtility::uniqueList($this->groupData['file_permissions'] ?? ''); // Check if the user access to all web mounts set diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php new file mode 100644 index 000000000000..4bf5ca4bd8f8 --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * To be implemented by all MFA providers. + * + * @internal This is an experimental TYPO3 Core API and subject to change until v11 LTS + */ +interface MfaProviderInterface +{ + /** + * Check if the current request can be handled by this provider (e.g. + * necessary query arguments are set). + * + * @param ServerRequestInterface $request + * @return bool + */ + public function canProcess(ServerRequestInterface $request): bool; + + /** + * Check if provider is active for the user by e.g. checking the user + * record for some provider specific active state. + * + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function isActive(MfaProviderPropertyManager $propertyManager): bool; + + /** + * Check if provider is temporarily locked for the user, because + * of e.g. to much false authentication attempts. This differs + * from the "isActive" state on purpose, so please DO NOT use + * the "isActive" state for such check internally. This will + * allow attackers to easily circumvent MFA! + * + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function isLocked(MfaProviderPropertyManager $propertyManager): bool; + + /** + * Verifies the MFA request + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool; + + /** + * Generate the provider specific response for the given view type. + * Note: Currently the calling controller only evaluates the response + * body and directly injects it into the corresponding view. It's however + * planned to also take further information like headers into account. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @param string $type + * @return ResponseInterface + * @see MfaViewType + */ + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + string $type + ): ResponseInterface; + + /** + * Activate / register this provider for the user + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool TRUE in case operation was successful, FALSE otherwise + */ + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool; + + /** + * Deactivate this provider for the user + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool TRUE in case operation was successful, FALSE otherwise + */ + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool; + + /** + * Unlock this provider for the user + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool TRUE in case operation was successful, FALSE otherwise + */ + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool; + + /** + * Handle changes of the provider by the user + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool TRUE in case operation was successful, FALSE otherwise + */ + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool; +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php new file mode 100644 index 000000000000..d4f14644c4b9 --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Adapter for MFA providers + * + * @internal + */ +final class MfaProviderManifest implements MfaProviderManifestInterface +{ + private string $identifier; + private string $title; + private string $description; + private string $setupInstructions; + private string $iconIdentifier; + private bool $isDefaultProviderAllowed; + private string $serviceName; + private ContainerInterface $container; + private ?MfaProviderInterface $instance = null; + + public function __construct( + string $identifier, + string $title, + string $description, + string $setupInstructions, + string $iconIdentifier, + bool $isDefaultProviderAllowed, + string $serviceName, + ContainerInterface $container + ) { + $this->identifier = $identifier; + $this->title = $title; + $this->description = $description; + $this->setupInstructions = $setupInstructions; + $this->iconIdentifier = $iconIdentifier; + $this->isDefaultProviderAllowed = $isDefaultProviderAllowed; + $this->serviceName = $serviceName; + $this->container = $container; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getIconIdentifier(): string + { + return $this->iconIdentifier; + } + + public function getSetupInstructions(): string + { + return $this->setupInstructions; + } + + public function isDefaultProviderAllowed(): bool + { + return $this->isDefaultProviderAllowed; + } + + public function canProcess(ServerRequestInterface $request): bool + { + return $this->getInstance()->canProcess($request); + } + + public function isActive(MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->isActive($propertyManager); + } + + public function isLocked(MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->isLocked($propertyManager); + } + + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->verify($request, $propertyManager); + } + + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + string $type + ): ResponseInterface { + return $this->getInstance()->handleRequest($request, $propertyManager, $type); + } + + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->activate($request, $propertyManager); + } + + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->deactivate($request, $propertyManager); + } + + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->unlock($request, $propertyManager); + } + + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->update($request, $propertyManager); + } + + private function getInstance(): MfaProviderInterface + { + return $this->instance ?? $this->createInstance(); + } + + private function createInstance(): MfaProviderInterface + { + $this->instance = $this->container->get($this->serviceName); + return $this->instance; + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php new file mode 100644 index 000000000000..075b60acacfa --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +/** + * Provides annotated information about the MFA provider – used in various views + * + * @internal + */ +interface MfaProviderManifestInterface extends MfaProviderInterface +{ + /** + * Unique provider identifier + * + * @return string + */ + public function getIdentifier(): string; + + /** + * The title of the provider + * + * @return string + */ + public function getTitle(): string; + + /** + * A short description about the provider + * + * @return string + */ + public function getDescription(): string; + + /** + * Instructions to be displayed in the setup view + * + * @return string + */ + public function getSetupInstructions(): string; + + /** + * The icon identifier for this provider + * + * @return string + */ + public function getIconIdentifier(): string; + + /** + * Whether the provider is allowed to be set as default + * + * @return bool + */ + public function isDefaultProviderAllowed(): bool; +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php new file mode 100644 index 000000000000..b2846def9dc7 --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php @@ -0,0 +1,220 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Basic manager for MFA providers to access and update their + * properties (information) from the mfa column in the user array. + * + * @internal This is an experimental TYPO3 Core API and subject to change until v11 LTS + */ +class MfaProviderPropertyManager implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + protected AbstractUserAuthentication $user; + protected array $mfa; + protected string $providerIdentifier; + protected array $providerProperties; + protected const DATABASE_FIELD_NAME = 'mfa'; + + public function __construct(AbstractUserAuthentication $user, string $provider) + { + $this->user = $user; + $this->mfa = json_decode($user->user[self::DATABASE_FIELD_NAME] ?? '', true) ?? []; + $this->providerIdentifier = $provider; + $this->providerProperties = $this->mfa[$provider] ?? []; + } + + /** + * Check if a provider entry exists for the current user + * + * @return bool + */ + public function hasProviderEntry(): bool + { + return isset($this->mfa[$this->providerIdentifier]); + } + + /** + * Check if a provider property exists + * + * @param string $key + * @return bool + */ + public function hasProperty(string $key): bool + { + return isset($this->providerProperties[$key]); + } + + /** + * Get a provider specific property value or the defined + * default value if the requested property was not found. + * + * @param string $key + * @param null $default + * @return mixed|null + */ + public function getProperty(string $key, $default = null) + { + return $this->providerProperties[$key] ?? $default; + } + + /** + * Get provider specific properties + * + * @return array + */ + public function getProperties(): array + { + return $this->providerProperties; + } + + /** + * Update the provider properties or create the provider entry if not yet present + * + * @param array $properties + * @return bool + */ + public function updateProperties(array $properties): bool + { + if (!isset($properties['updated'])) { + $properties['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + } + + $this->providerProperties = array_replace($this->providerProperties, $properties) ?? []; + $this->mfa[$this->providerIdentifier] = $this->providerProperties; + return $this->storeProperties(); + } + + /** + * Create a new provider entry for the current user + * Note: If a entry already exists, use updateProperties() instead. + * This can be checked with hasProviderEntry(). + * + * @param array $properties + * @return bool + */ + public function createProviderEntry(array $properties): bool + { + // This is to prevent unintentional overwriting of provider entries + if ($this->hasProviderEntry()) { + throw new \InvalidArgumentException( + 'A entry for provider ' . $this->providerIdentifier . ' already exists. Use updateProperties() instead.', + 1612781782 + ); + } + + if (!isset($properties['created'])) { + $properties['created'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + } + + if (!isset($properties['updated'])) { + $properties['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + } + + $this->providerProperties = $properties; + $this->mfa[$this->providerIdentifier] = $this->providerProperties; + return $this->storeProperties(); + } + + /** + * Delete a provider entry for the current user + * + * @return bool + * @throws \JsonException + */ + public function deleteProviderEntry(): bool + { + $this->providerProperties = []; + unset($this->mfa[$this->providerIdentifier]); + return $this->storeProperties(); + } + + /** + * Stores the updated properties in the user array and the database + * + * @return bool + * @throws \JsonException + */ + protected function storeProperties(): bool + { + // encode the mfa properties to store them in the database and the user array + $mfa = json_encode($this->mfa, JSON_THROW_ON_ERROR) ?: ''; + + // Write back the updated mfa properties to the user array + $this->user->user[self::DATABASE_FIELD_NAME] = $mfa; + + // Log MFA update + $this->logger->debug('MFA properties updated', [ + 'provider' => $this->providerIdentifier, + 'user' => [ + 'uid' => $this->user->user[$this->user->userid_column], + 'username' => $this->user->user[$this->user->username_column] + ] + ]); + + // Store updated mfa properties in the database + return (bool)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user->user_table)->update( + $this->user->user_table, + [self::DATABASE_FIELD_NAME => $mfa], + [$this->user->userid_column => (int)$this->user->user[$this->user->userid_column]], + [self::DATABASE_FIELD_NAME => Connection::PARAM_LOB] + ); + } + + /** + * Return the current user + * + * @return AbstractUserAuthentication + */ + public function getUser(): AbstractUserAuthentication + { + return $this->user; + } + + /** + * Return the current providers identifier + * + * @return string + */ + public function getIdentifier(): string + { + return $this->providerIdentifier; + } + + /** + * Create property manager for the user with the given provider + * + * @param MfaProviderManifestInterface $provider + * @param AbstractUserAuthentication $user + * @return MfaProviderPropertyManager + */ + public static function create(MfaProviderManifestInterface $provider, AbstractUserAuthentication $user): self + { + return GeneralUtility::makeInstance(self::class, $user, $provider->getIdentifier()); + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php new file mode 100644 index 000000000000..b5ef3c7ca82a --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication; + +/** + * Registry for configuration providers which is called by the ConfigurationProviderPass + * + * @internal + */ +class MfaProviderRegistry +{ + /** + * @var MfaProviderManifestInterface[] + */ + protected array $providers = []; + + public function registerProvider(MfaProviderManifestInterface $provider): void + { + $this->providers[$provider->getIdentifier()] = $provider; + } + + public function hasProvider(string $identifer): bool + { + return isset($this->providers[$identifer]); + } + + public function hasProviders(): bool + { + return $this->providers !== []; + } + + public function getProvider(string $identifier): MfaProviderManifestInterface + { + if (!$this->hasProvider($identifier)) { + throw new \InvalidArgumentException('No MFA provider for identifier ' . $identifier . 'found.', 1610994735); + } + + return $this->providers[$identifier]; + } + + public function getProviders(): array + { + return $this->providers; + } + + /** + * Whether the given user has active providers + * + * @param AbstractUserAuthentication $user + * @return bool + */ + public function hasActiveProviders(AbstractUserAuthentication $user): bool + { + return $this->getActiveProviders($user) !== []; + } + + /** + * Get all active providers for the given user + * + * @param AbstractUserAuthentication $user + * @return MfaProviderManifestInterface[] + */ + public function getActiveProviders(AbstractUserAuthentication $user): array + { + return array_filter($this->providers, static function ($provider) use ($user) { + return $provider->isActive(MfaProviderPropertyManager::create($provider, $user)); + }); + } + + /** + * Get the first provider for the user which can be used for authentication. + * This is either the user specified default provider, or the first active + * provider based on the providers configured ordering. + * + * @param AbstractUserAuthentication $user + * @return MfaProviderManifestInterface + */ + public function getFirstAuthenticationAwareProvider(AbstractUserAuthentication $user): ?MfaProviderManifestInterface + { + // Since the user is not fully authenticated we need to unpack UC here to be + // able to retrieve a possible defined default (preferred) provider. + $user->unpack_uc(); + + $activeProviders = $this->getActiveProviders($user); + // If the user did not activate any provider yet, authentication is not possible + if ($activeProviders === []) { + return null; + } + // Check if the user has chosen a default (preferred) provider, which is still active + $defaultProvider = (string)($user->uc['mfa']['defaultProvider'] ?? ''); + if ($defaultProvider !== '' && isset($activeProviders[$defaultProvider])) { + return $activeProviders[$defaultProvider]; + } + // If no default provider exists or is not valid, return the first active provider + return array_shift($activeProviders); + } + + /** + * Whether the given user has locked providers + * + * @param AbstractUserAuthentication $user + * @return bool + */ + public function hasLockedProviders(AbstractUserAuthentication $user): bool + { + return $this->getLockedProviders($user) !== []; + } + + /** + * Get all locked providers for the given user + * + * @param AbstractUserAuthentication $user + * @return MfaProviderManifestInterface[] + */ + public function getLockedProviders(AbstractUserAuthentication $user): array + { + return array_filter($this->providers, static function ($provider) use ($user) { + return $provider->isLocked(MfaProviderPropertyManager::create($provider, $user)); + }); + } + + public function allowedProvidersItemsProcFunc(array &$parameters): void + { + foreach ($this->providers as $provider) { + $parameters['items'][] = [ + $provider->getTitle(), + $provider->getIdentifier(), + $provider->getIconIdentifier() + ]; + } + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php new file mode 100644 index 000000000000..b169bd58b4ff --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +use TYPO3\CMS\Core\Exception; + +/** + * This exception is thrown during the authentication process + * when a user has successfully passed its first authentication + * method (e.g. via username+password), but is required to also + * pass multi-factor authentication (e.g. one-time password). + */ +class MfaRequiredException extends Exception +{ + private MfaProviderManifestInterface $provider; + + public function __construct(MfaProviderManifestInterface $provider, $code = 0, $message = '', \Throwable $previous = null) + { + $this->provider = $provider; + parent::__construct($message, $code, $previous); + } + + public function getProvider(): MfaProviderManifestInterface + { + return $this->provider; + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php new file mode 100644 index 000000000000..6c6871306068 --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa; + +use TYPO3\CMS\Core\Type\Enumeration; + +/** + * Enumeration of possible view types for MFA providers + * + * @internal This is an experimental TYPO3 Core API and subject to change until v11 LTS + */ +class MfaViewType extends Enumeration +{ + public const SETUP = 'setup'; + public const EDIT ='edit'; + public const AUTH ='auth'; +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php new file mode 100644 index 000000000000..3916c23b448a --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php @@ -0,0 +1,136 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +declare(strict_types=1); + +namespace TYPO3\CMS\Core\Authentication\Mfa\Provider; + +use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; +use TYPO3\CMS\Core\Crypto\Random; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Implementation for generation and validation of recovery codes + * + * @internal should only be used by the TYPO3 Core + */ +class RecoveryCodes +{ + private const MIN_LENGTH = 8; + + protected string $mode; + protected PasswordHashFactory $passwordHashFactory; + + public function __construct(string $mode) + { + $this->mode = $mode; + $this->passwordHashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class); + } + + /** + * Generate plain and hashed recovery codes and return them as key/value + * + * @return array + */ + public function generateRecoveryCodes(): array + { + $plainCodes = $this->generatePlainRecoveryCodes(); + return array_combine($plainCodes, $this->generatedHashedRecoveryCodes($plainCodes)); + } + + /** + * Generate given amount of plain recovery codes with the given length + * + * @param int $length + * @param int $quantity + * @return string[] + */ + public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array + { + if ($length < self::MIN_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, + 1613666803 + ); + } + + $codes = []; + $random = GeneralUtility::makeInstance(Random::class); + + while ($quantity >= 1 && count($codes) < $quantity) { + $number = (int)hexdec($random->generateRandomHexString(16)); + // We only want to work with positive integers + if ($number < 0) { + continue; + } + // Create a $length long string from the random number + $code = str_pad((string)($number % (10 ** $length)), $length, '0', STR_PAD_LEFT); + // Prevent duplicate codes which is however very unlikely to happen + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } + + return $codes; + } + + /** + * Hash the given plain recovery codes with the default hash instance and return them + * + * @param array $codes + * @return array + */ + public function generatedHashedRecoveryCodes(array $codes): array + { + // Use the current default hash instance for hashing the recovery codes + $hashInstance = $this->passwordHashFactory->getDefaultHashInstance($this->mode); + + foreach ($codes as &$code) { + $code = $hashInstance->getHashedPassword($code); + } + unset($code); + return $codes; + } + + /** + * Compare given recovery code against all hashed codes and + * unset the corresponding code on success. + * + * @param string $recoveryCode + * @param array $codes + * @return bool + */ + public function verifyRecoveryCode(string $recoveryCode, array &$codes): bool + { + if ($codes === []) { + return false; + } + + // Get the hash instance which was initially used to generate these codes. + // This could differ from the current default has instance. We however only need + // to check the first code since recovery codes can not be generated individually. + $hasInstance = $this->passwordHashFactory->get(reset($codes), $this->mode); + + foreach ($codes as $key => $code) { + // Compare hashed codes + if ($hasInstance->checkPassword($recoveryCode, $code)) { + // Unset the matching code + unset($codes[$key]); + return true; + } + } + return false; + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php new file mode 100644 index 000000000000..842b0765eb2b --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php @@ -0,0 +1,407 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +declare(strict_types=1); + +namespace TYPO3\CMS\Core\Authentication\Mfa\Provider; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Backend\Routing\UriBuilder; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Http\HtmlResponse; +use TYPO3\CMS\Core\Http\PropagateResponseException; +use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Fluid\View\StandaloneView; + +/** + * MFA provider for authentication with recovery codes + * + * @internal should only be used by the TYPO3 Core + */ +class RecoveryCodesProvider implements MfaProviderInterface +{ + protected MfaProviderRegistry $mfaProviderRegistry; + protected Context $context; + protected UriBuilder $uriBuilder; + protected FlashMessageService $flashMessageService; + + public function __construct( + MfaProviderRegistry $mfaProviderRegistry, + Context $context, + UriBuilder $uriBuilder, + FlashMessageService $flashMessageService + ) { + $this->mfaProviderRegistry = $mfaProviderRegistry; + $this->context = $context; + $this->uriBuilder = $uriBuilder; + $this->flashMessageService = $flashMessageService; + } + + private const MAX_ATTEMPTS = 3; + + /** + * Check if a recovery code is given in the current request + * + * @param ServerRequestInterface $request + * @return bool + */ + public function canProcess(ServerRequestInterface $request): bool + { + return $this->getRecoveryCode($request) !== ''; + } + + /** + * Evaluate if the provider is activated by checking the + * active state from the provider properties. This provider + * furthermore has a mannerism that it only works if at least + * one other MFA provider is activated for the user. + * + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function isActive(MfaProviderPropertyManager $propertyManager): bool + { + return (bool)$propertyManager->getProperty('active') + && $this->activeProvidersExist($propertyManager); + } + + /** + * Evaluate if the provider is temporarily locked by checking + * the current attempts state from the provider properties and + * if there are still recovery codes left. + * + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function isLocked(MfaProviderPropertyManager $propertyManager): bool + { + $attempts = (int)$propertyManager->getProperty('attempts', 0); + $codes = (array)$propertyManager->getProperty('codes', []); + + // Assume the provider is locked in case either the maximum attempts are exceeded or no codes + // are available. A provider however can only be locked if set up - an entry exists in database. + return $propertyManager->hasProviderEntry() && ($attempts >= self::MAX_ATTEMPTS || $codes === []); + } + + /** + * Verify the given recovery code and remove it from the + * provider properties if valid. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * + * @return bool + */ + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + $recoveryCode = $this->getRecoveryCode($request); + $codes = $propertyManager->getProperty('codes', []); + $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager)); + if (!$recoveryCodes->verifyRecoveryCode($recoveryCode, $codes)) { + $attempts = $propertyManager->getProperty('attempts', 0); + $propertyManager->updateProperties(['attempts' => ++$attempts]); + return false; + } + + // Since the codes were passed by reference to the verify method, the matching code was + // unset so we simply need to write the array back. However, if the update fails, we must + // return FALSE even if the authentication was successful to prevent data inconsistency. + return $propertyManager->updateProperties([ + 'codes' => $codes, + 'attempts' => 0, + 'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp') + ]); + } + + /** + * Render the provider specific response for the given content type + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @param string $type + * @return ResponseInterface + * @throws PropagateResponseException + */ + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + string $type + ): ResponseInterface { + $view = GeneralUtility::makeInstance(StandaloneView::class); + $view->setTemplateRootPaths(['EXT:core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes']); + switch ($type) { + case MfaViewType::SETUP: + if (!$this->activeProvidersExist($propertyManager)) { + // If no active providers are present for the current user, add a flash message and redirect + $lang = $this->getLanguageService(); + $this->addFlashMessage( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.message'), + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.title'), + FlashMessage::WARNING + ); + if (($normalizedParams = $request->getAttribute('normalizedParams'))) { + $returnUrl = $normalizedParams->getHttpReferer(); + } else { + // @todo this will not work for FE - make this more generic! + $returnUrl = $this->uriBuilder->buildUriFromRoute('mfa'); + } + throw new PropagateResponseException(new RedirectResponse($returnUrl, 303), 1612883326); + } + $codes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatePlainRecoveryCodes(); + $view->setTemplate('Setup'); + $view->assignMultiple([ + 'recoveryCodes' => implode(PHP_EOL, $codes), + // Generate hmac of the recovery codes to prevent them from being changed in the setup from + 'checksum' => GeneralUtility::hmac(json_encode($codes), 'recovery-codes-setup') + ]); + break; + case MfaViewType::EDIT: + $view->setTemplate('Edit'); + $view->assignMultiple([ + 'name' => $propertyManager->getProperty('name'), + 'amountOfCodesLeft' => count($propertyManager->getProperty('codes', [])), + 'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)), + 'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)) + ]); + break; + case MfaViewType::AUTH: + $view->setTemplate('Auth'); + $view->assign('isLocked', $this->isLocked($propertyManager)); + break; + } + return new HtmlResponse($view->assign('providerIdentifier', $propertyManager->getIdentifier())->render()); + } + + /** + * Activate the provider by hashing and storing the given recovery codes + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if ($this->isActive($propertyManager)) { + // Return since the user already activated this provider + return true; + } + + $recoveryCodes = GeneralUtility::trimExplode(PHP_EOL, (string)($request->getParsedBody()['recoveryCodes'] ?? '')); + $checksum = (string)($request->getParsedBody()['checksum'] ?? ''); + if ($recoveryCodes === [] + || !hash_equals(GeneralUtility::hmac(json_encode($recoveryCodes), 'recovery-codes-setup'), $checksum) + ) { + // Return since the request does not contain the initially created recovery codes + return false; + } + + // Hash given plain recovery codes and prepare the properties array with active state and custom name + $hashedCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatedHashedRecoveryCodes($recoveryCodes); + $properties = ['codes' => $hashedCodes, 'active' => true]; + if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') { + $properties['name'] = $name; + } + + // Usually there should be no entry if the provider is not activated, but to prevent the + // provider from being unable to activate again, we update the existing entry in such case. + return $propertyManager->hasProviderEntry() + ? $propertyManager->updateProperties($properties) + : $propertyManager->createProviderEntry($properties); + } + + /** + * Handle the deactivate action by removing the provider entry + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + // Only check for the active property here to enable bulk deactivation, + // e.g. in FormEngine. Otherwise it would not be possible to deactivate + // this provider if the last "fully" provider was deactivated before. + if (!(bool)$propertyManager->getProperty('active')) { + // Return since this provider is not activated + return false; + } + + // Delete the provider entry + return $propertyManager->deleteProviderEntry(); + } + + /** + * Handle the unlock action by resetting the attempts + * provider property and issuing new codes. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isLocked($propertyManager)) { + // Return since this provider is not locked + return false; + } + + // Reset attempts + if ((int)$propertyManager->getProperty('attempts', 0) !== 0 + && !$propertyManager->updateProperties(['attempts' => 0]) + ) { + // Could not reset the attempts, so we can not unlock the provider + return false; + } + + // Regenerate codes + if ($propertyManager->getProperty('codes', []) === []) { + // Generate new codes and store the hashed ones + $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes(); + if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) { + // Codes could not be stored, so we can not unlock the provider + return false; + } + // Add the newly generated codes to a flash message so the user can copy them + $lang = $this->getLanguageService(); + $this->addFlashMessage( + sprintf( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.message'), + implode(' ', array_keys($recoveryCodes)) + ), + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.title'), + FlashMessage::WARNING + ); + } + + return true; + } + + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + $name = (string)($request->getParsedBody()['name'] ?? ''); + if ($name !== '' && !$propertyManager->updateProperties(['name' => $name])) { + return false; + } + + if ((bool)($request->getParsedBody()['regenerateCodes'] ?? false)) { + // Generate new codes and store the hashed ones + $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes(); + if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) { + // Codes could not be stored, so we can not update the provider + return false; + } + // Add the newly generated codes to a flash message so the user can copy them + $lang = $this->getLanguageService(); + $this->addFlashMessage( + sprintf( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.message'), + implode(' ', array_keys($recoveryCodes)) + ), + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.title'), + FlashMessage::OK + ); + } + + // Provider properties successfully updated + return true; + } + + /** + * Check if the current user has other active providers + * + * @param MfaProviderPropertyManager $currentPropertyManager + * @return bool + */ + protected function activeProvidersExist(MfaProviderPropertyManager $currentPropertyManager): bool + { + $user = $currentPropertyManager->getUser(); + foreach ($this->mfaProviderRegistry->getProviders() as $identifier => $provider) { + $propertyManager = MfaProviderPropertyManager::create($provider, $user); + if ($identifier !== $currentPropertyManager->getIdentifier() && $provider->isActive($propertyManager)) { + return true; + } + } + return false; + } + + /** + * Internal helper method for fetching the recovery code from the request + * + * @param ServerRequestInterface $request + * @return string + */ + protected function getRecoveryCode(ServerRequestInterface $request): string + { + return trim((string)($request->getQueryParams()['rc'] ?? $request->getParsedBody()['rc'] ?? '')); + } + + /** + * Determine the mode (used for the hash instance) based on the current users table + * + * @param MfaProviderPropertyManager $propertyManager + * @return string + */ + protected function getMode(MfaProviderPropertyManager $propertyManager): string + { + return $propertyManager->getUser()->loginType; + } + + /** + * Add a custom flash message for this provider + * Note: The flash messages added by the main controller are still shown to the user. + * + * @param string $message + * @param string $title + * @param int $severity + */ + protected function addFlashMessage(string $message, string $title = '', int $severity = FlashMessage::INFO): void + { + $this->flashMessageService->getMessageQueueByIdentifier()->enqueue( + GeneralUtility::makeInstance(FlashMessage::class, $message, $title, $severity, true) + ); + } + + /** + * Return the timestamp as local time (date string) by applying the globally configured format + * + * @param int $timestamp + * @return string + */ + protected function getDateTime(int $timestamp): string + { + if ($timestamp === 0) { + return ''; + } + + return date( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], + $timestamp + ) ?: ''; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php new file mode 100644 index 000000000000..48c67085a222 --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php @@ -0,0 +1,220 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +declare(strict_types=1); + +namespace TYPO3\CMS\Core\Authentication\Mfa\Provider; + +use Base32\Base32; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Time-based one-time password (TOTP) implementation according to rfc6238 + * + * @internal should only be used by the TYPO3 Core + */ +class Totp +{ + private const ALLOWED_ALGOS = ['sha1', 'sha256', 'sha512']; + private const MIN_LENGTH = 6; + private const MAX_LENGTH = 8; + + protected string $secret; + protected string $algo; + protected int $length; + protected int $step; + protected int $epoch; + + public function __construct( + string $secret, + string $algo = 'sha1', + int $length = 6, + int $step = 30, + int $epoch = 0 + ) { + $this->secret = $secret; + $this->step = $step; + $this->epoch = $epoch; + + if (!in_array($algo, self::ALLOWED_ALGOS, true)) { + throw new \InvalidArgumentException( + $algo . ' is not allowed. Allowed algos are: ' . implode(',', self::ALLOWED_ALGOS), + 1611748791 + ); + } + $this->algo = $algo; + + if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as TOTP length. Must be between ' . self::MIN_LENGTH . ' and ' . self::MAX_LENGTH, + 1611748792 + ); + } + $this->length = $length; + } + + /** + * Generate a time-based one-time password for the given counter according to rfc4226 + * + * @param int $counter A timestamp (counter) according to rfc6238 + * @return string The generated TOTP + */ + public function generateTotp(int $counter): string + { + // Generate a 8-byte counter value (C) from the given counter input + $binary = []; + while ($counter !== 0) { + $binary[] = pack('C*', $counter); + $counter >>= 8; + } + // Implode and fill with NULL values + $binary = str_pad(implode(array_reverse($binary)), 8, "\000", STR_PAD_LEFT); + // Create a 20-byte hash string (HS) with given algo and decoded shared secret (K) + $hash = hash_hmac($this->algo, $binary, $this->getDecodedSecret()); + // Convert hash into hex and generate an array with the decimal values of the hash + $hmac = []; + foreach (str_split($hash, 2) as $hex) { + $hmac[] = hexdec($hex); + } + // Generate a 4-byte string with dynamic truncation (DT) + $offset = $hmac[\count($hmac) - 1] & 0xf; + $bits = ((($hmac[$offset + 0] & 0x7f) << 24) | (($hmac[$offset + 1] & 0xff) << 16) | (($hmac[$offset + 2] & 0xff) << 8) | ($hmac[$offset + 3] & 0xff)); + // Compute the TOTP value by reducing the bits modulo 10^Digits and filling it with zeros '0' + return str_pad((string)($bits % (10 ** $this->length)), $this->length, '0', STR_PAD_LEFT); + } + + /** + * Verify the given time-based one-time password + * + * @param string $totp The time-based one-time password to be verified + * @param int|null $gracePeriod The grace period for the TOTP +- (mainly to circumvent transmission delays) + * @return bool + */ + public function verifyTotp(string $totp, int $gracePeriod = null): bool + { + $counter = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + + // If no grace period is given, only check once + if ($gracePeriod === null) { + return $this->compare($totp, $this->getTimeCounter($counter)); + } + + // Check the token within the given grace period till it can be verified or the grace period is exhausted + for ($i = 0; $i < $gracePeriod; ++$i) { + $next = $i * $this->step + $counter; + $prev = $counter - $i * $this->step; + if ($this->compare($totp, $this->getTimeCounter($next)) + || $this->compare($totp, $this->getTimeCounter($prev)) + ) { + return true; + } + } + + return false; + } + + /** + * Generate and return the otpauth URL for TOTP + * + * @param string $issuer + * @param string $account + * @param array $additionalParameters + * @return string + */ + public function getTotpAuthUrl(string $issuer, string $account = '', array $additionalParameters = []): string + { + $parameters = [ + 'secret' => $this->secret, + 'issuer' => htmlspecialchars($issuer) + ]; + + // Common OTP applications expect the following parameters: + // - algo: sha1 + // - period: 30 (in seconds) + // - digits 6 + // - epoch: 0 + // Only if we differ from these assumption, the exact values must be provided. + if ($this->algo !== 'sha1') { + $parameters['algorithm'] = $this->algo; + } + if ($this->step !== 30) { + $parameters['period'] = $this->step; + } + if ($this->length !== 6) { + $parameters['digits'] = $this->length; + } + if ($this->epoch !== 0) { + $parameters['epoch'] = $this->epoch; + } + + // Generate the otpauth URL by providing information like issuer and account + return sprintf( + 'otpauth://totp/%s?%s', + rawurlencode($issuer . ($account !== '' ? ':' . $account : '')), + http_build_query(array_merge($parameters, $additionalParameters), '', '&', PHP_QUERY_RFC3986) + ); + } + + /** + * Compare given time-based one-time password with a time-based one-time + * password generated from the known $counter (the moving factor). + * + * @param string $totp The time-based one-time password to verify + * @param int $counter The counter value, the moving factor + * @return bool + */ + protected function compare(string $totp, int $counter): bool + { + return hash_equals($this->generateTotp($counter), $totp); + } + + /** + * Generate the counter value (moving factor) from the given timestamp + * + * @param int $timestamp + * @return int + */ + protected function getTimeCounter(int $timestamp): int + { + return (int)floor(($timestamp - $this->epoch) / $this->step); + } + + /** + * Generate the shared secret (K) by using a random and applying + * additional authentication factors like username or email address. + * + * @param array $additionalAuthFactors + * @return string + */ + public static function generateEncodedSecret(array $additionalAuthFactors = []): string + { + $secret = ''; + $payload = implode($additionalAuthFactors); + // Prevent secrets with a trailing pad character since this will eventually break the QR-code feature + while ($secret === '' || strpos($secret, '=') !== false) { + // RFC 4226 (https://tools.ietf.org/html/rfc4226#section-4) suggests 160 bit TOTP secret keys + // HMAC-SHA1 based on static factors and a 160 bit HMAC-key lead again to 160 bits (20 bytes) + // base64-encoding (factor 1.6) 20 bytes lead to 32 uppercase characters + $secret = Base32::encode(hash_hmac('sha1', $payload, random_bytes(20), true)); + } + return $secret; + } + + protected function getDecodedSecret(): string + { + return Base32::decode($this->secret); + } +} diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php new file mode 100644 index 000000000000..0d7f036eddae --- /dev/null +++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php @@ -0,0 +1,345 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Authentication\Mfa\Provider; + +use BaconQrCode\Renderer\Image\SvgImageBackEnd; +use BaconQrCode\Renderer\ImageRenderer; +use BaconQrCode\Renderer\RendererStyle\RendererStyle; +use BaconQrCode\Writer; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Http\HtmlResponse; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; +use TYPO3\CMS\Fluid\View\StandaloneView; + +/** + * MFA provider for time-based one-time password authentication + * + * @internal should only be used by the TYPO3 Core + */ +class TotpProvider implements MfaProviderInterface +{ + private const MAX_ATTEMPTS = 3; + + protected Context $context; + + public function __construct(Context $context) + { + $this->context = $context; + } + + /** + * Check if a TOTP is given in the current request + * + * @param ServerRequestInterface $request + * @return bool + */ + public function canProcess(ServerRequestInterface $request): bool + { + return $this->getTotp($request) !== ''; + } + + /** + * Evaluate if the provider is activated by checking + * the active state from the provider properties. + * + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function isActive(MfaProviderPropertyManager $propertyManager): bool + { + return (bool)$propertyManager->getProperty('active'); + } + + /** + * Evaluate if the provider is temporarily locked by checking + * the current attempts state from the provider properties. + * + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function isLocked(MfaProviderPropertyManager $propertyManager): bool + { + $attempts = (int)$propertyManager->getProperty('attempts', 0); + + // Assume the provider is locked in case the maximum attempts are exceeded. + // A provider however can only be locked if set up - an entry exists in database. + return $propertyManager->hasProviderEntry() && $attempts >= self::MAX_ATTEMPTS; + } + + /** + * Verify the given TOTP and update the provider properties in case the TOTP is valid. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + $totp = $this->getTotp($request); + $secret = $propertyManager->getProperty('secret', ''); + $verified = GeneralUtility::makeInstance(Totp::class, $secret)->verifyTotp($totp, 2); + if (!$verified) { + $attempts = $propertyManager->getProperty('attempts', 0); + $propertyManager->updateProperties(['attempts' => ++$attempts]); + return false; + } + $propertyManager->updateProperties([ + 'attempts' => 0, + 'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp') + ]); + return true; + } + + /** + * Activate the provider by checking the necessary parameters, + * verifying the TOTP and storing the provider properties. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if ($this->isActive($propertyManager)) { + // Return since the user already activated this provider + return true; + } + + if (!$this->canProcess($request)) { + // Return since the request can not be processed by this provider + return false; + } + + $secret = (string)($request->getParsedBody()['secret'] ?? ''); + $checksum = (string)($request->getParsedBody()['checksum'] ?? ''); + if ($secret === '' || !hash_equals(GeneralUtility::hmac($secret, 'totp-setup'), $checksum)) { + // Return since the request does not contain the initially created secret + return false; + } + + $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret); + if (!$totpInstance->verifyTotp($this->getTotp($request), 2)) { + // Return since the given TOTP could not be verified + return false; + } + + // If valid, prepare the provider properties to be stored + $properties = ['secret' => $secret, 'active' => true]; + if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') { + $properties['name'] = $name; + } + + // Usually there should be no entry if the provider is not activated, but to prevent the + // provider from being unable to activate again, we update the existing entry in such case. + return $propertyManager->hasProviderEntry() + ? $propertyManager->updateProperties($properties) + : $propertyManager->createProviderEntry($properties); + } + + /** + * Handle the save action by updating the provider properties + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + $name = (string)($request->getParsedBody()['name'] ?? ''); + if ($name !== '') { + return $propertyManager->updateProperties(['name' => $name]); + } + + // Provider properties successfully updated + return true; + } + + /** + * Handle the unlock action by resetting the attempts provider property + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isLocked($propertyManager)) { + // Return since this provider is not locked + return false; + } + + // Reset the attempts + return $propertyManager->updateProperties(['attempts' => 0]); + } + + /** + * Handle the deactivate action. For security reasons, the provider entry + * is completely deleted and setting up this provider again, will therefore + * create a brand new entry. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @return bool + */ + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager)) { + // Return since this provider is not activated + return false; + } + + // Delete the provider entry + return $propertyManager->deleteProviderEntry(); + } + + /** + * Initialize view and forward to the appropriate implementation + * based on the view type to be returned. + * + * @param ServerRequestInterface $request + * @param MfaProviderPropertyManager $propertyManager + * @param string $type + * @return ResponseInterface + */ + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + string $type + ): ResponseInterface { + $view = GeneralUtility::makeInstance(StandaloneView::class); + $view->setTemplateRootPaths(['EXT:core/Resources/Private/Templates/Authentication/MfaProvider/Totp']); + switch ($type) { + case MfaViewType::SETUP: + $this->prepareSetupView($view, $propertyManager); + break; + case MfaViewType::EDIT: + $this->prepareEditView($view, $propertyManager); + break; + case MfaViewType::AUTH: + $this->prepareAuthView($view, $propertyManager); + break; + } + return new HtmlResponse($view->assign('providerIdentifier', $propertyManager->getIdentifier())->render()); + } + + /** + * Generate a new shared secret, generate the otpauth URL and create a qr-code + * for improved usability. Set template and assign necessary variables for the + * setup view. + * + * @param ViewInterface $view + * @param MfaProviderPropertyManager $propertyManager + */ + protected function prepareSetupView(ViewInterface $view, MfaProviderPropertyManager $propertyManager): void + { + $userData = $propertyManager->getUser()->user ?? []; + $secret = Totp::generateEncodedSecret([(string)($userData['uid'] ?? ''), (string)($userData['username'] ?? '')]); + $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret); + $view->setTemplate('Setup'); + $view->assignMultiple([ + 'secret' => $secret, + 'qrCode' => $this->getSvgQrCode($totpInstance, $userData), + // Generate hmac of the secret to prevent it from being changed in the setup from + 'checksum' => GeneralUtility::hmac($secret, 'totp-setup') + ]); + } + + /** + * Set the template and assign necessary variables for the edit view + * + * @param ViewInterface $view + * @param MfaProviderPropertyManager $propertyManager + */ + protected function prepareEditView(ViewInterface $view, MfaProviderPropertyManager $propertyManager): void + { + $view->setTemplate('Edit'); + $view->assignMultiple([ + 'name' => $propertyManager->getProperty('name'), + 'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)), + 'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)) + ]); + } + + /** + * Set the template for the auth view where the user has to provide the TOTP + * + * @param ViewInterface $view + * @param MfaProviderPropertyManager $propertyManager + */ + protected function prepareAuthView(ViewInterface $view, MfaProviderPropertyManager $propertyManager): void + { + $view->setTemplate('Auth'); + $view->assign('isLocked', $this->isLocked($propertyManager)); + } + + /** + * Internal helper method for fetching the TOTP from the request + * + * @param ServerRequestInterface $request + * @return string + */ + protected function getTotp(ServerRequestInterface $request): string + { + return trim((string)($request->getQueryParams()['totp'] ?? $request->getParsedBody()['totp'] ?? '')); + } + + /** + * Internal helper method for generating a svg QR-code for TOTP applications + * + * @param Totp $totp + * @param array $userData + * @return string + */ + protected function getSvgQrCode(Totp $totp, array $userData): string + { + $qrCodeRenderer = new ImageRenderer( + new RendererStyle(225, 4), + new SvgImageBackEnd() + ); + + $content = $totp->getTotpAuthUrl( + (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? 'TYPO3'), + (string)($userData['email'] ?? '') ?: (string)($userData['username'] ?? '') + ); + return (new Writer($qrCodeRenderer))->writeString($content); + } + + /** + * Return the timestamp as local time (date string) by applying the globally configured format + * + * @param int $timestamp + * @return string + */ + protected function getDateTime(int $timestamp): string + { + if ($timestamp === 0) { + return ''; + } + + return date( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], + $timestamp + ) ?: ''; + } +} diff --git a/typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php b/typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php new file mode 100644 index 000000000000..2467a4900e80 --- /dev/null +++ b/typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\DependencyInjection; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifest; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Service\DependencyOrderingService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Compiler pass to register tagged MFA providers + * + * @internal + */ +final class MfaProviderPass implements CompilerPassInterface +{ + protected string $tagName; + + public function __construct(string $tagName) + { + $this->tagName = $tagName; + } + + public function process(ContainerBuilder $container): void + { + $mfaProviderRegistryDefinition = $container->findDefinition(MfaProviderRegistry::class); + $providers = []; + + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + $definition->setPublic(true); + + foreach ($tags as $attributes) { + $identifier = $attributes['identifier'] ?? $id; + $providers[$identifier] = [ + 'title' => $attributes['title'] ?? '', + 'description' => $attributes['description'] ?? '', + 'setupInstructions' => $attributes['setupInstructions'] ?? '', + 'iconIdentifier' => $attributes['icon'] ?? '', + 'isDefaultProviderAllowed' => (bool)($attributes['defaultProviderAllowed'] ?? true), + 'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true), + 'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true), + 'serviceName' => $id + ]; + } + } + + foreach ((new DependencyOrderingService())->orderByDependencies($providers) as $identifier => $properties) { + $manifest = new Definition(MfaProviderManifest::class); + $manifest->setArguments([ + $identifier, + $properties['title'], + $properties['description'], + $properties['setupInstructions'], + $properties['iconIdentifier'], + $properties['isDefaultProviderAllowed'], + $properties['serviceName'], + new Reference(ContainerInterface::class) + ]); + $manifest->setShared(false); + + $mfaProviderRegistryDefinition->addMethodCall('registerProvider', [$manifest]); + } + } +} diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php index 44e066597101..ae175d945949 100644 --- a/typo3/sysext/core/Configuration/DefaultConfiguration.php +++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php @@ -1145,6 +1145,8 @@ return [ 'warning_mode' => 0, 'passwordReset' => true, 'passwordResetForAdmins' => true, + 'requireMfa' => 0, + 'recommendedMfaProvider' => 'totp', 'lockIP' => 0, 'lockIPv6' => 0, 'sessionTimeout' => 28800, // a backend user logged in for 8 hours diff --git a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml index 9487cbd78038..c0725a47da8a 100644 --- a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml +++ b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml @@ -263,6 +263,17 @@ BE: passwordResetForAdmins: type: bool description: 'Enable password reset functionality on the backend login for TYPO3 Administrators as well. Disable this option for increased security.' + requireMfa: + type: int + allowedValues: + '0': 'Do not require multi-factor authentication' + '1': 'Require multi-factor authentication for all users' + '2': 'Require multi-factor authentication only for non-admin users' + '3': 'Require multi-factor authentication only for admin users' + description: 'Define users which should be required to set up multi-factor authentication.' + recommendedMfaProvider: + type: text + description: 'Set the identifier of the multi-factor authentication provider, recommended for all users.' lockIP: type: int allowedValues: diff --git a/typo3/sysext/core/Configuration/Services.php b/typo3/sysext/core/Configuration/Services.php index 70113fee07d8..051acc5c8693 100644 --- a/typo3/sysext/core/Configuration/Services.php +++ b/typo3/sysext/core/Configuration/Services.php @@ -19,6 +19,7 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu $containerBuilder->addCompilerPass(new DependencyInjection\SingletonPass('typo3.singleton')); $containerBuilder->addCompilerPass(new DependencyInjection\LoggerAwarePass('psr.logger_aware')); + $containerBuilder->addCompilerPass(new DependencyInjection\MfaProviderPass('mfa.provider')); $containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener')); $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware')); $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler')); diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml index 5fa0ced8ed0f..4c647d08fdd3 100644 --- a/typo3/sysext/core/Configuration/Services.yaml +++ b/typo3/sysext/core/Configuration/Services.yaml @@ -68,6 +68,31 @@ services: TYPO3\CMS\Core\Controller\FileDumpController: public: true + TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry: + public: true + + TYPO3\CMS\Core\Authentication\Mfa\Provider\TotpProvider: + tags: + - name: mfa.provider + identifier: 'totp' + title: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.title' + description: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.description' + setupInstructions: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.setupInstructions' + icon: 'content-coffee' + defaultProviderAllowed: true + before: 'recovery-codes' + + TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodesProvider: + tags: + - name: mfa.provider + identifier: 'recovery-codes' + title: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.title' + description: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.description' + setupInstructions: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.setupInstructions' + icon: 'content-text-columns' + defaultProviderAllowed: false + after: 'totp' + TYPO3\CMS\Core\Core\ClassLoadingInformation: public: false tags: diff --git a/typo3/sysext/core/Configuration/TCA/be_groups.php b/typo3/sysext/core/Configuration/TCA/be_groups.php index 5944200f6f39..896f109bfef1 100644 --- a/typo3/sysext/core/Configuration/TCA/be_groups.php +++ b/typo3/sysext/core/Configuration/TCA/be_groups.php @@ -213,6 +213,16 @@ return [ 'autoSizeMax' => 50, ] ], + 'mfa_providers' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:mfa_providers', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry::class . '->allowedProvidersItemsProcFunc', + 'size' => 5, + 'autoSizeMax' => 50, + ] + ], 'description' => [ 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.description', 'config' => [ @@ -268,7 +278,7 @@ return [ --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, title,subgroup, --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tabs.base_rights, - groupMods, tables_select, tables_modify, pagetypes_select, non_exclude_fields, explicit_allowdeny, allowed_languages, custom_options, + groupMods, mfa_providers, tables_select, tables_modify, pagetypes_select, non_exclude_fields, explicit_allowdeny, allowed_languages, custom_options, --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tabs.mounts_and_workspaces, workspace_perms, db_mountpoints, file_mountpoints, file_permissions, category_perms, --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tabs.options, diff --git a/typo3/sysext/core/Configuration/TCA/be_users.php b/typo3/sysext/core/Configuration/TCA/be_users.php index f54f542f85f5..52be0d2ca990 100644 --- a/typo3/sysext/core/Configuration/TCA/be_users.php +++ b/typo3/sysext/core/Configuration/TCA/be_users.php @@ -58,6 +58,14 @@ return [ 'autocomplete' => false, ] ], + 'mfa' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.mfa', + 'config' => [ + 'type' => 'none', + 'renderType' => 'mfaInfo', + 'eval' => 'password' // Fallback to prevent raw data being displayed in the backend + ] + ], 'usergroup' => [ 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.usergroup', 'config' => [ @@ -346,7 +354,7 @@ return [ 'types' => [ '0' => ['showitem' => ' --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, - disable, admin, username, password, avatar, usergroup, realName, email, lang, lastlogin, + disable, admin, username, password, mfa, avatar, usergroup, realName, email, lang, lastlogin, --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.tabs.rights, userMods, allowed_languages, --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.tabs.mounts_and_workspaces, @@ -361,7 +369,7 @@ return [ '], '1' => ['showitem' => ' --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, - disable, admin, username, password, avatar, usergroup, realName, email, lang, lastlogin, + disable, admin, username, password, mfa, avatar, usergroup, realName, email, lang, lastlogin, --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.tabs.options, TSconfig, db_mountpoints, options, file_mountpoints, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst new file mode 100644 index 000000000000..80a80cbcdb30 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst @@ -0,0 +1,276 @@ + +.. include:: ../../Includes.txt + +============================================= +Feature: #93526 - Multi-Factor Authentication +============================================= + +See :issue:`93526` + +Description +=========== + +TYPO3 is now capable of authentication via multiple factors, in short +"multi-factor authentication" or "MFA". This is sometimes also referred to +"2FA" as a 2-Factor Authentication process, where - in order to log in - the user +needs + +1) "something you know" (= the password) and +2) "something you own" (= an authenticator device, or an authenticator app + on mobile phones or desktop devices). + +Read more about the concepts of MFA here https://en.wikipedia.org/wiki/Multi-factor_authentication + +TYPO3 ships with some built-in MFA providers by default. But more importantly, +TYPO3 now provides an API to allow extension authors to integrate their own +MFA providers. + +The API is designed in a way to allow providers to be used for TYPO3 Backend +Authentication or Frontend Authentication with a multi-factor step in-between. + +TYPO3 Core currently provides the integration for the TYPO3 Backend, but will +fully support multi-factor authentication for the Frontend in future releases. + +Impact +====== + +Managing MFA providers is currently accessible via the User Settings module in +the new tab called "Account security", which was previously called just +"Password". The Account security tab displays the current state, if MFA can +be configured, is activated or additional providers can be configured. + +By default, MFA can be configured for every backend user. It is possible to +disable this field for editors via userTSconfig: + +.. code-block:: typoscript + + setup.fields.mfaProviders.disabled = 1 + +The MFA configuration module displays all registered providers in an overview. + +Included MFA providers +---------------------- + +TYPO3 Core includes two MFA providers: + +1. Time-based one-time password (TOTP) + +The most common MFA implementation. A QR-code is scanned (or alternatively, +a shared secret can be entered) to connect an Authenticator app such as Google +Authenticator, Microsoft Authenticator, 1Password, Authly or others to the +system and then synchronize a token, which changes every 30 seconds. + +On each log-in, after successfully entering the password, the 6-digit code +shown of the Authenticator App must be entered. + +2. Recovery codes + +This is a special provider which can only be activated if at least one other +provider is active, as it's only meant as a fallback provider, in case the +authentication credentials for the "main" provider(s) are lost. It is encouraged +to activate this provider, and keep the codes at a safe place. + +Setting up MFA for a backend user +--------------------------------- + +Each provider is displayed with its icon, the name and a short description. +In case a provider is active this is indicated by a corresponding label, next to +the providers' title. The same goes for a locked provider - an active provider, +which can currently not be used since the provider specific implementation +detected some unusual behaviour, e.g. to many false authentication attempts. +Furthermore does the configured default provider indicate this state with a +"star" icon, next to the providers title. + +Each inactive provider contains a "Setup" button which opens the corresponding +configuration view. This view can be different depending on the MFA provider. + +Each provider contains an "Edit / Change" button, which allows to adjust the +providers' setting. This view allows for example to set a provider as the +default (primary) provider, to be used on authentication. Note that the +default provider setting will be automatically applied on activation of the +first provider or in case it is the recommended provider for this user. + +In case the provider is locked, the "Edit / Change" button changes its button +title to "Unlock". This button can therefore be used to unlock the provider. +This, depending on the provider to unlock, may require further actions by the +user. + +Another view is the "Authentication view", which is displayed as soon as a user +with at least one active provider has successfully passed the username and +password mask. + +As for the other views, it is up to the specific provider, used for the current +multi-factor authentication attempt, what content is displayed in this view. +In any case, if the user has further active providers, the view displays them +as "Alternative providers" in the footer. So the user can switch between all +activated providers on every authentication attempt. + +All providers need to define a locking functionality. In case of the TOTP +and recovery code providers, this e.g. includes an attempts count. Therefore, +these providers are locked in case a wrong OTP was entered three times in a +row. The attempts count is automatically reset as soon as a correct OTP is +entered or the user unlocks the provider in the backend. + +All Core providers also feature the "Last used" and "Last updated" information +which can be retrieved in the "Edit / Change" view. + +**Administration of users' MFA providers** + +If a user is not able to access the backend anymore, e.g. because all of their +active providers are locked, MFA needs to be disabled by an administrator for +this specific user. + +Administrators are able to manage users' MFA providers in the corresponding +user record. The new `Multi-factor authentication` field displays a +list of active providers and a button to deactivate MFA for the user, or +only a specific MFA provider. + +Note that all of these deactivate buttons are executed immediately, after +confirming the appearing dialog, and can't be undone. + +The backend users listing in the backend user module also displays the current +MFA status ("enabled", "disabled" or "locked") for each user. This allows an +administrator a quick glance of the MFA usage of their users. + +Via the System => Configuration admin module, it's possible to get an overview +of all currently registered providers in the installation. This is especially +helpful to find out the exact provider identifier, needed for some +userTSconfig options. + +Configuration +------------- + +**Enforcing MFA for users** + +It seems reasonable to require MFA for specific users or user groups. This can +be achieved with :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['requireMFA']` which +allows 4 options: + +* `0`: Do not require multi-factor authentication (default) +* `1`: Require multi-factor authentication for all users +* `2`: Require multi-factor authentication only for non-admin users +* `3`: Require multi-factor authentication only for admin users + +To set this requirement only for a specific user or user group, a new +userTSconfig option `auth.mfa.required` is introduced. The userTSconfig option +overrules the global configuration. + +.. code-block:: typoscript + + auth.mfa.required = 1 + +.. note:: + + Requiring MFA has currently limited effect. Only an information is + displayed in the MFA configuration module. This will change in future + releases when this setting will require MFA being configured on accessing + the TYPO3 Backend the first time. You are still already able to try it out. + +**Allowed provider** + +It is possible to only allow a subset of the available providers for some users +or user groups. + +A new configuration option "Allowed multi-factor authentication providers" is +available in the users and user groups record in the "Access Rights/List" tab. + +Note: Defining allowed MFA providers in a user record extends the settings +already done in user groups records the user is attached to. + +There may surely be use cases in which just a single provider should be +disallowed for a specific user, which is however configured to be allowed in +one of the assigned user groups. Another use case is to disallow providers +for admin users, which simply do not have the "Access Rights/List" tab. +Therefore, the new userTSconfig option `auth.mfa.disableProviders` can be used. +It overrules the configuration from the "Access Rights/List", which means if +a provider is allowed in "Access Rights/List" but disallowed via userTSconfig, +it will be disallowed for the user or user group the TSconfig applies to. + +This does not affect the remaining allowed providers from the +"Access Rights/List". + +.. code-block:: typoscript + + auth.mfa.disableProviders := addToList(totp) + +**Recommended provider** + +To recommend a specific provider, :php:`$GLOBALS['TYPO3_CONF_VARS]['BE]['recommendedMfaProvider']` +can be used and is set to `totp` (Time-based one-time password) by default. + +To set a recommended provider on a per user or user group basis, the new +userTSconfig option `auth.mfa.recommendedProvider` can be used, which overrules +the global configuration. + +.. code-block:: typoscript + + auth.mfa.recommendedProvider = totp + +TYPO3 Integration and API +------------------------- + +.. important:: + + The MFA API is still experimental and subject to change until v11 LTS, + since we are looking forward to receive feedback, especially for custom + use-cases, the API is not capable yet. + +To register a custom MFA provider, the provider class has to implement the new +:php:`MfaProviderInterface`, shipped via a third-party extension. The provider +then has to be configured in the extensions' :file:`Services.yaml` or +:file:`Services.php` file with the :yaml:`mfa.provider` tag. + +.. code-block:: yaml + + Vender\Extension\Authentication\Mfa\MyProvider: + tags: + - name: mfa.provider + identifier: 'my-provider' + title: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.title' + description: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.description' + setupInstructions: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.setupInstructions' + icon: 'tx-extension-provider-icon' + +This will register the provider `MyProvider` with the `my-provider` identifier. +To change the position of your provider the :yaml:`before` and :yaml:`after` +arguments can be useful. This can be needed if you e.g. like your provider to +show up prior to any other provider in the MFA configuration module. The +ordering is also taken into account in the authentication step while logging +in. Note that the user defined default provider will always take precedence. + +If you dont want your provider to be selectable as a default provider, set the +:yaml:`defaultProviderAllowed` argument to `false`. + +You can also completely deactivate existing providers with: + +.. code-block:: yaml + + TYPO3\CMS\Core\Authentication\Mfa\Provider\TotpProvider: ~ + +The :php:`MfaProviderInterface` contains a lot of methods to be implemented by +the providers. This can be split up into state-providing ones, +e.g. :php:`isActive` or :php:`isLocked` and functional ones, +e.g. :php:`activate` or :php:`update`. + +Their exact task is explained in the corresponding PHPDoc of the Interface files +and the Core MFA provider implementations. + +All of these methods are recieving either the current PSR-7 Request object, the +:php:`MfaProviderPropertyManager` or both. The :php:`MfaProviderPropertyManager` +can be used to retreive and update the provider specific properties and +also contains the :php:`getUser` method, providing the current user object. + +To store provider specific data, the MFA API uses a new database field `mfa`, +which can be freely used by the providers. The field contains of a JSON encoded +Array with each provider as array key. Common properties of such provider array +could be `active` or `lastUsed`. Since the information is stored in either the +`be_users` or the `fe_users` table, the context is implicit. Same goes for the +user, the providers deal with. It's important to have such generic field so +providers are able to store arbitrary data, TYPO3 does not need to know about. + +To retrieve and update the providers data, the already mentioned +:php:`MfaProviderPropertyManager`, which is automatically passed to all +necessary provider methods, should be used. It is highly discouraged +to directly access the `mfa` database field. + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf b/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf index f16f2309e49d..7c5bf5491023 100644 --- a/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf +++ b/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf @@ -151,6 +151,9 @@ Do you want to continue WITHOUT saving?</source> <trans-unit id="labels.cancel" resname="labels.cancel"> <source>Cancel</source> </trans-unit> + <trans-unit id="labels.deactivate" resname="labels.deactivate"> + <source>Deactivate</source> + </trans-unit> <trans-unit id="labels.hidden" resname="labels.hidden"> <source>Hidden</source> </trans-unit> @@ -415,6 +418,18 @@ Do you want to continue WITHOUT saving?</source> <trans-unit id="labels.label.search_levels" resname="labels.label.search_levels"> <source>Search levels</source> </trans-unit> + <trans-unit id="labels.active" resname="labels.active"> + <source>Active</source> + </trans-unit> + <trans-unit id="labels.locked" resname="labels.locked"> + <source>Locked</source> + </trans-unit> + <trans-unit id="labels.mfa.enabled" resname="labels.mfa.enabled"> + <source>MFA enabled</source> + </trans-unit> + <trans-unit id="labels.mfa.disabled" resname="labels.mfa.disabled"> + <source>MFA disabled</source> + </trans-unit> <trans-unit id="labels.recordReadonly" resname="labels.recordReadonly"> <source>The table is defined as readonly and can't be edited.</source> </trans-unit> @@ -912,6 +927,18 @@ Do you want to refresh it now?</source> <trans-unit id="buttons.recreateSlugExplanation" resname="buttons.recreateSlugExplanation"> <source>Recalculate URL segment from page title</source> </trans-unit> + <trans-unit id="buttons.deactivateMfa" resname="buttons.deactivateMfa"> + <source>Deactivate multi-factor authentication</source> + </trans-unit> + <trans-unit id="buttons.deactivateMfaProvider" resname="buttons.deactivateMfaProvider"> + <source>Deactivate %s</source> + </trans-unit> + <trans-unit id="buttons.deactivateMfa.confirmation.text" resname="deactivateMfa.confirmation.text"> + <source>Are you sure you want to deactivate all active providers? This action can not be undone and will be applied immediately!</source> + </trans-unit> + <trans-unit id="buttons.deactivateMfaProvider.confirmation.text" resname="deactivateMfaProvider.confirmation.text"> + <source>Are you sure you want to deactivate %s? This action can not be undone and will be applied immediately!</source> + </trans-unit> <trans-unit id="slugCreation.success.page" resname="slugCreation.success.page"> <source>This page will be reachable via %s</source> </trans-unit> diff --git a/typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf b/typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf new file mode 100644 index 000000000000..1d937ac7fb9c --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf @@ -0,0 +1,157 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff"> + <file t3:id="1611879242" source-language="en" datatype="plaintext" original="EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf" date="2021-01-29T00:14:02Z" product-name="cms"> + <header/> + <body> + <trans-unit id="totp.title" resname="totp.title"> + <source>Time-based one-time password</source> + </trans-unit> + <trans-unit id="totp.description" resname="totp.description"> + <source>This provider allows to authenticate with a single-use passcode which is based on the current time. + Each code is only valid for 30 seconds. You need a OTP application supporting such tokens.</source> + </trans-unit> + <trans-unit id="totp.setupInstructions" resname="totp.setupInstructions"> + <source>The time-based one-time password provider enables you to strengthen your accounts' security by requiring a six-digit code on every login. + This provider is based on a shared secret, which will be exchanged between your OTP application (or device) and TYPO3. Each code takes the current time into account and is only valid for 30 seconds. + + Setting up: + 1. Scan the QR-code or directly enter the shared secret in your application or device + 2. Enter the generated six-digit code in the corresponding field + 3. Add a specific name for this provider (optional) + 4. Submit the form to activate the provider</source> + </trans-unit> + <trans-unit id="recoveryCodes.title" resname="recoveryCodes.title"> + <source>Recovery codes</source> + </trans-unit> + <trans-unit id="recoveryCodes.description" resname="recoveryCodes.description"> + <source>This provider allows to authenticate with a set of single-use passcodes, in case you lost your + primary MFA credentials, or just have no access to them currently.</source> + </trans-unit> + <trans-unit id="recoveryCodes.setupInstructions" resname="recoveryCodes.setupInstructions"> + <source>Recovery codes are random eight-digit codes which can be used to authenticate, in case your lost your main authentication credentials. Each code is only valid once. + As soon as all recovery codes are exhausted, you're required to generate a new set. Therefore, periodically check the amount of remaining codes in the Edit / Change view of the provider. + + Setting up: + 1. Copy the recovery codes and store them at a safe place + 2. Add a specific name for this provider (optional) + 3. Submit the form to activate the provider - see below note + + Note: Since the recovery codes are being encrypted and stored securely, this process can take some time.</source> + </trans-unit> + <trans-unit id="totpInputLabel" resname="totpInputLabel"> + <source>Enter the six-digit code</source> + </trans-unit> + <trans-unit id="totpInputHelp" resname="totpInputHelp"> + <source>This code should now be displayed on your device or in your application.</source> + </trans-unit> + <trans-unit id="locked" resname="locked"> + <source> + The maximum attempts for this provider are exceeded. Please use another provider, reset your + password or contact your administrator. + </source> + </trans-unit> + <trans-unit id="edit.table.head" resname="edit.table.head"> + <source>Provider information</source> + </trans-unit> + <trans-unit id="edit.table.name" resname="edit.table.name"> + <source>Name</source> + </trans-unit> + <trans-unit id="edit.table.nameInputPlaceholder" resname="edit.table.nameInputPlaceholder"> + <source>Enter a name</source> + </trans-unit> + <trans-unit id="edit.table.recoveryCodesLeft" resname="edit.table.recoveryCodesLeft"> + <source>Recovery codes left</source> + </trans-unit> + <trans-unit id="edit.table.lastUsed" resname="edit.table.lastUsed"> + <source>Last used</source> + </trans-unit> + <trans-unit id="edit.table.lastUsed.default" resname="edit.table.lastUsed.default"> + <source>Never</source> + </trans-unit> + <trans-unit id="edit.table.lastUpdated" resname="edit.table.lastUpdated"> + <source>Last updated</source> + </trans-unit> + <trans-unit id="edit.regenerate" resname="edit.regenerate"> + <source>Regenerate recovery codes</source> + </trans-unit> + <trans-unit id="edit.regenerate.description" resname="edit.regenerate.description"> + <source> + If you lost your recovery codes or just want to get fresh ones, you can + regenerate them by selecting the checkbox below. You've currently %d codes left. + </source> + </trans-unit> + <trans-unit id="edit.regenerate.inputLabel" resname="edit.regenerate.inputLabel"> + <source>Regenerate recovery codes</source> + </trans-unit> + <trans-unit id="setup.step.1" resname="setup.step.1"> + <source>Step 1</source> + </trans-unit> + <trans-unit id="setup.step.1a" resname="setup.step.1a"> + <source>Step 1a</source> + </trans-unit> + <trans-unit id="setup.step.1b" resname="setup.step.1b"> + <source>Step 1b</source> + </trans-unit> + <trans-unit id="setup.step.2" resname="setup.step.2"> + <source>Step 2</source> + </trans-unit> + <trans-unit id="setup.step.3" resname="setup.step.3"> + <source>Step 3</source> + </trans-unit> + <trans-unit id="setup.qrCode.label" resname="setup.qrCode.label"> + <source>Scan the displayed QR code</source> + </trans-unit> + <trans-unit id="setup.qrCode.help" resname="setup.qrCode.help"> + <source>Scan this code with your OTP application (e.g. GoogleAuthenticator).</source> + </trans-unit> + <trans-unit id="setup.secret.label" resname="setup.secret.label"> + <source>Copy the shared secret</source> + </trans-unit> + <trans-unit id="setup.secret.help" resname="setup.secret.help"> + <source>You can also enter the shared secret manually in your OTP device or application.</source> + </trans-unit> + <trans-unit id="setup.name.label" resname="setup.name.label"> + <source>Enter a name (optional)</source> + </trans-unit> + <trans-unit id="setup.name.help" resname="setup.name.help"> + <source>Specify a custom name for this provider.</source> + </trans-unit> + <trans-unit id="setup.recoveryCodes.InputLabel" resname="setup.recoveryCodes.InputLabel"> + <source>Recovery codes</source> + </trans-unit> + <trans-unit id="setup.recoveryCodes.InputHelp" resname="setup.recoveryCodes.InputHelp"> + <source>Copy these codes and store them at a safe place.</source> + </trans-unit> + <trans-unit id="setup.recoveryCodes.reloadTitle" resname="setup.recoveryCodes.reloadTitle"> + <source>Reload recovery codes</source> + </trans-unit> + <trans-unit id="setup.recoveryCodes.reloadLabel" resname="setup.recoveryCodes.reloadLabel"> + <source>Reload</source> + </trans-unit> + <trans-unit id="setup.recoveryCodes.noActiveProviders.title" resname="setup.recoveryCodes.noActiveProviders.title"> + <source>Setup not possible</source> + </trans-unit> + <trans-unit id="setup.recoveryCodes.noActiveProviders.message" resname="setup.recoveryCodes.noActiveProviders.message"> + <source> + Recovery codes are only meant as a fallback if you lose access to your main multi-factor + authentication credentials. Therefore, please active a comprehensive MFA provider first. + </source> + </trans-unit> + <trans-unit id="auth.recoveryCodes.inputLabel" resname="auth.recoveryCodes.inputLabel"> + <source>Enter an eight-digit recovery code</source> + </trans-unit> + <trans-unit id="unlock.recoveryCodes.title" resname="unlock.recoveryCodes.title"> + <source>Your recovery codes were automatically updated!</source> + </trans-unit> + <trans-unit id="unlock.recoveryCodes.message" resname="unlock.recoveryCodes.message"> + <source>Please copy and store them at a safe place: %s</source> + </trans-unit> + <trans-unit id="update.recoveryCodes.title" resname="unlock.recoveryCodes.title"> + <source>Recovery codes successfully regenerated</source> + </trans-unit> + <trans-unit id="update.recoveryCodes.message" resname="unlock.recoveryCodes.message"> + <source>Please copy and store them at a safe place: %s</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf b/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf index ec22275a23e6..89b2ac2c6040 100644 --- a/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf +++ b/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf @@ -24,6 +24,9 @@ <trans-unit id="availableWidgets" resname="userMods"> <source>Dashboard widgets</source> </trans-unit> + <trans-unit id="mfa_providers" resname="mfa_providers"> + <source>Allowed multi-factor authentication providers</source> + </trans-unit> <trans-unit id="allowed_languages" resname="allowed_languages"> <source>Limit to languages</source> </trans-unit> @@ -54,6 +57,9 @@ <trans-unit id="be_users.password" resname="be_users.password"> <source>Password</source> </trans-unit> + <trans-unit id="be_users.mfa" resname="be_users.mfa"> + <source>Multi-factor authentication</source> + </trans-unit> <trans-unit id="be_users.avatar" resname="be_users.avatar"> <source>Avatar</source> </trans-unit> diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html new file mode 100644 index 000000000000..efd05d28957e --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html @@ -0,0 +1,26 @@ +<html + xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true"> + +<f:if condition="!{isLocked}"> + <f:then> + <div class="form-field-item"> + <div class="form-control-wrap"> + <div class="form-control-holder"> + <div class="form-control-clearable form-control"> + <input type="text" id="recoveryCode" class="form-control input-login" name="rc" value="" autofocus="autofocus" required="required" maxlength="8" minlength="8" pattern="[0-9]+" aria-label="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:auth.recoveryCodes.inputLabel')}" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:auth.recoveryCodes.inputLabel')}"/> + </div> + </div> + </div> + </div> + </f:then> + <f:else> + <p> + <core:icon identifier="actions-lock"/> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:locked"/> + </p> + </f:else> +</f:if> + +</html> diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html new file mode 100644 index 000000000000..a5caee1df77d --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html @@ -0,0 +1,82 @@ +<div class="row"> + <div class="col-md-5"> + <div class="table-fit"> + <table class="table"> + <thead> + <tr> + <th colspan="2"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.head"/> + </th> + </tr> + </thead> + <tbody> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.name"/> + </td> + <td> + <f:if condition="{name}"> + <f:then> + {name} + </f:then> + <f:else> + <input type="text" id="name" name="name" value="" class="form-control" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.nameInputPlaceholder')}"/> + </f:else> + </f:if> + </td> + </tr> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.recoveryCodesLeft"/> + </td> + <td> + {amountOfCodesLeft} + </td> + </tr> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed"/> + </td> + <td> + <f:if condition="{lastUsed}"> + <f:then>{lastUsed}</f:then> + <f:else><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed.default"/></f:else> + </f:if> + </td> + </tr> + <f:if condition="{updated}"> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUpdated"/> + </td> + <td> + {updated} + </td> + </tr> + </f:if> + </tbody> + </table> + </div> + </div> +</div> + +<div class="row"> + <div class="col-md-5"> + <h3><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.regenerate"/></h3> + <p><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.regenerate.description" arguments="{0: amountOfCodesLeft}"/></p> + <f:comment> + TODO: The following should be a button which directly triggers a form submit + with the information to regenerate the recovery codes saved in some hidden field. + </f:comment> + <div class="mb-3"> + <div class="form-check"> + <input type="checkbox" name="regenerateCodes" id="regenerateCodes" class="form-check-input" value="1" /> + <label class="form-check-label" for="regenerateCodes"> + <span class="form-check-label-text"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.regenerate.inputLabel"/> + </span> + </label> + </div> + </div> + </div> +</div> diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html new file mode 100644 index 000000000000..54e4d1182524 --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html @@ -0,0 +1,37 @@ +<fieldset class="row"> + <div class="col-lg-6"> + <div class="mb-3"> + <label for="recoveryCodes" class="form-label"> + <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.1"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.InputLabel"/> + </label> + <div class=form-field-item"> + <span id="recoveryCodesHelp" class="text-muted"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.InputHelp"/> + </span> + <div class="form-control-wrap mt-1"> + <textarea type="text" id="recoveryCodes" name="recoveryCodes" class="form-control" readonly="readonly" style="min-height: 10rem;">{recoveryCodes}</textarea> + </div> + </div> + <button type="button" onclick="window.location.reload();" class="btn btn-default btn-sm" title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.reloadTitle')}"> + <core:icon identifier="actions-refresh" /> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.reloadLabel"/> + </button> + </div> + </div> + <div class="col-lg-6"> + <div class="mb-3"> + <label for="name" class="form-label"> + <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.2"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.label"/> + </label> + <div class=form-field-item"> + <span id="nameHelp" class="text-muted"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.help"/> + </span> + <div class="form-control-wrap mt-1"> + <input type="text" id="name" name="name" value="" class="form-control"/> + </div> + </div> + </div> + </div> + <input type="hidden" name="checksum" value="{checksum}"/> +</fieldset> diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html new file mode 100644 index 000000000000..887f4331069d --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html @@ -0,0 +1,26 @@ +<html + xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true"> + +<f:if condition="!{isLocked}"> + <f:then> + <div class="form-field-item"> + <div class="form-control-wrap"> + <div class="form-control-holder"> + <div class="form-control-clearable form-control"> + <input type="text" id="totp" class="form-control input-login" name="totp" value="" autofocus="autofocus" required="required" maxlength="6" minlength="6" pattern="[0-9]+" aria-label="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputLabel')}" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputLabel')}"/> + </div> + </div> + </div> + </div> + </f:then> + <f:else> + <p> + <core:icon identifier="actions-lock"/> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:locked"/> + </p> + </f:else> +</f:if> + +</html> diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html new file mode 100644 index 000000000000..ff02ad0086ca --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html @@ -0,0 +1,53 @@ +<div class="row"> + <div class="col-md-5"> + <div class="table-fit"> + <table class="table"> + <thead> + <tr> + <th colspan="2"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.head"/> + </th> + </tr> + </thead> + <tbody> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.name"/> + </td> + <td> + <f:if condition="{name}"> + <f:then> + {name} + </f:then> + <f:else> + <input type="text" id="name" name="name" value="" class="form-control" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.nameInputPlaceholder')}"/> + </f:else> + </f:if> + </td> + </tr> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed"/> + </td> + <td> + <f:if condition="{lastUsed}"> + <f:then>{lastUsed}</f:then> + <f:else><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed.default"/></f:else> + </f:if> + </td> + </tr> + <f:if condition="{updated}"> + <tr> + <td> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUpdated"/> + </td> + <td> + {updated} + </td> + </tr> + </f:if> + </tbody> + </table> + </div> + </div> +</div> diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html new file mode 100644 index 000000000000..1058abae3268 --- /dev/null +++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html @@ -0,0 +1,59 @@ +<fieldset class="row"> + <div class="col-lg-4"> + <div class="mt-3"> + <label for="qr-code" class="form-label"> + <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.1a"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.qrCode.label"/> + </label> + <div class="form-field-item"> + <span id="qrCodeHelp" class="text-muted"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.qrCode.help"/> + </span> + <div class="form-control-wrap mt-1"> + <div id="qr-code" class="border float-start">{qrCode -> f:format.raw()}</div> + </div> + </div> + </div> + </div> + <div class="col-lg-8"> + <div class="mt-3 mb-3"> + <label for="secret" class="form-label"> + <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.1b"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.secret.label"/> + </label> + <div class="form-field-item"> + <span id="secretHelp" class="text-muted"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.secret.help"/> + </span> + <div class="form-control-wrap mt-1"> + <input type="text" id="secret" name="secret" value="{secret}" readonly="readonly" class="form-control"/> + </div> + </div> + </div> + <div class="mb-3"> + <label for="name" class="form-label"> + <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.2"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.label"/> + </label> + <div class="form-field-item"> + <span id="nameHelp" class="text-muted"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.help"/> + </span> + <div class="form-control-wrap mt-1"> + <input type="text" id="name" name="name" value="" class="form-control"/> + </div> + </div> + </div> + <div class="mb-3"> + <label for="totp" class="form-label"> + <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.3"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputLabel"/> + </label> + <div class="form-field-item"> + <span id="totpHelp" class="text-muted"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputHelp"/> + </span> + <div class="form-control-wrap mt-1"> + <input type="text" id="totp" name="totp" value="" required="required" class="form-control" maxlength="6" minlength="6" pattern="[0-9]+"/> + </div> + </div> + </div> + <input type="hidden" name="checksum" value="{checksum}"/> + </div> +</fieldset> diff --git a/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php new file mode 100644 index 000000000000..5ebe09a1b943 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Authentication\Mfa\Provider; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodesProvider; +use TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Http\PropagateResponseException; +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class RecoveryCodesProviderTest extends FunctionalTestCase +{ + private BackendUserAuthentication $user; + + public function setUp(): void + { + parent::setUp(); + $this->importDataSet(ORIGINAL_ROOT . 'typo3/sysext/core/Tests/Functional/Fixtures/be_users.xml'); + $this->user = $this->setUpBackendUser(1); + $GLOBALS['LANG'] = LanguageService::createFromUserPreferences($this->user); + } + + /** + * @test + */ + public function setupFailsIfNoOtherMfaProviderIsActive(): void + { + $request = new ServerRequest('https://example.com', 'GET'); + $recoveryCodesManifest = $this->getContainer()->get(MfaProviderRegistry::class)->getProvider('recovery-codes'); + $propertyManager = MfaProviderPropertyManager::create($recoveryCodesManifest, $this->user); + $subject = $this->getContainer()->get(RecoveryCodesProvider::class); + $this->expectException(PropagateResponseException::class); + $subject->handleRequest($request, $propertyManager, 'setup'); + } + + /** + * @test + */ + public function setupReturnsHtmlWithRecoveryCodes(): void + { + $this->setupTotp(); + $request = new ServerRequest('https://example.com', 'GET'); + $recoveryCodesManifest = $this->getContainer()->get(MfaProviderRegistry::class)->getProvider('recovery-codes'); + $propertyManager = MfaProviderPropertyManager::create($recoveryCodesManifest, $this->user); + $subject = $this->getContainer()->get(RecoveryCodesProvider::class); + $response = $subject->handleRequest($request, $propertyManager, 'setup'); + self::assertStringContainsString('<textarea type="text" id="recoveryCodes"', $response->getBody()->getContents()); + } + + /** + * The user must have another MFA provider configured / activated in order to test recovery codes + */ + protected function setupTotp(): void + { + $totpProvider = $this->getContainer()->get(MfaProviderRegistry::class)->getProvider('totp'); + $propertyManager = MfaProviderPropertyManager::create($totpProvider, $this->user); + $request = new ServerRequest('https://example.com', 'POST'); + $secret = 'supersecret'; + $timestamp = $this->getContainer()->get(Context::class)->getPropertyFromAspect('date', 'timestamp'); + $totpInstance = new Totp($secret); + $totp = $totpInstance->generateTotp((int)floor($timestamp / 30)); + $request = $request->withQueryParams(['totp' => $totp]); + $request = $request->withParsedBody(['secret' => $secret, 'checksum' => GeneralUtility::hmac($secret, 'totp-setup')]); + $result = $totpProvider->activate($request, $propertyManager); + self::assertTrue($result); + } +} diff --git a/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php new file mode 100644 index 000000000000..ef4aae11b6f6 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Authentication\Mfa\Provider; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; +use TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class TotpProviderTest extends FunctionalTestCase +{ + private BackendUserAuthentication $user; + + public function setUp(): void + { + parent::setUp(); + $this->importDataSet(ORIGINAL_ROOT . 'typo3/sysext/core/Tests/Functional/Fixtures/be_users.xml'); + $this->user = $this->setUpBackendUser(1); + $GLOBALS['LANG'] = LanguageService::createFromUserPreferences($this->user); + } + + /** + * @test + */ + public function activateReturnsTrue(): void + { + $subject = GeneralUtility::makeInstance(MfaProviderRegistry::class)->getProvider('totp'); + $propertyManager = MfaProviderPropertyManager::create($subject, $this->user); + $request = new ServerRequest('https://example.com', 'POST'); + $secret = 'supersecret'; + $timestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret); + $totp = $totpInstance->generateTotp((int)floor($timestamp / 30)); + $request = $request->withQueryParams(['totp' => $totp]); + $request = $request->withParsedBody(['secret' => $secret, 'checksum' => GeneralUtility::hmac($secret, 'totp-setup')]); + self::assertTrue($subject->activate($request, $propertyManager)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php new file mode 100644 index 000000000000..ad0355fcd912 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Authentication\Mfa\Provider; + +use TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodes; +use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash; +use TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class RecoveryCodesTest extends UnitTestCase +{ + protected RecoveryCodes $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = GeneralUtility::makeInstance(RecoveryCodes::class, 'BE'); + } + + /** + * @test + */ + public function generateRecoveryCodesTest(): void + { + $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordHashing'] = [ + 'className' => Argon2iPasswordHash::class, + 'options' => [], + ]; + + $codes = $this->subject->generateRecoveryCodes(); + + self::assertCount(8, $codes); + + $plainCodes = array_keys($codes); + $hashedCodes = array_values($codes); + $hashInstance = (new Argon2iPasswordHash()); + + foreach ($hashedCodes as $key => $code) { + self::assertTrue($hashInstance->isValidSaltedPW($code)); + self::assertTrue($hashInstance->checkPassword((string)$plainCodes[$key], $code)); + } + } + + /** + * @test + */ + public function generatePlainRecoveryCodesThrowsExceptionOnInvalidLengthTest(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(1613666803); + $this->subject->generatePlainRecoveryCodes(6); + } + + /** + * @test + * @dataProvider generatePlainRecoveryCodesTestDataProvider + * + * @param int $length + * @param int $quantity + */ + public function generatePlainRecoveryCodesTest(int $length, int $quantity): void + { + $recoveryCodes = $this->subject->generatePlainRecoveryCodes($length, $quantity); + self::assertCount($quantity, $recoveryCodes); + foreach ($recoveryCodes as $code) { + self::assertIsNumeric($code); + self::assertEquals($length, strlen($code)); + } + } + + public function generatePlainRecoveryCodesTestDataProvider(): \Generator + { + yield 'Default 8 codes with 8 chars' => [8, 8]; + yield '8 codes with 10 chars' => [8, 10]; + yield '10 codes with 8 chars' => [10, 8]; + yield '0 codes with 8 chars' => [8, 0]; + yield '10 codes with 10 chars' => [10, 10]; + } + + /** + * @test + */ + public function generatedHashedRecoveryCodesAreHashedWithDefaultHashInstanceTest(): void + { + $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordHashing'] = [ + 'className' => BcryptPasswordHash::class, + 'options' => [], + ]; + + $codes = $this->subject->generatedHashedRecoveryCodes(['12345678', '87654321']); + + self::assertTrue((new BcryptPasswordHash())->isValidSaltedPW(reset($codes))); + self::assertCount(2, $codes); + } + + /** + * @test + */ + public function verifyRecoveryCodeTest(): void + { + $recoveryCode = '18742989'; + $codes = []; + + // False on empty codes + self::assertFalse($this->subject->verifyRecoveryCode($recoveryCode, $codes)); + + $codes = $this->subject->generatedHashedRecoveryCodes( + array_merge([$recoveryCode], $this->subject->generatePlainRecoveryCodes(8, 2)) + ); + + // Recovery code can be verified + self::assertTrue($this->subject->verifyRecoveryCode($recoveryCode, $codes)); + // Verified code is removed from available codes + self::assertCount(2, $codes); + // Recovery code can not be verified again + self::assertFalse($this->subject->verifyRecoveryCode($recoveryCode, $codes)); + } + + /** + * @test + */ + public function verifyRecoveryCodeUsesTheCorrectHashInstanceTest(): void + { + $code = '18742989'; + $codes = [(new Argon2iPasswordHash())->getHashedPassword($code)]; + + // Ensure we have another default hash instance + $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordHashing'] = [ + 'className' => BcryptPasswordHash::class, + 'options' => [], + ]; + + self::assertTrue($this->subject->verifyRecoveryCode($code, $codes)); + self::assertEmpty($codes); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php new file mode 100644 index 000000000000..8e8422cc01c2 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php @@ -0,0 +1,199 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Authentication\Mfa\Provider; + +use Base32\Base32; +use TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\DateTimeAspect; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class TotpTest extends UnitTestCase +{ + protected string $secret; + protected int $timestamp = 1613652061; + protected $resetSingletonInstances = true; + + protected function setUp(): void + { + parent::setUp(); + // We generate the secret here to ensure TOTP works with a secret encoded by the 3rd party package + $this->secret = Base32::encode('TYPO3IsAwesome!'); // KRMVATZTJFZUC53FONXW2ZJB + } + + /** + * @test + */ + public function throwsExceptionOnDisallowedAlogTest(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(1611748791); + GeneralUtility::makeInstance(Totp::class, 'some-secret', 'md5'); + } + + /** + * @test + */ + public function throwsExceptionOnInvalidTotpLengthTest(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(1611748792); + GeneralUtility::makeInstance(Totp::class, 'some-secret', 'sha1', 4); + } + + /** + * @test + * @dataProvider totpDataProvider + * + * @param string $expectedTotp + * @param array $arguments + */ + public function generateTotpTest(string $expectedTotp, array $arguments): void + { + $counter = (int)floor(($this->timestamp - 0) / 30); // see Totp::getTimeCounter() + + self::assertEquals( + $expectedTotp, + GeneralUtility::makeInstance(Totp::class, $this->secret, ...$arguments)->generateTotp($counter) + ); + } + + /** + * @test + * @dataProvider totpDataProvider + * + * @param string $totp + * @param array $arguments + */ + public function verifyTotpTest(string $totp, array $arguments): void + { + GeneralUtility::makeInstance(Context::class) + ->setAspect('date', new DateTimeAspect(new \DateTimeImmutable('@' . $this->timestamp))); + + self::assertTrue( + GeneralUtility::makeInstance(Totp::class, $this->secret, ...$arguments)->verifyTotp($totp) + ); + } + + public function totpDataProvider(): \Generator + { + yield 'Default' => ['337475', []]; + yield 'sha256 algo' => ['874487', ['sha256']]; + yield 'sha512 algo' => ['497852', ['sha512']]; + yield '7 digit code' => ['8337475', ['sha1', 7]]; + yield '8 digit code' => ['48337475', ['sha1', 8]]; + } + + /** + * @test + */ + public function verifyTotpWithGracePeriodTest(): void + { + GeneralUtility::makeInstance(Context::class) + ->setAspect('date', new DateTimeAspect(new \DateTimeImmutable('@' . $this->timestamp))); + + $totpInstance = GeneralUtility::makeInstance(Totp::class, $this->secret); + + $totpFuture = $totpInstance->generateTotp((int)floor((($this->timestamp + 90) - 0) / 30)); + self::assertFalse($totpInstance->verifyTotp($totpFuture, 3)); + + $totpFuture = $totpInstance->generateTotp((int)floor((($this->timestamp + 60) - 0) / 30)); + self::assertTrue($totpInstance->verifyTotp($totpFuture, 3)); + + $totpFuture = $totpInstance->generateTotp((int)floor((($this->timestamp + 30) - 0) / 30)); + self::assertTrue($totpInstance->verifyTotp($totpFuture, 3)); + + $totpPast = $totpInstance->generateTotp((int)floor((($this->timestamp - 30) - 0) / 30)); + self::assertTrue($totpInstance->verifyTotp($totpPast, 3)); + + $totpPast = $totpInstance->generateTotp((int)floor((($this->timestamp - 60) - 0) / 30)); + self::assertTrue($totpInstance->verifyTotp($totpPast, 3)); + + $totpPast = $totpInstance->generateTotp((int)floor((($this->timestamp - 90) - 0) / 30)); + self::assertFalse($totpInstance->verifyTotp($totpPast, 3)); + } + + /** + * @test + * @dataProvider getTotpAuthUrlTestDataProvider + * + * @param array $constructorArguments + * @param array $methodArguments + * @param string $expected + */ + public function getTotpAuthUrlTest(array $constructorArguments, array $methodArguments, string $expected): void + { + $totp = GeneralUtility::makeInstance(Totp::class, ...$constructorArguments); + + self::assertEquals($expected, $totp->getTotpAuthUrl(...$methodArguments)); + } + + public function getTotpAuthUrlTestDataProvider(): \Generator + { + yield 'Default Totp with account and additional params' => [ + [ + 'N5WGS4ZNOR4XA3ZTFVZWS5DF', + ], + [ + 'Oli`s awesome site`', + 'user@typo3.org', + [ + 'foo' => 'bar', + 'bar' => [ + 'baz' => 123 + ] + ] + ], + 'otpauth://totp/Oli%60s%20awesome%20site%60%3Auser%40typo3.org?secret=N5WGS4ZNOR4XA3ZTFVZWS5DF&issuer=Oli%60s%20awesome%20site%60&foo=bar&bar%5Bbaz%5D=123' + ]; + yield 'Custom Totp settings with account without additional params' => [ + [ + 'N5WGS4ZNOR4XA3ZTFVZWS5DF', + 'sha256', + 8, + 20, + 12345 + ], + [ + 'Some other site', + 'user@typo3.org', + ], + 'otpauth://totp/Some%20other%20site%3Auser%40typo3.org?secret=N5WGS4ZNOR4XA3ZTFVZWS5DF&issuer=Some%20other%20site&algorithm=sha256&period=20&digits=8&epoch=12345' + ]; + } + + /** + * @test + */ + public function generateEncodedSecretTest(): void + { + // Check 100 times WITHOUT additional auth factors + for ($i=0; $i<100; $i++) { + // Assert correct length and secret only contains allowed alphabet + self::assertRegExp('/^[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{32}$/', Totp::generateEncodedSecret()); + } + + // Check 100 times WITH additional auth factors + for ($i=0; $i<100; $i++) { + $authFactors = ['uid' => 5, 'username' => 'non.admin']; + // Assert correct length and secret only contains allowed alphabet + self::assertRegExp('/^[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{32}$/', Totp::generateEncodedSecret($authFactors)); + } + } +} diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json index 77e67c6590d3..ae5f12cb3d89 100644 --- a/typo3/sysext/core/composer.json +++ b/typo3/sysext/core/composer.json @@ -26,6 +26,8 @@ "ext-pcre": "*", "ext-session": "*", "ext-xml": "*", + "bacon/bacon-qr-code": "^2.0", + "christian-riesen/base32": "^1.5", "cogpowered/finediff": "~0.3.1", "doctrine/annotations": "^1.11", "doctrine/dbal": "^2.12", diff --git a/typo3/sysext/core/ext_tables.sql b/typo3/sysext/core/ext_tables.sql index 208281a5a34d..d297ca2d1063 100644 --- a/typo3/sysext/core/ext_tables.sql +++ b/typo3/sysext/core/ext_tables.sql @@ -13,6 +13,7 @@ CREATE TABLE be_groups ( tables_modify text, groupMods text, availableWidgets text, + mfa_providers text, file_mountpoints text, file_permissions text, TSconfig text, @@ -58,6 +59,7 @@ CREATE TABLE be_users ( lastlogin int(10) unsigned DEFAULT '0' NOT NULL, workspace_id int(11) DEFAULT '0' NOT NULL, category_perms text, + mfa mediumblob, KEY username (username) ); diff --git a/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php b/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php index 1f6e4f8d15f5..6a601f2c0f7c 100644 --- a/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php +++ b/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php @@ -22,6 +22,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use TYPO3\CMS\Backend\FrontendBackendUserAuthentication; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Localization\LanguageService; @@ -83,7 +84,13 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut { // New backend user object $backendUserObject = GeneralUtility::makeInstance(FrontendBackendUserAuthentication::class); - $backendUserObject->start(); + try { + $backendUserObject->start(); + } catch (MfaRequiredException $e) { + // Do nothing, as the user is not fully authenticated - has not + // passed required multi-factor authentication - via the backend. + return null; + } $backendUserObject->unpack_uc(); if (!empty($backendUserObject->user['uid'])) { $this->setBackendUserAspect($backendUserObject, (int)$backendUserObject->user['workspace_id']); diff --git a/typo3/sysext/frontend/ext_tables.sql b/typo3/sysext/frontend/ext_tables.sql index 972466189f45..9ef075050edf 100644 --- a/typo3/sysext/frontend/ext_tables.sql +++ b/typo3/sysext/frontend/ext_tables.sql @@ -62,6 +62,7 @@ CREATE TABLE fe_users ( TSconfig text, lastlogin int(10) unsigned DEFAULT '0' NOT NULL, is_online int(10) unsigned DEFAULT '0' NOT NULL, + mfa mediumblob, KEY parent (pid,username(100)), KEY username (username(100)), diff --git a/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php b/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php new file mode 100644 index 000000000000..799b56ed40b4 --- /dev/null +++ b/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Lowlevel\ConfigurationModuleProvider; + +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; + +class MfaProvidersProvider extends AbstractProvider +{ + protected MfaProviderRegistry $mfaProviderRegistry; + + public function __construct(MfaProviderRegistry $mfaProviderRegistry) + { + $this->mfaProviderRegistry = $mfaProviderRegistry; + } + + public function getConfiguration(): array + { + $providers = $this->mfaProviderRegistry->getProviders(); + $configuration = []; + foreach ($providers as $identifier => $provider) { + $configuration[$identifier] = [ + 'title' => $this->getLanguageService()->sL($provider->getTitle()), + 'description' => $this->getLanguageService()->sL($provider->getDescription()), + 'isDefaultAllowed' => $provider->isDefaultProviderAllowed() + ]; + } + return $configuration; + } +} diff --git a/typo3/sysext/lowlevel/Configuration/Services.yaml b/typo3/sysext/lowlevel/Configuration/Services.yaml index 412eb4fcb049..bf1c37a1d9d8 100644 --- a/typo3/sysext/lowlevel/Configuration/Services.yaml +++ b/typo3/sysext/lowlevel/Configuration/Services.yaml @@ -199,3 +199,11 @@ services: identifier: 'eventListeners' label: 'LLL:EXT:lowlevel/Resources/Private/Language/locallang.xlf:eventListeners' after: 'siteConfiguration' + + lowlevel.configuration.module.provider.mfaproviders: + class: 'TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\MfaProvidersProvider' + tags: + - name: 'lowlevel.configuration.module.provider' + identifier: 'mfaProviders' + label: 'LLL:EXT:lowlevel/Resources/Private/Language/locallang.xlf:mfaProviders' + after: 'eventListeners' diff --git a/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf b/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf index 5eeaf247519d..442fb52eb8d6 100644 --- a/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf @@ -39,6 +39,9 @@ <trans-unit id="eventListeners" resname="eventListeners"> <source>Event Listeners (PSR-14)</source> </trans-unit> + <trans-unit id="mfaProviders" resname="mfaProviders"> + <source>MFA providers</source> + </trans-unit> <trans-unit id="formYamlConfiguration" resname="formYamlConfiguration"> <source>Form: YAML Configuration</source> </trans-unit> diff --git a/typo3/sysext/setup/Classes/Controller/SetupModuleController.php b/typo3/sysext/setup/Classes/Controller/SetupModuleController.php index 8ab67c2453bb..f26bf18a0201 100644 --- a/typo3/sysext/setup/Classes/Controller/SetupModuleController.php +++ b/typo3/sysext/setup/Classes/Controller/SetupModuleController.php @@ -24,6 +24,7 @@ use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Backend\Template\ModuleTemplate; use TYPO3\CMS\Backend\Utility\BackendUtility; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException; use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; @@ -151,14 +152,17 @@ class SetupModuleController */ protected $eventDispatcher; + protected MfaProviderRegistry $mfaProviderRegistry; + /** * Instantiate the form protection before a simulated user is initialized. * * @param EventDispatcherInterface $eventDispatcher */ - public function __construct(EventDispatcherInterface $eventDispatcher) + public function __construct(EventDispatcherInterface $eventDispatcher, MfaProviderRegistry $mfaProviderRegistry) { $this->eventDispatcher = $eventDispatcher; + $this->mfaProviderRegistry = $mfaProviderRegistry; $this->moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class); $this->formProtection = FormProtectionFactory::get(); $pageRenderer = $this->moduleTemplate->getPageRenderer(); @@ -656,6 +660,27 @@ class SetupModuleController . '>' . $iconFactory->getIcon('actions-insert-record', Icon::SIZE_SMALL) . '</button></div>'; break; + case 'mfa': + $html = ''; + $lang = $this->getLanguageService(); + $hasActiveProviders = $this->mfaProviderRegistry->hasActiveProviders($backendUser); + if ($hasActiveProviders) { + if ($this->mfaProviderRegistry->hasLockedProviders($backendUser)) { + $html .= ' <span class="badge badge-danger">' . htmlspecialchars($lang->getLL('mfaProviders.lockedMfaProviders')) . '</span>'; + } else { + $html .= ' <span class="badge badge-success">' . htmlspecialchars($lang->getLL('mfaProviders.enabled')) . '</span>'; + } + } + $html .= '<p class="text-muted">' . nl2br(htmlspecialchars($lang->getLL('mfaProviders.description'))) . '</p>'; + if (!$this->mfaProviderRegistry->hasProviders()) { + $html .= '<span class="badge badge-danger">' . htmlspecialchars($lang->getLL('mfaProviders.notAvailable')) . '</span>'; + break; + } + $html .= '<a href="' . htmlspecialchars((string)$uriBuilder->buildUriFromRoute('mfa')) . '" class="btn btn-' . ($hasActiveProviders ? 'default' : 'success') . '">'; + $html .= GeneralUtility::makeInstance(IconFactory::class)->getIcon($hasActiveProviders ? 'actions-cog' : 'actions-add', Icon::SIZE_SMALL); + $html .= ' <span>' . htmlspecialchars($lang->getLL('mfaProviders.' . ($hasActiveProviders ? 'manageLinkTitle' : 'setupLinkTitle'))) . '</span>'; + $html .= '</a>'; + break; default: $html = ''; } diff --git a/typo3/sysext/setup/Resources/Private/Language/locallang.xlf b/typo3/sysext/setup/Resources/Private/Language/locallang.xlf index f33d2cb2d2a5..a1f9e117c6d3 100644 --- a/typo3/sysext/setup/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/setup/Resources/Private/Language/locallang.xlf @@ -318,8 +318,29 @@ <trans-unit id="passwordCurrent" resname="passwordCurrent"> <source>Current password</source> </trans-unit> - <trans-unit id="passwordHeader" resname="passwordHeader"> - <source>Password</source> + <trans-unit id="accountSecurity" resname="accountSecurity"> + <source>Account security</source> + </trans-unit> + <trans-unit id="mfaProviders" resname="mfaProviders"> + <source>Multi-factor authentication providers</source> + </trans-unit> + <trans-unit id="mfaProviders.notAvailable" resname="mfaProviders.notAvailable"> + <source>Currently no multi-factor authentication providers are available.</source> + </trans-unit> + <trans-unit id="mfaProviders.description" resname="mfaProviders.description"> + <source>Use multi-factor authentication to secure your account by providing another factor next to your password.</source> + </trans-unit> + <trans-unit id="mfaProviders.lockedMfaProviders" resname="mfaProviders.lockedMfaProviders"> + <source>Some providers are currently locked!</source> + </trans-unit> + <trans-unit id="mfaProviders.enabled" resname="mfaProviders.enabled"> + <source>Enabled</source> + </trans-unit> + <trans-unit id="mfaProviders.setupLinkTitle" resname="mfaProviders.setupLinkTitle"> + <source>Setup multi-factor authentication</source> + </trans-unit> + <trans-unit id="mfaProviders.manageLinkTitle" resname="mfaProviders.manageLinkTitle"> + <source>Manage your MFA providers</source> </trans-unit> <trans-unit id="setupWasUpdated" resname="setupWasUpdated"> <source>User settings were updated.</source> diff --git a/typo3/sysext/setup/ext_tables.php b/typo3/sysext/setup/ext_tables.php index 028983e59802..0edd5ceb0e31 100644 --- a/typo3/sysext/setup/ext_tables.php +++ b/typo3/sysext/setup/ext_tables.php @@ -121,9 +121,13 @@ $GLOBALS['TYPO3_USER_SETTINGS'] = [ 'label' => 'LLL:EXT:setup/Resources/Private/Language/locallang.xlf:flexibleTextareas_MaxHeight', 'csh' => 'flexibleTextareas_MaxHeight' ], + 'mfaProviders' => [ + 'type' => 'mfa', + 'label' => 'LLL:EXT:setup/Resources/Private/Language/locallang.xlf:mfaProviders', + ] ], 'showitem' => '--div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:personal_data,realName,email,emailMeAtLogin,avatar,lang, - --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:passwordHeader,passwordCurrent,password,password2, + --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:accountSecurity,passwordCurrent,password,password2,mfaProviders, --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:opening,startModule, --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:editFunctionsTab,edit_RTE,resizeTextareas_MaxHeight,titleLen,edit_docModuleUpload,showHiddenFilesAndFolders,copyLevels,resetConfiguration' ]; -- GitLab