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 0000000000000000000000000000000000000000..1dc62390ffc598a259103dc92a0765e37b266595 --- /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 7901567a016798cf045da9f60c552a8ababb3891..b7b40bd16611b9ee84386496911c44f020673032 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 8471a49a26cda236e5f94caba9c910e43a71fc64..3f791a9740c71e0966519e09b0715da37a2ea5e9 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 0988f524d56008b33b1a66f7e093acd27a88daa6..ecdaeff89dacc3ba9ee4785b7502bd428fa8f223 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 0000000000000000000000000000000000000000..120f0876a19f5ede6ca24a255d31c2384928ebe4 --- /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 c2e3ceccf090b46cf8f1dc601f25f7da37038ab4..12e59c03eacad16ae23d038103a5779e0b9b1086 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 0000000000000000000000000000000000000000..9466a4afcf2b001a88dbddb7803627c9ff0c3c0a --- /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 0000000000000000000000000000000000000000..dc688f1b16d2741cff39b56ad525e2b0c03eef55 --- /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 0000000000000000000000000000000000000000..2256dfdb4199f189da38289f5bb00cb47a05449e --- /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 0000000000000000000000000000000000000000..d4e931b266d1b1ec822024c9c6fa85321cda01e7 --- /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 e4bf7a72a76c0afcda61cc64e42aeb662112918d..4ca65ab47f60e026bb2a176b45fef4f9c19f78c1 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 2ca5bd0e778cbb321214fa3b6950749982a61246..c204b1be20fc870a39db61cee10d484a73613118 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 0000000000000000000000000000000000000000..c0d02e72780b2be175c1ae99ddf6c8a29306181e --- /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 83e309256556b1001159028561ef9110f9320e75..8667641d1bf4a4e4ae98bf520b624a18474084ce 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 f4ee172ed72ef1247e0c1e36cd6f29f528cef7ed..fef158bda195d91a2a87d19d15c74d8874e61d8f 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 66711bbca1f9d751207b7f8f6ce7b73fe4d5f6f5..e62462705e1fcebf3f1130de90cc514d6f8e843c 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 0000000000000000000000000000000000000000..489d3862d10a4d5dd916ee793f880753b019c0f4 --- /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 0000000000000000000000000000000000000000..3ea9301ba2dfd7e50aca68fd03d946dc7673d5e8 --- /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 0000000000000000000000000000000000000000..74916e77c400fbeea9883541324f36ff2c08e47d --- /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 0000000000000000000000000000000000000000..18064d1d9be2554cb92f1be330f75c6dfeb12b0a --- /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 0000000000000000000000000000000000000000..400325af708ead73a4066433a4c73217eb3741a2 --- /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 0000000000000000000000000000000000000000..b4b082631bcf37c8ea030a36a1566e19a5903ad7 --- /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 a22c3113d2d5f6ba6bf4e04ee3567cabe07eaba8..15efb8e47002411c66cc88c380e79861e9d96123 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 0000000000000000000000000000000000000000..2a0af6a5d3aabd64b7b8e882e8a371c5a71533a3 --- /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 5ae57abd9298d946f975436a25b5d367939b565a..046fa74f5a849a4c8052b7a7327fa7b302766868 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 0b5a2e8219595f3a3f1906fdd355c336c391d09c..9ac59bf0eda82d691fef13eebd354db906902a9e 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 01d52640985764aa0fb98c466d0d7c9dcff3e11c..11e7c7ee69bbcc7e0eb2a4133d706c87fc0f67e0 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 bb6c779313d058a5abd4ef4f21ac40106edc0ab5..b15b39309af20b73c477990fe683f84c5d32275c 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 9328def9ae41c2fab862269185457c4915ca7332..12823c24681209696e26c70e9b97ef31b021f9d6 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 0000000000000000000000000000000000000000..4bf5ca4bd8f8819154020ad00c9c0bec2f0a2abe --- /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 0000000000000000000000000000000000000000..d4f14644c4b99f6b3a43bd632048ba0025a0861c --- /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 0000000000000000000000000000000000000000..075b60acacfa0a10bded6655b561fb4421fcfcec --- /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 0000000000000000000000000000000000000000..b2846def9dc730b2d89428329e15508230e7a34e --- /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 0000000000000000000000000000000000000000..b5ef3c7ca82a5ea56183bdb330775fb52ad96895 --- /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 0000000000000000000000000000000000000000..b169bd58b4ff2815d5e755cf0697a1155ce828c8 --- /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 0000000000000000000000000000000000000000..6c6871306068fafba197d4d51c57e1b878ec2b6e --- /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 0000000000000000000000000000000000000000..3916c23b448aa98cb17b4c15dd6cf42462da0670 --- /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 0000000000000000000000000000000000000000..842b0765eb2b863efa5c3cde1d859c96f4e3e620 --- /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 0000000000000000000000000000000000000000..48c67085a2222f59a121a8773f19834cd539b721 --- /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 0000000000000000000000000000000000000000..0d7f036eddaeb0eac5eccee696a8866bd6076f76 --- /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 0000000000000000000000000000000000000000..2467a4900e80d674669f0d3e60fff9af7c935b10 --- /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 44e066597101008fffbf2f0a5499dec6f64b9576..ae175d9459490aa5b2153c582e707a55f7a493be 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 9487cbd78038bd3e9f5e2e84a7783a4dbfa49a8b..c0725a47da8ab438131b4705810fbcbe3b11c612 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 70113fee07d853787ef1ed579113a34fa6acb67a..051acc5c8693fff4ff5be6459fe555c99a1a0223 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 5fa0ced8ed0fd330f3c038b079867e2e76d641d3..4c647d08fdd3b2b1bd7da48b6cdc50b0fdee5d53 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 5944200f6f39229728412eb644dcc462adf8151b..896f109bfef17e42a3219579cbbb6ff8e14d2fed 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 f54f542f85f5b1f52a4af02a0de4e81509d103bd..52be0d2ca9906469271920be8a0729f6bac495ef 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 0000000000000000000000000000000000000000..80a80cbcdb30c2271061c58ce0d4611bf2acc744 --- /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 f16f2309e49d95056d5b7ee3b49a5b9817fe40b9..7c5bf5491023cec1c9f321be5dbce7d92ce08114 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 0000000000000000000000000000000000000000..1d937ac7fb9c5a6fd793cf7b7a21c9a2bdc5f047 --- /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 ec22275a23e6a85dab8445f63c873d0bc243828a..89b2ac2c6040d7929aad308b9f4af80a61b79adb 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 0000000000000000000000000000000000000000..efd05d28957e9ae303cc767d2c9dfb6bb6590d0e --- /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 0000000000000000000000000000000000000000..a5caee1df77d6d0de9ea308f1cf1966b4343d21f --- /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 0000000000000000000000000000000000000000..54e4d11825246382aa62da4624a27ad861f8445c --- /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 0000000000000000000000000000000000000000..887f4331069de8df81eb00262afc0bdda0fddca9 --- /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 0000000000000000000000000000000000000000..ff02ad0086ca0925db9396c6d934bf7822800858 --- /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 0000000000000000000000000000000000000000..1058abae3268d2a106b0ae7fa85f2ff0b901dbd9 --- /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 0000000000000000000000000000000000000000..5ebe09a1b943fb56a4dd6b61b9bb3a0601cf5f2e --- /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 0000000000000000000000000000000000000000..ef4aae11b6f687693048cb7fa52dbc54198573b3 --- /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 0000000000000000000000000000000000000000..ad0355fcd912fddbcbb4d2310fb64e5d44f64f72 --- /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 0000000000000000000000000000000000000000..8e8422cc01c2520ddde312155bdacf5fa46a9506 --- /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 77e67c6590d3641ea327c49c2dad18f51a87ade4..ae5f12cb3d89bce0bded420f5cf9edfdc329aa27 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 208281a5a34d8933b1223ee2cc2db9d7df1252ce..d297ca2d10631d56ee5c5dd9ffe6787133ae1475 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 1f6e4f8d15f55d8f901c9b31cbfc7f6126154b8b..6a601f2c0f7cac19aaf37292b6df4d7408a2c018 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 972466189f452f57ecc6db6ee5fcad84d652ec04..9ef075050edf576ea93f40469a66762c677627a6 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 0000000000000000000000000000000000000000..799b56ed40b4c435de4bd49c553399dd19d7a346 --- /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 412eb4fcb04939fe77a69693e4bca0d15e35e6c5..bf1c37a1d9d8f8e283d4a4fd6512bab02a92fff9 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 5eeaf247519de8965376c032fc6e194b9694da97..442fb52eb8d6ded7b36283096d86e16b0b992617 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 8ab67c2453bb56a4c82e211d53934cf9101c761f..f26bf18a0201d6834ee14c224423c3f9864e193b 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 f33d2cb2d2a56d44569590ba8858d74265373cb5..a1f9e117c6d3b85a9e523d02fe7aaa707abb7830 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 028983e59802158788c2a899e094849bb873e281..0edd5ceb0e313f8e1beb5927df887b975805c9fc 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' ];