Commit 39145a46 authored by Oliver Bartsch's avatar Oliver Bartsch Committed by Benni Mack
Browse files

[FEATURE] Introduce MFA in Core

A new API is introduced, providing multi-factor
authentication for the Core. The API is furthermore
directly used to add two MFA providers by default:

* TOTP (time-based one-time passwords)
* Recovery codes

Even if the API is designed to allow MFA in both,
backend and frontend, it is currently only implemented
into the backend. Users can therefore configure their
available MFA providers in a new backend module,
accessible via their user settings.

There are also some configuration options for
administrators to e.g. define a recommended provider
or to disallow available providers for specific users
or user groups.

Administration of the users' MFA providers is possible
for administrators in the corresponding user records.

New providers can be introduced by implementing the
MfaProviderInterface and tagging the service with the
`mfa.provider` tag.

Note that the API is currently marked as internal since
changes in upcoming patches are to be expected.

Following dependencies are introduced:

* bacon/bacon-qr-code "^2.0"
* christian-riesen/base32 "^1.5"

Possible features that could follow later-on:

* MFA frontend integration
* Webauthn core provider for FIDO2 and U2F.
* Forcing users to set up MFA on login
* Password-recovery with active MFA

Resolves: #93526
Releases: master
Change-Id: I4e902be624c80295c9c0c3286c90a6a680feeb5d
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67548

Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent 25a9262a
/*
* 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;
......@@ -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});
......
......@@ -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",
......
......@@ -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",
......
<?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'];
}
}
......@@ -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;
......
<?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));