TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<?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.
|
||||
*/
|
||||
interface MfaProviderInterface
|
||||
{
|
||||
/**
|
||||
* Check if the current request can be handled by this provider (e.g.
|
||||
* necessary query arguments are set).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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!
|
||||
*/
|
||||
public function isLocked(MfaProviderPropertyManager $propertyManager): bool;
|
||||
|
||||
/**
|
||||
* Verifies the MFA request
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @see MfaViewType
|
||||
*/
|
||||
public function handleRequest(
|
||||
ServerRequestInterface $request,
|
||||
MfaProviderPropertyManager $propertyManager,
|
||||
MfaViewType $type
|
||||
): ResponseInterface;
|
||||
|
||||
/**
|
||||
* Activate / register this provider for the user
|
||||
*
|
||||
* @return bool TRUE in case operation was successful, FALSE otherwise
|
||||
*/
|
||||
public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
|
||||
|
||||
/**
|
||||
* Deactivate this provider for the user
|
||||
*
|
||||
* @return bool TRUE in case operation was successful, FALSE otherwise
|
||||
*/
|
||||
public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
|
||||
|
||||
/**
|
||||
* Unlock this provider for the user
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* @return bool TRUE in case operation was successful, FALSE otherwise
|
||||
*/
|
||||
public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?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 should only be used by the TYPO3 Core
|
||||
*/
|
||||
final class MfaProviderManifest implements MfaProviderManifestInterface
|
||||
{
|
||||
private ?MfaProviderInterface $instance = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $identifier,
|
||||
private readonly string $title,
|
||||
private readonly string $description,
|
||||
private readonly string $setupInstructions,
|
||||
private readonly string $iconIdentifier,
|
||||
private readonly bool $isDefaultProviderAllowed,
|
||||
private readonly string $serviceName,
|
||||
private readonly ContainerInterface $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,
|
||||
MfaViewType $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Annotated information about the MFA provider – used in various views
|
||||
*
|
||||
* @internal should only be used by the TYPO3 Core
|
||||
*/
|
||||
interface MfaProviderManifestInterface extends MfaProviderInterface
|
||||
{
|
||||
/**
|
||||
* Unique provider identifier
|
||||
*/
|
||||
public function getIdentifier(): string;
|
||||
|
||||
/**
|
||||
* The title of the provider
|
||||
*/
|
||||
public function getTitle(): string;
|
||||
|
||||
/**
|
||||
* A short description about the provider
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
|
||||
/**
|
||||
* Instructions to be displayed in the setup view
|
||||
*/
|
||||
public function getSetupInstructions(): string;
|
||||
|
||||
/**
|
||||
* The icon identifier for this provider
|
||||
*/
|
||||
public function getIconIdentifier(): string;
|
||||
|
||||
/**
|
||||
* Whether the provider is allowed to be set as default
|
||||
*/
|
||||
public function isDefaultProviderAllowed(): bool;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?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.
|
||||
*/
|
||||
class MfaProviderPropertyManager implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
protected const DATABASE_FIELD_NAME = 'mfa';
|
||||
|
||||
protected array $mfa;
|
||||
protected array $providerProperties;
|
||||
|
||||
public function __construct(protected readonly AbstractUserAuthentication $user, protected readonly string $providerIdentifier)
|
||||
{
|
||||
$this->mfa = json_decode($user->user[self::DATABASE_FIELD_NAME] ?? '', true) ?? [];
|
||||
$this->providerProperties = $this->mfa[$this->providerIdentifier] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider entry exists for the current user
|
||||
*/
|
||||
public function hasProviderEntry(): bool
|
||||
{
|
||||
return isset($this->mfa[$this->providerIdentifier]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider property exists
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
public function getProperty(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->providerProperties[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider specific properties
|
||||
*/
|
||||
public function getProperties(): array
|
||||
{
|
||||
return $this->providerProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the provider properties
|
||||
* Note: If no entry exists yet, use createProviderEntry() instead.
|
||||
* This can be checked with hasProviderEntry().
|
||||
*/
|
||||
public function updateProperties(array $properties): bool
|
||||
{
|
||||
// This is to prevent provider data inconsistency
|
||||
if (!$this->hasProviderEntry()) {
|
||||
throw new \InvalidArgumentException(
|
||||
'No entry for provider ' . $this->providerIdentifier . ' exists yet. Use createProviderEntry() instead.',
|
||||
1613993188
|
||||
);
|
||||
}
|
||||
|
||||
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 an entry already exists, use updateProperties() instead.
|
||||
* This can be checked with hasProviderEntry().
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* @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->getUserId(),
|
||||
'username' => $this->user->getUserName(),
|
||||
],
|
||||
]);
|
||||
|
||||
// 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->getUserId()],
|
||||
[self::DATABASE_FIELD_NAME => Connection::PARAM_LOB]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current user
|
||||
*/
|
||||
public function getUser(): AbstractUserAuthentication
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current providers identifier
|
||||
*/
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->providerIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create property manager for the user with the given provider
|
||||
*/
|
||||
public static function create(MfaProviderManifestInterface $provider, AbstractUserAuthentication $user): self
|
||||
{
|
||||
return GeneralUtility::makeInstance(self::class, $user, $provider->getIdentifier());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?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 Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
|
||||
|
||||
/**
|
||||
* Registry for configuration providers which is called by the ConfigurationProviderPass
|
||||
*
|
||||
* @internal should only be used by the TYPO3 Core
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
class MfaProviderRegistry
|
||||
{
|
||||
/**
|
||||
* @var MfaProviderManifestInterface[]
|
||||
*/
|
||||
protected array $providers = [];
|
||||
|
||||
public function registerProvider(MfaProviderManifestInterface $provider): void
|
||||
{
|
||||
$this->providers[$provider->getIdentifier()] = $provider;
|
||||
}
|
||||
|
||||
public function hasProvider(string $identifier): bool
|
||||
{
|
||||
return isset($this->providers[$identifier]);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
public function hasActiveProviders(AbstractUserAuthentication $user): bool
|
||||
{
|
||||
return $this->getActiveProviders($user) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active providers for the given user
|
||||
*
|
||||
* @return MfaProviderManifestInterface[]
|
||||
*/
|
||||
public function getActiveProviders(AbstractUserAuthentication $user): array
|
||||
{
|
||||
return array_filter($this->providers, static function (MfaProviderManifestInterface $provider) use ($user): bool {
|
||||
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.
|
||||
*
|
||||
* @return MfaProviderManifestInterface
|
||||
*/
|
||||
public function getFirstAuthenticationAwareProvider(AbstractUserAuthentication $user): ?MfaProviderManifestInterface
|
||||
{
|
||||
$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
|
||||
*/
|
||||
public function hasLockedProviders(AbstractUserAuthentication $user): bool
|
||||
{
|
||||
return $this->getLockedProviders($user) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all locked providers for the given user
|
||||
*
|
||||
* @return MfaProviderManifestInterface[]
|
||||
*/
|
||||
public function getLockedProviders(AbstractUserAuthentication $user): array
|
||||
{
|
||||
return array_filter($this->providers, static function (MfaProviderManifestInterface $provider) use ($user): bool {
|
||||
return $provider->isLocked(MfaProviderPropertyManager::create($provider, $user));
|
||||
});
|
||||
}
|
||||
|
||||
public function allowedProvidersItemsProcFunc(array &$parameters): void
|
||||
{
|
||||
foreach ($this->providers as $provider) {
|
||||
$parameters['items'][] = [
|
||||
'label' => $provider->getTitle(),
|
||||
'value' => $provider->getIdentifier(),
|
||||
'icon' => $provider->getIconIdentifier(),
|
||||
'description' => $provider->getDescription(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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 his 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
|
||||
{
|
||||
public function __construct(private readonly MfaProviderManifestInterface $provider, $code = 0, $message = '', ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getProvider(): MfaProviderManifestInterface
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Enumeration of possible view types for MFA providers
|
||||
*/
|
||||
enum MfaViewType: string
|
||||
{
|
||||
case SETUP = 'setup';
|
||||
case EDIT = 'edit';
|
||||
case AUTH = 'auth';
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?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 TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
|
||||
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 int MIN_LENGTH = 8;
|
||||
|
||||
protected PasswordHashFactory $passwordHashFactory;
|
||||
|
||||
public function __construct(protected readonly string $mode)
|
||||
{
|
||||
$this->passwordHashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plain and hashed recovery codes and return them as key/value
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @return list<non-empty-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
|
||||
);
|
||||
}
|
||||
|
||||
/** @var list<non-empty-string> $codes */
|
||||
$codes = [];
|
||||
while ($quantity >= 1 && count($codes) < $quantity) {
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= (string)random_int(0, 9);
|
||||
}
|
||||
// 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
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 hash 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
<?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 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\Crypto\HashAlgo;
|
||||
use TYPO3\CMS\Core\Crypto\HashService;
|
||||
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\Type\ContextualFeedbackSeverity;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\View\ViewFactoryData;
|
||||
use TYPO3\CMS\Core\View\ViewFactoryInterface;
|
||||
|
||||
/**
|
||||
* MFA provider for authentication with recovery codes
|
||||
*
|
||||
* @internal should only be used by the TYPO3 Core
|
||||
*/
|
||||
final readonly class RecoveryCodesProvider implements MfaProviderInterface
|
||||
{
|
||||
private const int MAX_ATTEMPTS = 3;
|
||||
public function __construct(
|
||||
private MfaProviderRegistry $mfaProviderRegistry,
|
||||
private Context $context,
|
||||
private UriBuilder $uriBuilder,
|
||||
private FlashMessageService $flashMessageService,
|
||||
private HashService $hashService,
|
||||
private ViewFactoryInterface $viewFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if a recovery code is given in the current request
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
public function isActive(MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
return $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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
|
||||
// Can not verify an inactive or locked provider
|
||||
return false;
|
||||
}
|
||||
|
||||
$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
|
||||
*
|
||||
* @throws PropagateResponseException
|
||||
*/
|
||||
public function handleRequest(
|
||||
ServerRequestInterface $request,
|
||||
MfaProviderPropertyManager $propertyManager,
|
||||
MfaViewType $type
|
||||
): ResponseInterface {
|
||||
$viewFactoryData = new ViewFactoryData(
|
||||
templateRootPaths: ['EXT:core/Resources/Private/Templates'],
|
||||
partialRootPaths: ['EXT:core/Resources/Private/Partials'],
|
||||
layoutRootPaths: ['EXT:core/Resources/Private/Layouts'],
|
||||
request: $request,
|
||||
);
|
||||
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'),
|
||||
ContextualFeedbackSeverity::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 = $this->viewFactory->create($viewFactoryData);
|
||||
$view->assignMultiple([
|
||||
'providerIdentifier' => $propertyManager->getIdentifier(),
|
||||
'recoveryCodes' => implode(PHP_EOL, $codes),
|
||||
// Generate hmac of the recovery codes to prevent them from being changed in the setup from
|
||||
'checksum' => $this->hashService->hmac(json_encode($codes) ?: '', 'recovery-codes-setup', HashAlgo::SHA3_256),
|
||||
]);
|
||||
return new HtmlResponse($view->render('Authentication/MfaProvider/RecoveryCodes/Setup'));
|
||||
case MfaViewType::EDIT:
|
||||
$view = $this->viewFactory->create($viewFactoryData);
|
||||
$view->assignMultiple([
|
||||
'providerIdentifier' => $propertyManager->getIdentifier(),
|
||||
'name' => $propertyManager->getProperty('name'),
|
||||
'amountOfCodesLeft' => count($propertyManager->getProperty('codes', [])),
|
||||
'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)),
|
||||
'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)),
|
||||
]);
|
||||
return new HtmlResponse($view->render('Authentication/MfaProvider/RecoveryCodes/Edit'));
|
||||
default: // MfaViewType::AUTH
|
||||
$view = $this->viewFactory->create($viewFactoryData);
|
||||
$view->assignMultiple([
|
||||
'providerIdentifier' => $propertyManager->getIdentifier(),
|
||||
'isLocked' => $this->isLocked($propertyManager),
|
||||
]);
|
||||
return new HtmlResponse($view->render('Authentication/MfaProvider/RecoveryCodes/Auth'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the provider by hashing and storing the given recovery codes
|
||||
*/
|
||||
public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if ($this->isActive($propertyManager)) {
|
||||
// Can not activate an active provider
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->activeProvidersExist($propertyManager)) {
|
||||
// Can not activate since no other provider is activated yet
|
||||
return false;
|
||||
}
|
||||
|
||||
$recoveryCodes = GeneralUtility::trimExplode(PHP_EOL, (string)($request->getParsedBody()['recoveryCodes'] ?? ''));
|
||||
$checksum = (string)($request->getParsedBody()['checksum'] ?? '');
|
||||
if ($recoveryCodes === []
|
||||
|| !hash_equals($this->hashService->hmac(json_encode($recoveryCodes) ?: '', 'recovery-codes-setup', HashAlgo::SHA3_256), $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
|
||||
*/
|
||||
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')) {
|
||||
// Can not deactivate an inactive provider
|
||||
return false;
|
||||
}
|
||||
// Delete the provider entry
|
||||
return $propertyManager->deleteProviderEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the unlock action by resetting the attempts
|
||||
* provider property and issuing new codes.
|
||||
*/
|
||||
public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager) || !$this->isLocked($propertyManager)) {
|
||||
// Can not unlock an inactive or not locked provider
|
||||
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'),
|
||||
ContextualFeedbackSeverity::WARNING
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
|
||||
// Can not update an inactive or locked provider
|
||||
return false;
|
||||
}
|
||||
|
||||
$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'),
|
||||
ContextualFeedbackSeverity::OK
|
||||
);
|
||||
}
|
||||
|
||||
// Provider properties successfully updated
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has other active providers
|
||||
*/
|
||||
private 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
|
||||
*/
|
||||
private 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
|
||||
*/
|
||||
private 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.
|
||||
*/
|
||||
private function addFlashMessage(string $message, string $title = '', ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::INFO): void
|
||||
{
|
||||
$this->flashMessageService->getMessageQueueByIdentifier()->enqueue(
|
||||
new FlashMessage($message, $title, $severity, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the timestamp as local time (date string) by applying the globally configured format
|
||||
*/
|
||||
private function getDateTime(int $timestamp): string
|
||||
{
|
||||
if ($timestamp === 0) {
|
||||
return '';
|
||||
}
|
||||
return date(
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
|
||||
$timestamp
|
||||
) ?: '';
|
||||
}
|
||||
|
||||
private function getLanguageService(): LanguageService
|
||||
{
|
||||
return $GLOBALS['LANG'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?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 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 array ALLOWED_ALGOS = ['sha1', 'sha256', 'sha512'];
|
||||
private const int MIN_LENGTH = 6;
|
||||
private const int MAX_LENGTH = 8;
|
||||
|
||||
public function __construct(
|
||||
protected readonly string $secret,
|
||||
protected readonly string $algo = 'sha1',
|
||||
protected readonly int $length = 6,
|
||||
protected readonly int $step = 30,
|
||||
protected readonly int $epoch = 0
|
||||
) {
|
||||
if (!in_array($this->algo, self::ALLOWED_ALGOS, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
$this->algo . ' is not allowed. Allowed algos are: ' . implode(',', self::ALLOWED_ALGOS),
|
||||
1611748791
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->length < self::MIN_LENGTH || $this->length > self::MAX_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
$this->length . ' is not allowed as TOTP length. Must be between ' . self::MIN_LENGTH . ' and ' . self::MAX_LENGTH,
|
||||
1611748792
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 === '' || str_contains($secret, '=')) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?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\Crypto\HashAlgo;
|
||||
use TYPO3\CMS\Core\Crypto\HashService;
|
||||
use TYPO3\CMS\Core\Http\HtmlResponse;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\View\ViewFactoryData;
|
||||
use TYPO3\CMS\Core\View\ViewFactoryInterface;
|
||||
|
||||
/**
|
||||
* MFA provider for time-based one-time password authentication
|
||||
*
|
||||
* @internal should only be used by the TYPO3 Core
|
||||
*/
|
||||
final readonly class TotpProvider implements MfaProviderInterface
|
||||
{
|
||||
private const int MAX_ATTEMPTS = 3;
|
||||
|
||||
public function __construct(
|
||||
private Context $context,
|
||||
private HashService $hashService,
|
||||
private ViewFactoryInterface $viewFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if a TOTP is given in the current request
|
||||
*/
|
||||
public function canProcess(ServerRequestInterface $request): bool
|
||||
{
|
||||
return $this->getTotp($request) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if the provider is activated by checking the
|
||||
* active state and the secret from the provider properties.
|
||||
*/
|
||||
public function isActive(MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
return (bool)$propertyManager->getProperty('active')
|
||||
&& $propertyManager->getProperty('secret', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if the provider is temporarily locked by checking
|
||||
* the current attempts state from the provider properties.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
|
||||
// Can not verify an inactive or locked provider
|
||||
return false;
|
||||
}
|
||||
|
||||
$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.
|
||||
*/
|
||||
public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if ($this->isActive($propertyManager)) {
|
||||
// Can not activate an active provider
|
||||
return false;
|
||||
}
|
||||
|
||||
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($this->hashService->hmac($secret, 'totp-setup', HashAlgo::SHA3_256), $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
|
||||
*/
|
||||
public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
|
||||
// Can not update an inactive or locked provider
|
||||
return false;
|
||||
}
|
||||
$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
|
||||
*/
|
||||
public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager) || !$this->isLocked($propertyManager)) {
|
||||
// Can not unlock an inactive or not locked provider
|
||||
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.
|
||||
*/
|
||||
public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
|
||||
{
|
||||
if (!$this->isActive($propertyManager)) {
|
||||
// Can not deactivate an inactive provider
|
||||
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.
|
||||
*/
|
||||
public function handleRequest(
|
||||
ServerRequestInterface $request,
|
||||
MfaProviderPropertyManager $propertyManager,
|
||||
MfaViewType $type
|
||||
): ResponseInterface {
|
||||
$viewFactoryData = new ViewFactoryData(
|
||||
templateRootPaths: ['EXT:core/Resources/Private/Templates'],
|
||||
partialRootPaths: ['EXT:core/Resources/Private/Partials'],
|
||||
layoutRootPaths: ['EXT:core/Resources/Private/Layouts'],
|
||||
request: $request,
|
||||
);
|
||||
$view = $this->viewFactory->create($viewFactoryData);
|
||||
switch ($type) {
|
||||
case MfaViewType::SETUP:
|
||||
// Generate a new shared secret, generate the otpauth URL and create a qr-code for improved usability.
|
||||
$userData = $propertyManager->getUser()->user ?? [];
|
||||
$secret = Totp::generateEncodedSecret([(string)($userData['uid'] ?? ''), (string)($userData['username'] ?? '')]);
|
||||
$totpInstance = GeneralUtility::makeInstance(Totp::class, $secret);
|
||||
$totpAuthUrl = $totpInstance->getTotpAuthUrl(
|
||||
(string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? 'TYPO3'),
|
||||
(string)($userData['email'] ?? '') ?: (string)($userData['username'] ?? '')
|
||||
);
|
||||
$view->assignMultiple([
|
||||
'secret' => $secret,
|
||||
'totpAuthUrl' => $totpAuthUrl,
|
||||
'qrCode' => $this->getSvgQrCode($totpAuthUrl),
|
||||
// Generate hmac of the secret to prevent it from being changed in the setup from
|
||||
'checksum' => $this->hashService->hmac($secret, 'totp-setup', HashAlgo::SHA3_256),
|
||||
'providerIdentifier' => $propertyManager->getIdentifier(),
|
||||
]);
|
||||
return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Setup'));
|
||||
case MfaViewType::EDIT:
|
||||
$view->assignMultiple([
|
||||
'name' => $propertyManager->getProperty('name'),
|
||||
'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)),
|
||||
'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)),
|
||||
'providerIdentifier' => $propertyManager->getIdentifier(),
|
||||
]);
|
||||
return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Edit'));
|
||||
default: // MfaViewType::AUTH
|
||||
$view->assignMultiple([
|
||||
'isLocked' => $this->isLocked($propertyManager),
|
||||
'providerIdentifier' => $propertyManager->getIdentifier(),
|
||||
]);
|
||||
return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Auth'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper method for fetching the TOTP from the request
|
||||
*/
|
||||
private 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
|
||||
*/
|
||||
private function getSvgQrCode(string $content): string
|
||||
{
|
||||
$qrCodeRenderer = new ImageRenderer(new RendererStyle(225, 4), new SvgImageBackEnd());
|
||||
return (new Writer($qrCodeRenderer))->writeString($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the timestamp as local time (date string) by applying the globally configured format
|
||||
*/
|
||||
private function getDateTime(int $timestamp): string
|
||||
{
|
||||
if ($timestamp === 0) {
|
||||
return '';
|
||||
}
|
||||
return date(
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
|
||||
$timestamp
|
||||
) ?: '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user