TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
@@ -0,0 +1,230 @@
<?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!
*/
namespace TYPO3\CMS\Core\Authentication;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Authentication services class
*/
class AbstractAuthenticationService implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* User object
*
* @var AbstractUserAuthentication
*/
public $pObj;
/**
* Subtype of the service which is used to call the service.
*
* @var string
*/
public $mode;
/**
* Submitted login form data
*
* @var array
*/
public $login = [];
/**
* Various data
*
* @var array
*/
public $authInfo = [];
/**
* User db table definition
*
* @var array
*/
public $db_user = [];
/**
* If the writelog() functions is called if a login-attempt has be tried without success
*
* @var bool
*/
public $writeAttemptLog = false;
/**
* @var array service description array
*/
public $info = [];
/**
* Initialize authentication service
*
* @param string $mode Subtype of the service which is used to call the service.
* @param array $loginData Submitted login form data
* @param array $authInfo Information array. Holds submitted form data etc.
* @param AbstractUserAuthentication $pObj Parent object
*/
public function initAuth($mode, $loginData, $authInfo, $pObj)
{
$this->pObj = $pObj;
// Sub type
$this->mode = $mode;
$this->login = $loginData;
$this->authInfo = $authInfo;
$this->db_user = $this->getServiceOption('db_user', $authInfo['db_user'] ?? [], false);
$this->writeAttemptLog = $this->pObj->writeAttemptLog ?? true;
}
/**
* Writes to log database table in pObj
*
* @param int $type denotes which module that has submitted the entry. This is the current list: 1=tce_db; 2=tce_file; 3=system (eg. sys_history save); 4=modules; 254=Personal settings changed; 255=login / out action: 1=login, 2=logout, 3=failed login (+ errorcode 3), 4=failure_warning_email sent
* @param int $action denotes which specific operation that wrote the entry (eg. 'delete', 'upload', 'update' and so on...). Specific for each $type. Also used to trigger update of the interface. (see the log-module for the meaning of each number !!)
* @param int $error flag. 0 = message, 1 = error (user problem), 2 = System Error (which should not happen), 3 = security notice (admin)
* @param null $_ unused
* @param string $details Default text that follows the message
* @param array $data Data that follows the log. Might be used to carry special information. If an array the first 5 entries (0-4) will be sprintf'ed the details-text...
* @param string $tablename Special field used by tce_main.php. These ($tablename, $recuid) holds the reference to the record which the log-entry is about.
* @param int|string $recuid Special field used by tce_main.php. These ($tablename, $recuid) holds the reference to the record which the log-entry is about.
*/
public function writelog($type, $action, $error, $_, $details, $data, $tablename = '', $recuid = '')
{
if ($this->writeAttemptLog) {
$this->pObj->writelog($type, $action, $error, null, $details, $data, $tablename, $recuid);
}
}
/**
* Get a user from DB by username
*
* @param string $username User name
* @param string $extraWhere Additional WHERE clause: " AND ...
* @param array|string $dbUserSetup User db table definition, or empty string for $this->db_user
* @return array<string, mixed>|false User array or FALSE
*/
public function fetchUserRecord($username, $extraWhere = '', $dbUserSetup = '')
{
$dbUser = is_array($dbUserSetup) ? $dbUserSetup : $this->db_user;
$user = false;
if ($username || $extraWhere) {
$query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($dbUser['table']);
$query->getRestrictions()->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$constraints = array_filter([
QueryHelper::stripLogicalOperatorPrefix($dbUser['enable_clause']),
QueryHelper::stripLogicalOperatorPrefix($extraWhere),
]);
if (!empty($username)) {
array_unshift(
$constraints,
$query->expr()->eq(
$dbUser['username_column'],
$query->createNamedParameter($username)
)
);
}
$user = $query->select('*')
->from($dbUser['table'])
->where(...$constraints)
->executeQuery()
->fetchAssociative();
}
return $user;
}
/**
* Initialization of the service.
* This is a stub as needed by GeneralUtility::makeInstanceService()
* @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally
*/
public function init(): bool
{
return true;
}
/**
* Resets the service.
* This is a stub as needed by GeneralUtility::makeInstanceService()
* @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally
*/
public function reset()
{
// nothing to do
}
/**
* Returns the service key of the service
*
* @return string Service key
* @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally
*/
public function getServiceKey()
{
return $this->info['serviceKey'];
}
/**
* Returns the title of the service
*
* @return string Service title
* @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally
*/
public function getServiceTitle()
{
return $this->info['title'];
}
/**
* Returns service configuration values from the $TYPO3_CONF_VARS['SVCONF'] array
*
* @param string $optionName Name of the config option
* @param mixed $defaultValue Default configuration if no special config is available
* @param bool $includeDefaultConfig If set the 'default' config will be returned if no special config for this service is available (default: TRUE)
* @return mixed Configuration value for the service
* @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally
*/
public function getServiceOption($optionName, $defaultValue = '', $includeDefaultConfig = true)
{
$config = null;
$serviceType = $this->info['serviceType'] ?? '';
$serviceKey = $this->info['serviceKey'] ?? '';
$svOptions = $GLOBALS['TYPO3_CONF_VARS']['SVCONF'][$serviceType] ?? [];
if (isset($svOptions[$serviceKey][$optionName])) {
$config = $svOptions[$serviceKey][$optionName];
} elseif ($includeDefaultConfig && isset($svOptions['default'][$optionName])) {
$config = $svOptions['default'][$optionName];
}
if (!isset($config)) {
$config = $defaultValue;
}
return $config;
}
/**
* @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally
*/
public function getLastErrorArray(): array
{
return [];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<?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;
/**
* Result object for access checks.
*
* Encapsulates the result of access checks in BackendUserAuthentication
* without requiring state sharing via properties.
*
* @internal
*/
final readonly class AccessCheckResult
{
public function __construct(
public bool $isAllowed,
public string $errorMessage = '',
) {}
}
@@ -0,0 +1,232 @@
<?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!
*/
namespace TYPO3\CMS\Core\Authentication;
use Psr\Log\LogLevel;
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Authentication services class
*/
class AuthenticationService extends AbstractAuthenticationService implements MimicServiceInterface
{
/**
* Process the submitted credentials. Returns true, if loginData was processed successfully, else false.
* In addition, extensions overwriting this method or implement the context related methods `processLoginDataFE`
* or `processLoginDataBE` may also return an int (e.g. >= 200) in order to stop loginData processing of other
* services in the authentication service chain.
*
* @param array $loginData Credentials that are submitted and potentially modified by other services
*/
public function processLoginData(array &$loginData): bool|int
{
$loginData = array_map(trim(...), $loginData);
$loginData['uident_text'] = $loginData['uident'];
return true;
}
/**
* Find a user (eg. look up the user record in database when a login is sent)
*
* @return array<string, mixed>|false User array or FALSE
*/
public function getUser()
{
if (LoginType::tryFrom($this->login['status'] ?? '') !== LoginType::LOGIN) {
return false;
}
if ((string)$this->login['uident_text'] === '') {
// Failed Login attempt (no password given)
$this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, 'Login-attempt from ###IP### for username \'%s\' with an empty password!', [
$this->login['uname'],
]);
$this->logger->warning('Login-attempt from {ip}, for username "{username}" with an empty password!', [
'ip' => $this->authInfo['REMOTE_ADDR'],
'username' => $this->login['uname'],
]);
return false;
}
$user = $this->fetchUserRecord($this->login['uname']);
if (!is_array($user)) {
// Failed login attempt (no username found)
$this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, 'Login-attempt from ###IP###, username \'%s\' not found!', [$this->login['uname']]);
$this->logger->info('Login-attempt from username "{username}" not found!', [
'username' => $this->login['uname'],
'REMOTE_ADDR' => $this->authInfo['REMOTE_ADDR'],
]);
} else {
$this->logger->debug('User found', [
$this->db_user['userid_column'] => $user[$this->db_user['userid_column']],
$this->db_user['username_column'] => $user[$this->db_user['username_column']],
]);
}
return $user;
}
/**
* Authenticate a user: Check submitted user credentials against stored hashed password.
*
* Returns one of the following status codes:
* >= 200: User authenticated successfully. No more checking is needed by other auth services.
* >= 100: User not authenticated; this service is not responsible. Other auth services will be asked.
* > 0: User authenticated successfully. Other auth services will still be asked.
* <= 0: Authentication failed, no more checking needed by other auth services.
*
* @param array<string, mixed> $user User data
* @return int Authentication status code, one of 0, 100, 200
*/
public function authUser(array $user): int
{
// Early 100 "not responsible, check other services" if username or password is empty
if (!isset($this->login['uident_text']) || (string)$this->login['uident_text'] === ''
|| !isset($this->login['uname']) || (string)$this->login['uname'] === '') {
return 100;
}
if (empty($this->db_user['table'])) {
throw new \RuntimeException('User database table not set', 1533159150);
}
$submittedUsername = (string)$this->login['uname'];
$submittedPassword = (string)$this->login['uident_text'];
$passwordHashInDatabase = $user['password'];
$userDatabaseTable = $this->db_user['table'];
$isReHashNeeded = false;
$saltFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
// Get a hashed password instance for the hash stored in db of this user
try {
$hashInstance = $saltFactory->get($passwordHashInDatabase, $this->pObj->loginType);
} catch (InvalidPasswordHashException $exception) {
// Could not find a responsible hash algorithm for given password. This is unusual since other
// authentication services would usually be called before this one with higher priority. We thus log
// the failed login but still return '100' to proceed with other services that may follow.
$message = 'Login-attempt from ###IP###, username \'%s\', no suitable hash method found!';
$this->writeLogMessage($message, $submittedUsername);
$this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, $message, [$submittedUsername]);
// Not responsible, check other services
return 100;
}
// An instance of the currently configured salted password mechanism
// Don't catch InvalidPasswordHashException here: Only install tool should handle those configuration failures
$defaultHashInstance = $saltFactory->getDefaultHashInstance($this->pObj->loginType);
// We found a hash class that can handle this type of hash
$isValidPassword = $hashInstance->checkPassword($submittedPassword, $passwordHashInDatabase);
if ($isValidPassword) {
if ($hashInstance->isHashUpdateNeeded($passwordHashInDatabase)
|| $defaultHashInstance != $hashInstance
) {
// Lax object comparison intended: Rehash if old and new salt objects are not
// instances of the same class.
$isReHashNeeded = true;
}
}
if (!$isValidPassword) {
// Failed login attempt - wrong password
$message = 'Login-attempt from ###IP###, username \'%s\', password not accepted!';
$this->writeLogMessage($message, $submittedUsername);
$this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, $message, [$submittedUsername]);
// Responsible, authentication failed, do NOT check other services
return 0;
}
if ($isReHashNeeded) {
// Given password validated but a re-hash is needed. Do so.
$this->updatePasswordHashInDatabase(
$userDatabaseTable,
(int)$user['uid'],
$defaultHashInstance->getHashedPassword($submittedPassword)
);
}
// Responsible, authentication ok. Log successful login and return 'auth ok, do NOT check other services'
$this->writeLogMessage($this->pObj->loginType . ' Authentication successful for username \'%s\'', $submittedUsername);
return 200;
}
/**
* Mimics password hashing for invalid authentication requests to mitigate
* @link https://cwe.mitre.org/data/definitions/208.html: CWE-208: Observable Timing Discrepancy
*/
public function mimicAuthUser(): bool
{
try {
$hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
$defaultHashInstance = $hashFactory->getDefaultHashInstance($this->pObj->loginType);
$defaultHashInstance->getHashedPassword(random_bytes(10));
} catch (\Exception) {
// no further processing here
}
return false;
}
/**
* Method updates a FE/BE user record - in this case a new password string will be set.
*
* @param string $table Database table of this user, usually 'be_users' or 'fe_users'
* @param int $uid uid of user record that will be updated
* @param string $newPassword Field values as key=>value pairs to be updated in database
*/
protected function updatePasswordHashInDatabase(string $table, int $uid, string $newPassword): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
$connection->update(
$table,
['password' => $newPassword],
['uid' => $uid]
);
$this->logger->notice('Automatic password update for user record in {table} with uid {uid}', [
'table' => $table,
'uid' => $uid,
]);
}
/**
* Writes log message. Destination log depends on the current system mode.
*
* This function accepts variable number of arguments and can format
* parameters. The syntax is the same as for sprintf()
* If a marker ###IP### is present in the message, it is automatically replaced with the REMOTE_ADDR
*
* @param string $message Message to output
* @param array<int,string> $params
*/
protected function writeLogMessage(string $message, ...$params): void
{
if (!empty($params)) {
$message = vsprintf($message, $params);
}
$message = str_replace('###IP###', (string)($this->authInfo['REMOTE_ADDR'] ?? ''), $message);
if ($this->pObj->loginType === 'FE') {
$timeTracker = GeneralUtility::makeInstance(TimeTracker::class);
$timeTracker->setTSlogMessage($message, LogLevel::INFO);
}
$this->logger->notice($message);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
<?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!
*/
namespace TYPO3\CMS\Core\Authentication;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* TYPO3 backend user authentication on a CLI level
* Auto-logs in, only allowed on CLI
*/
class CommandLineUserAuthentication extends BackendUserAuthentication
{
/**
/**
* Constructor, only allowed in CLI mode
*
* @throws \RuntimeException
*/
public function __construct()
{
if (!Environment::isCli()) {
throw new \RuntimeException('Creating a CLI-based user object on non-CLI level is not allowed', 1483971165);
}
if (!$this->isUserAllowedToLogin()) {
throw new \RuntimeException('Login Error: TYPO3 is in maintenance mode at the moment. Only administrators are allowed access.', 1483971855);
}
$this->dontSetCookie = true;
parent::__construct();
}
/**
* Replacement for AbstractUserAuthentication::start()
*
* We do not need support for sessions, cookies, $_GET-modes, the postUserLookup hook or
* a database connection during CLI Bootstrap
*
* @param ServerRequestInterface|null $request
*/
public function start(?ServerRequestInterface $request = null)
{
// do nothing
}
/**
* Replacement for AbstractUserAuthentication::checkAuthentication()
*
* Not required in CLI mode, therefore empty.
*/
public function checkAuthentication(ServerRequestInterface $request)
{
// do nothing
}
/**
* On CLI there is no session and no switched user
*/
public function getOriginalUserIdWhenInSwitchUserMode(): ?int
{
return null;
}
/**
* Logs-in the _CLI_ user. It does not need to check for credentials.
*
* @throws \RuntimeException when the user could not log in or it is an admin
*/
public function authenticate()
{
// check if a _CLI_ user exists, if not, create one
$this->setBeUserByName(CommandLineUserCreation::CLI_USERNAME);
if (empty($this->user['uid'])) {
$userCreation = GeneralUtility::makeInstance(CommandLineUserCreation::class);
// create a new BE user in the database
if (!$userCreation->ensureCliUserExists()) {
throw new \RuntimeException('No backend user named "_cli_" could be authenticated, maybe this user is "hidden"?', 1484050401);
}
$this->setBeUserByName(CommandLineUserCreation::CLI_USERNAME);
}
if (empty($this->user['uid'])) {
throw new \RuntimeException('No backend user named "_cli_" could be created.', 1476107195);
}
$this->unpack_uc();
// The groups are fetched and ready for permission checking in this initialization.
$this->fetchGroupData();
$this->backendSetUC();
}
/**
* Logs in the TYPO3 Backend user "_cli_"
*/
public function backendCheckLogin(?ServerRequestInterface $request = null)
{
$this->authenticate();
}
/**
* Determines whether a CLI backend user is allowed to access TYPO3.
* Only when adminOnly is off (=0), and only allowed for admins and CLI users (=2)
*
* @return bool Whether the CLI user is allowed to access TYPO3
* @internal
*/
public function isUserAllowedToLogin()
{
return in_array((int)$GLOBALS['TYPO3_CONF_VARS']['BE']['adminOnly'], [0, 2], true);
}
}
@@ -0,0 +1,92 @@
<?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;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal Exclusively for use in CommandLineUserAuthentication and in TYPO3 setup code (cli and controller)
*/
#[Autoconfigure(public: true)]
final readonly class CommandLineUserCreation
{
public const string CLI_USERNAME = '_cli_';
public function __construct(
private ConnectionPool $connectionPool,
private PasswordHashFactory $passwordHashFactory,
) {}
/**
* Create a record in the DB table be_users called "_cli_" with no other information,
* when it does not exist already
*/
public function ensureCliUserExists(): bool
{
if ($this->cliUserExists()) {
return false;
}
$userFields = [
'username' => self::CLI_USERNAME,
'password' => $this->generateHashedPassword(),
'admin' => 1,
'tstamp' => $GLOBALS['EXEC_TIME'] ?? time(),
'crdate' => $GLOBALS['EXEC_TIME'] ?? time(),
];
$databaseConnection = $this->connectionPool->getConnectionForTable('be_users');
$databaseConnection->insert('be_users', $userFields);
return true;
}
/**
* Check if a user with username "_cli_" exists. Deleted users are left out
* but hidden and start / endtime restricted users are considered.
*/
private function cliUserExists(): bool
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$count = $queryBuilder
->count('*')
->from('be_users')
->where($queryBuilder->expr()->eq('username', $queryBuilder->createNamedParameter(self::CLI_USERNAME)))
->executeQuery()
->fetchOne();
return (bool)$count;
}
/**
* This function returns a salted hashed key.
*/
private function generateHashedPassword(): string
{
$cryptoService = GeneralUtility::makeInstance(Random::class);
$password = $cryptoService->generateRandomBytes(20);
return $this->passwordHashFactory
->getDefaultHashInstance('BE')
->getHashedPassword($password);
}
}
@@ -0,0 +1,52 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
/**
* Class to be extended by events, fired after authentication has failed
*/
abstract class AbstractAuthenticationFailedEvent
{
public function __construct(
private readonly ServerRequestInterface $request
) {}
/**
* Returns the user, who failed to authenticate successfully
*/
abstract public function getUser(): AbstractUserAuthentication;
public function isFrontendAttempt(): bool
{
return !$this->isBackendAttempt();
}
public function isBackendAttempt(): bool
{
return $this->getUser() instanceof BackendUserAuthentication;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,75 @@
<?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\Event;
/**
* Event fired after user groups have been resolved for a specific user
*/
final class AfterGroupsResolvedEvent
{
public function __construct(
private readonly string $sourceDatabaseTable,
private array $groups,
private readonly array $originalGroupIds,
private readonly array $userData
) {}
/**
* @return string 'be_groups' or 'fe_groups' depending on context.
*/
public function getSourceDatabaseTable(): string
{
return $this->sourceDatabaseTable;
}
/**
* List of group records including sub groups as resolved by core.
*
* Note order is important: A user with main groups "1,2", where 1 has sub group 3,
* results in "3,1,2" as record list array - sub groups are listed before the group
* that includes the sub group.
*/
public function getGroups(): array
{
return $this->groups;
}
/**
* List of group records as manipulated by the event.
*/
public function setGroups(array $groups): void
{
$this->groups = $groups;
}
/**
* List of group uids directly attached to the user
*/
public function getOriginalGroupIds(): array
{
return $this->originalGroupIds;
}
/**
* Full user record with all fields
*/
public function getUserData(): array
{
return $this->userData;
}
}
@@ -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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
/**
* Event fired after a user has been actively logged in to backend (incl. possible MFA) or frontend.
*/
final readonly class AfterUserLoggedInEvent
{
public function __construct(
private AbstractUserAuthentication $user,
private ?ServerRequestInterface $request = null
) {}
public function getUser(): AbstractUserAuthentication
{
return $this->user;
}
public function getRequest(): ?ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,35 @@
<?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\Event;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
/**
* Event fired after a user has been actively logged out.
*/
final readonly class AfterUserLoggedOutEvent
{
public function __construct(
private AbstractUserAuthentication $user
) {}
public function getUser(): AbstractUserAuthentication
{
return $this->user;
}
}
@@ -0,0 +1,54 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Security\RequestToken;
/**
* Event fired before request-token is processed.
*/
final class BeforeRequestTokenProcessedEvent
{
public function __construct(
private readonly AbstractUserAuthentication $user,
private readonly ServerRequestInterface $request,
private RequestToken|false|null $requestToken
) {}
public function getUser(): AbstractUserAuthentication
{
return $this->user;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getRequestToken(): RequestToken|false|null
{
return $this->requestToken;
}
public function setRequestToken(RequestToken|false|null $requestToken): void
{
$this->requestToken = $requestToken;
}
}
@@ -0,0 +1,61 @@
<?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\Event;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Session\UserSession;
/**
* Event fired before a user is going to be actively logged out.
* An option to interrupt the regular logout flow from TYPO3 Core (so you can do this yourself)
* is also available.
*/
final class BeforeUserLogoutEvent
{
private bool $shouldLogout = true;
public function __construct(
private readonly AbstractUserAuthentication $user,
private readonly ?UserSession $userSession
) {}
public function getUser(): AbstractUserAuthentication
{
return $this->user;
}
public function disableRegularLogoutProcess(): void
{
$this->shouldLogout = false;
}
public function enableRegularLogoutProcess(): void
{
$this->shouldLogout = true;
}
public function shouldLogout(): bool
{
return $this->shouldLogout;
}
public function getUserSession(): ?UserSession
{
return $this->userSession;
}
}
@@ -0,0 +1,45 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
/**
* Event fired after a login attempt failed.
*/
final class LoginAttemptFailedEvent extends AbstractAuthenticationFailedEvent
{
public function __construct(
private readonly AbstractUserAuthentication $user,
private readonly ServerRequestInterface $request,
private readonly array $loginData,
) {
parent::__construct($this->request);
}
public function getUser(): AbstractUserAuthentication
{
return $this->user;
}
public function getLoginData(): array
{
return $this->loginData;
}
}
@@ -0,0 +1,62 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
/**
* Event fired after MFA verification failed.
*/
final class MfaVerificationFailedEvent extends AbstractAuthenticationFailedEvent
{
public function __construct(
private readonly ServerRequestInterface $request,
private readonly MfaProviderPropertyManager $propertyManager,
private readonly MfaProviderManifestInterface $mfaProvider,
) {
parent::__construct($this->request);
}
public function getUser(): AbstractUserAuthentication
{
return $this->propertyManager->getUser();
}
public function getProvider(): MfaProviderManifestInterface
{
return $this->mfaProvider;
}
public function getProviderIdentifier(): string
{
return $this->mfaProvider->getIdentifier();
}
public function getProviderProperties(): array
{
return $this->propertyManager->getProperties();
}
public function isProviderLocked(): bool
{
return $this->mfaProvider->isLocked($this->propertyManager);
}
}
@@ -0,0 +1,23 @@
<?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\Exception;
use Psr\Container\NotFoundExceptionInterface;
use TYPO3\CMS\Core\Exception;
class UserSettingsNotFoundException extends Exception implements NotFoundExceptionInterface {}
+224
View File
@@ -0,0 +1,224 @@
<?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;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Authentication\Event\AfterGroupsResolvedEvent;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A provider for resolving fe_groups / be_groups, including nested sub groups.
*
* When fetching subgroups, the current group (parent group) is handed in recursive.
* Duplicates are suppressed: If a subgroup is including in multiple parent groups,
* it will be resolved only once.
*
* @internal this is not part of TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
readonly class GroupResolver
{
private const string SOURCE_FIELD = 'usergroup';
private const string RECURSIVE_SOURCE_FIELD = 'subgroup';
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private ConnectionPool $connectionPool,
) {}
/**
* Fetch all group records for a given user recursive.
*
* Note order is important: A user with main groups "1,2", where 1 has sub group 3,
* results in "3,1,2" as record list array - sub groups are listed before the group
* that includes the sub group.
*
* @param array $userRecord Used for context in PSR-14 event
* @param string $sourceTable The database table to look up: be_groups / fe_groups depending on context
* @return array List of group records. Note the ordering note above.
*/
public function resolveGroupsForUser(array $userRecord, string $sourceTable): array
{
$originalGroupIds = GeneralUtility::intExplode(',', (string)($userRecord[self::SOURCE_FIELD] ?? ''), true);
$resolvedGroups = $this->fetchGroupsRecursive($sourceTable, $originalGroupIds);
$event = $this->eventDispatcher->dispatch(new AfterGroupsResolvedEvent($sourceTable, $resolvedGroups, $originalGroupIds, $userRecord));
return $event->getGroups();
}
/**
* This works the other way around: Find all users that belong to some groups. Because groups are nested,
* we need to find all groups and subgroups first, because maybe a user is only part of a higher group,
* instead of a "All editors" group.
*
* @param int[] $groupIds a list of IDs of groups
* @param string $sourceTable e.g. be_groups or fe_groups
* @param string $userSourceTable e.g. be_users or fe_users
* @return array full user records
*/
public function findAllUsersInGroups(array $groupIds, string $sourceTable, string $userSourceTable): array
{
// Ensure the given groups exist
$mainGroups = $this->fetchRowsFromDatabase($sourceTable, $groupIds);
$groupIds = array_map(intval(...), array_column($mainGroups, 'uid'));
if (empty($groupIds)) {
return [];
}
$parentGroupIds = $this->fetchParentGroupsRecursive($sourceTable, $groupIds, $groupIds);
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($userSourceTable);
$queryBuilder
->select('*')
->from($userSourceTable);
$constraints = [];
foreach ($groupIds as $groupUid) {
$constraints[] = $queryBuilder->expr()->inSet(self::SOURCE_FIELD, (string)$groupUid);
}
foreach ($parentGroupIds as $groupUid) {
$constraints[] = $queryBuilder->expr()->inSet(self::SOURCE_FIELD, (string)$groupUid);
}
$users = $queryBuilder
->where(
$queryBuilder->expr()->or(...$constraints)
)
->executeQuery()
->fetchAllAssociative();
return !empty($users) ? $users : [];
}
/**
* Load a list of group uids, and take into account if groups have been loaded before.
*
* @param int[] $groupIds
*/
protected function fetchGroupsRecursive(string $sourceTable, array $groupIds, array $processedGroupIds = []): array
{
if (empty($groupIds)) {
return [];
}
$foundGroups = $this->fetchRowsFromDatabase($sourceTable, $groupIds);
$validGroups = [];
foreach ($groupIds as $groupId) {
// Database did not find the record
if (!is_array($foundGroups[$groupId] ?? null)) {
continue;
}
// Record was already processed, continue to avoid adding this group again
if (in_array($groupId, $processedGroupIds, true)) {
continue;
}
// Add sub groups first
$subgroupIds = GeneralUtility::intExplode(',', (string)($foundGroups[$groupId][self::RECURSIVE_SOURCE_FIELD] ?? ''), true);
if (!empty($subgroupIds)) {
$subgroups = $this->fetchGroupsRecursive($sourceTable, $subgroupIds, array_merge($processedGroupIds, [$groupId]));
$validGroups = array_merge($validGroups, $subgroups);
}
// Add main group after sub groups have been added
$validGroups[] = $foundGroups[$groupId];
}
return $validGroups;
}
/**
* Does the database query. Does not care about ordering, this is done by caller.
*
* @return array Full records with record uid as key
*/
protected function fetchRowsFromDatabase(string $sourceTable, array $groupIds): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($sourceTable);
$result = $queryBuilder
->select('*')
->from($sourceTable)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter(
$groupIds,
Connection::PARAM_INT_ARRAY
)
)
)
->executeQuery();
$groups = [];
while ($row = $result->fetchAssociative()) {
$groups[(int)$row['uid']] = $row;
}
return $groups;
}
/**
* Load a list of group uids, and take into account if groups have been loaded before as part of recursive detection.
*
* @param int[] $groupIds a list of groups to find THEIR ancestors
* @param array $processedGroupIds helper function to avoid recursive detection
* @return array a list of parent groups and thus, grand grand parent groups as well
*/
protected function fetchParentGroupsRecursive(string $sourceTable, array $groupIds, array $processedGroupIds = []): array
{
if (empty($groupIds)) {
return [];
}
$parentGroups = $this->fetchParentGroupsFromDatabase($sourceTable, $groupIds);
$validParentGroupIds = [];
foreach ($parentGroups as $parentGroup) {
$parentGroupId = (int)$parentGroup['uid'];
// Record was already processed, continue to avoid adding this group again
if (in_array($parentGroupId, $processedGroupIds, true)) {
continue;
}
$processedGroupIds[] = $parentGroupId;
$validParentGroupIds[] = $parentGroupId;
}
$grandParentGroups = $this->fetchParentGroupsRecursive($sourceTable, $validParentGroupIds, $processedGroupIds);
return array_merge($validParentGroupIds, $grandParentGroups);
}
/**
* Find all groups that have a FIND_IN_SET(subgroups, [$subgroupIds]) => the parent groups
* via one SQL query.
*/
protected function fetchParentGroupsFromDatabase(string $sourceTable, array $subgroupIds): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($sourceTable);
$queryBuilder
->select('*')
->from($sourceTable);
$constraints = [];
foreach ($subgroupIds as $subgroupId) {
$constraints[] = $queryBuilder->expr()->inSet(self::RECURSIVE_SOURCE_FIELD, (string)$subgroupId);
}
$result = $queryBuilder
->where(
$queryBuilder->expr()->or(...$constraints)
)
->executeQuery();
$groups = [];
while ($row = $result->fetchAssociative()) {
$groups[(int)$row['uid']] = $row;
}
return $groups;
}
}
+112
View File
@@ -0,0 +1,112 @@
<?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;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Handles the locking of sessions to IP addresses.
*/
class IpLocker
{
public const DISABLED_LOCK_VALUE = '[DISABLED]';
/**
* If set to 4, the session will be locked to the user's IP address (all four numbers).
* Reducing this to 1-3 means that only the given number of parts of the IP address is used.
*/
protected int $lockIPv4PartCount = 4;
/**
* Same setting as lockIP but for IPv6 addresses.
*/
protected int $lockIPv6PartCount = 8;
public function __construct(int $lockIPv4PartCount, int $lockIPv6PartCount)
{
$this->lockIPv4PartCount = $lockIPv4PartCount;
$this->lockIPv6PartCount = $lockIPv6PartCount;
}
public function getSessionIpLock(string $ipAddress): string
{
if ($this->lockIPv4PartCount === 0 && $this->lockIPv6PartCount === 0) {
return static::DISABLED_LOCK_VALUE;
}
if ($this->isIpv6Address($ipAddress)) {
return $this->getIpLockPartForIpv6Address($ipAddress);
}
return $this->getIpLockPartForIpv4Address($ipAddress);
}
public function validateRemoteAddressAgainstSessionIpLock(string $ipAddress, string $sessionIpLock): bool
{
if ($sessionIpLock === static::DISABLED_LOCK_VALUE) {
return true;
}
$ipToCompare = $this->isIpv6Address($ipAddress)
? $this->getIpLockPartForIpv6Address($ipAddress)
: $this->getIpLockPartForIpv4Address($ipAddress);
return $ipToCompare === $sessionIpLock;
}
protected function getIpLockPart(string $ipAddress, int $numberOfParts, int $maxParts, string $delimiter): string
{
if ($numberOfParts >= $maxParts) {
return $ipAddress;
}
$numberOfParts = MathUtility::forceIntegerInRange($numberOfParts, 1, $maxParts);
$ipParts = explode($delimiter, $ipAddress);
for ($a = $maxParts; $a > $numberOfParts; $a--) {
$ipPartValue = $delimiter === '.' ? '0' : str_pad('', strlen($ipParts[$a - 1]), '0');
$ipParts[$a - 1] = $ipPartValue;
}
return implode($delimiter, $ipParts);
}
protected function getIpLockPartForIpv4Address(string $ipAddress): string
{
if ($this->lockIPv4PartCount === 0) {
return static::DISABLED_LOCK_VALUE;
}
return $this->getIpLockPart($ipAddress, $this->lockIPv4PartCount, 4, '.');
}
protected function getIpLockPartForIpv6Address(string $ipAddress): string
{
if ($this->lockIPv6PartCount === 0) {
return static::DISABLED_LOCK_VALUE;
}
// inet_pton also takes care of IPv4-mapped addresses (see https://en.wikipedia.org/wiki/IPv6_address#Representation)
$unpacked = unpack('H*hex', (string)inet_pton($ipAddress)) ?: [];
$expandedAddress = rtrim(chunk_split($unpacked['hex'] ?? '', 4, ':'), ':');
return $this->getIpLockPart($expandedAddress, $this->lockIPv6PartCount, 8, ':');
}
protected function isIpv6Address(string $ipAddress): bool
{
return str_contains($ipAddress, ':');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?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;
use TYPO3\CMS\Core\Type\BitSet;
/**
* Bitset for bitwise operations on javascript confirmation popups
*
* @see https://docs.typo3.org/m/typo3/reference-tsconfig/main/en-us/UserTsconfig/Options.html#alertpopups
*/
final class JsConfirmation extends BitSet
{
public const int TYPE_CHANGE = 0b00000001;
public const int COPY_MOVE_PASTE = 0b00000010;
public const int DELETE = 0b00000100;
public const int FE_EDIT = 0b00001000;
private const int UNUSED_16 = 0b00010000;
private const int UNUSED_32 = 0b00100000;
private const int UNUSED_64 = 0b01000000;
public const int OTHER = 0b10000000;
public const ALL = self::TYPE_CHANGE | self::COPY_MOVE_PASTE | self::DELETE | self::FE_EDIT | self::UNUSED_16 | self::UNUSED_32 | self::UNUSED_64 | self::OTHER;
}
+27
View File
@@ -0,0 +1,27 @@
<?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;
/**
* Contains the different login types
*/
enum LoginType: string
{
case LOGIN = 'login';
case LOGOUT = 'logout';
}
@@ -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
) ?: '';
}
}
@@ -0,0 +1,35 @@
<?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;
interface MimicServiceInterface
{
/**
* Mimics user authentication for known invalid authentication requests. This method can be used
* to mitigate timing discrepancies for invalid authentication attempts, which can be used for
* user enumeration.
*
* Authentication services can implement this method to simulate(!) corresponding processes that
* would be processed during valid requests - e.g. perform password hashing (timing) or call
* remote services (network latency).
*
* @return bool whether other services shall continue
* @link https://cwe.mitre.org/data/definitions/208.html: CWE-208: Observable Timing Discrepancy
*/
public function mimicAuthUser(): bool;
}
+59
View File
@@ -0,0 +1,59 @@
<?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;
use Psr\Container\ContainerInterface;
use TYPO3\CMS\Core\Authentication\Exception\UserSettingsNotFoundException;
readonly class UserSettings implements ContainerInterface
{
public function __construct(
private array $settings,
) {}
public function has(string $id): bool
{
return array_key_exists($id, $this->settings);
}
public function get(string $id): mixed
{
if (array_key_exists($id, $this->settings)) {
return $this->settings[$id];
}
throw new UserSettingsNotFoundException(
'User setting "' . $id . '" is not available.',
1738500000
);
}
public function toArray(): array
{
return $this->settings;
}
public function isEmailMeAtLoginEnabled(): bool
{
return (bool)($this->settings['emailMeAtLogin'] ?? false);
}
public function isUploadFieldsInTopOfEBEnabled(): bool
{
return (bool)($this->settings['edit_docModuleUpload'] ?? true);
}
}
@@ -0,0 +1,91 @@
<?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;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This class is not part of the TYPO3 Core API yet.
*/
readonly class UserSettingsFactory
{
private UserSettingsSchema $schema;
public function __construct(
?UserSettingsSchema $schema = null,
) {
$this->schema = $schema ?? GeneralUtility::makeInstance(UserSettingsSchema::class);
}
public function createFromUserRecord(array $userRecord, array $uc = []): UserSettings
{
$dbColumnSettings = $this->extractDbColumnSettings($userRecord);
$jsonFieldSettings = $this->extractJsonFieldSettings($userRecord, $uc);
return new UserSettings(array_merge($dbColumnSettings, $jsonFieldSettings));
}
public function createFromUc(array $uc): UserSettings
{
$settings = [];
foreach ($this->schema->getJsonFieldSettingKeys() as $key) {
if (array_key_exists($key, $uc)) {
$settings[$key] = $uc[$key];
}
}
return new UserSettings($settings);
}
private function extractJsonFieldSettings(array $userRecord, array $uc): array
{
$settings = [];
// Primary source: user_settings JSON field
if (!empty($userRecord['user_settings'])) {
$decoded = is_string($userRecord['user_settings'])
? json_decode($userRecord['user_settings'], true)
: $userRecord['user_settings'];
if (is_array($decoded)) {
$settings = $decoded;
}
}
// Fallback: fill missing values from uc (migration period)
foreach ($this->schema->getJsonFieldSettingKeys() as $key) {
if (!array_key_exists($key, $settings) && array_key_exists($key, $uc)) {
$settings[$key] = $uc[$key];
}
}
return $settings;
}
private function extractDbColumnSettings(array $userRecord): array
{
$settings = [];
foreach ($this->schema->getDbColumnSettingKeys() as $key) {
if (array_key_exists($key, $userRecord)) {
$settings[$key] = $userRecord[$key];
}
}
return $settings;
}
}
@@ -0,0 +1,363 @@
<?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;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Provides unified access to backend user settings configuration.
*
* This class consolidates access to user settings configuration stored in
* TCA at $GLOBALS['TCA']['be_users']['columns']['user_settings'].
*
* @internal This class is not part of the TYPO3 Core API yet.
*/
readonly class UserSettingsSchema
{
/**
* Get all column configurations in legacy format.
* This merges TCA-based config with legacy global, preferring TCA.
*
* @return array<string, array>
*/
public function getColumns(): array
{
$columns = [];
$tcaColumns = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'] ?? [];
foreach ($tcaColumns as $fieldName => $tcaConfig) {
$columns[$fieldName] = $this->resolveTcaColumn($fieldName, $tcaConfig);
}
return $columns;
}
/**
* Get configuration for a specific field in legacy format.
*/
public function getColumn(string $fieldName): ?array
{
$tcaConfig = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'][$fieldName] ?? null;
if ($tcaConfig !== null) {
return $this->resolveTcaColumn($fieldName, $tcaConfig);
}
return null;
}
/**
* Returns a "fake TCA" for the be_users_settings pseudo-table.
*/
public function getTca(): array
{
$columns = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'] ?? [];
foreach ($columns as $fieldName => $columnConfig) {
$partitionedFieldName = $this->getTcaFieldName($fieldName);
$columns[$partitionedFieldName] = $this->resolveInheritFromParent($fieldName, $columnConfig);
}
return [
'be_users_settings' => [
'ctrl' => [
'title' => 'backend.user_profile:user_settings',
],
'columns' => $columns,
'types' => [
'0' => [
'showitem' => $this->getTcaShowitem(),
],
],
],
];
}
/**
* Returns a partitioned field name for use in TCA.
* - e.g. `be_users__password`, reflecting `be_users` values
* - e.g. `user_settings__titleLen`, reflecting JSON values
*/
public function getTcaFieldName(string $fieldName): string
{
$configuration = $this->getColumn($fieldName);
$partition = ($configuration['table'] ?? null) === 'be_users' ? 'be_users' : 'user_settings';
return $partition . '__' . $fieldName;
}
private function resolveTcaFieldName(string $fieldName, bool $strict = true): string
{
$configuration = $this->getColumn($fieldName);
if ($configuration !== null) {
$partition = ($configuration['table'] ?? null) === 'be_users' ? 'be_users' : 'user_settings';
return $partition . '__' . $fieldName;
}
if (!$strict) {
return $fieldName;
}
throw new \LogicException(
sprintf(
'Column "%s" not found in UserSettingsSchema',
$fieldName
),
1776439141
);
}
/**
* Get the partitioned showitem string to be used as virtual TCA.
*/
public function getTcaShowitem(): string
{
$items = GeneralUtility::trimExplode(',', $this->getRawShowitem(), true);
$items = array_map(
fn(string $fieldName): string => $this->resolveTcaFieldName($fieldName, false),
$items
);
return implode(',', $items);
}
/**
* Get the raw showitem string.
*/
public function getRawShowitem(): string
{
return trim($GLOBALS['TCA']['be_users']['columns']['user_settings']['showitem'] ?? '');
}
/**
* @return list<string>
*/
public function getJsonFieldSettingKeys(): array
{
$keys = [];
foreach ($this->getColumns() as $key => $config) {
// Fields with 'table' => 'be_users' are stored in be_users columns directly
// Also skip non-storable types like 'button' and 'mfa'
$type = $config['type'] ?? 'text';
if (($config['table'] ?? '') !== 'be_users'
&& !in_array($type, ['button', 'mfa'], true)
) {
$keys[] = $key;
}
}
return $keys;
}
/**
* @return string[]
*/
public function getDbColumnSettingKeys(): array
{
$keys = [];
foreach ($this->getColumns() as $key => $config) {
$type = $config['type'] ?? 'text';
if (($config['table'] ?? '') === 'be_users'
&& !in_array($type, ['button', 'mfa', 'password'], true)
) {
$keys[] = $key;
}
}
return $keys;
}
public function isJsonFieldSetting(string $key): bool
{
return in_array($key, $this->getJsonFieldSettingKeys(), true);
}
public function isDbColumnSetting(string $key): bool
{
return in_array($key, $this->getDbColumnSettingKeys(), true);
}
/**
* Returns field names that should trigger a JS persistent storage update
* when their value changes in User Settings, so JS components can react
* immediately without a page reload.
*
* @return string[]
*/
public function getPersistentUpdateFieldNames(): array
{
$keys = [];
foreach ($this->getColumns() as $key => $config) {
if (!empty($config['persistentUpdate'])) {
$keys[] = $key;
}
}
return $keys;
}
public function getDefault(string $key): mixed
{
$config = $this->getColumn($key);
return $config['default'] ?? null;
}
/**
* Resolves inheritFromParent for a TCA column by merging with the
* parent be_users TCA column configuration. Returns TCA format
* (without legacy conversion).
*
* @internal
*/
public function resolveInheritFromParent(string $fieldName, array $tcaConfig): array
{
if (!empty($tcaConfig['inheritFromParent'])) {
$parentConfig = $GLOBALS['TCA']['be_users']['columns'][$fieldName] ?? [];
$tcaConfig = array_replace_recursive($parentConfig, $tcaConfig);
unset($tcaConfig['inheritFromParent']);
}
return $tcaConfig;
}
/**
* Resolves a TCA column configuration, handling inheritFromParent,
* and converts to legacy format.
*/
private function resolveTcaColumn(string $fieldName, array $tcaConfig): array
{
return $this->convertTcaToLegacyFormat($fieldName, $this->resolveInheritFromParent($fieldName, $tcaConfig));
}
/**
* Converts a TCA column configuration to legacy format.
*/
private function convertTcaToLegacyFormat(string $fieldName, array $tcaConfig): array
{
$legacyConfig = [
'label' => $tcaConfig['label'] ?? '',
];
$config = $tcaConfig['config'] ?? [];
$tcaType = $config['type'] ?? 'input';
$renderType = $config['renderType'] ?? '';
// Determine if this field is stored in be_users table
// Fields with inheritFromParent that exist in be_users columns are table fields
if (isset($GLOBALS['TCA']['be_users']['columns'][$fieldName])) {
$legacyConfig['table'] = 'be_users';
}
// Convert TCA type to legacy type
switch ($tcaType) {
case 'input':
$legacyConfig['type'] = 'text';
if (isset($config['max'])) {
$legacyConfig['max'] = $config['max'];
}
break;
case 'email':
$legacyConfig['type'] = 'email';
if (isset($config['max'])) {
$legacyConfig['max'] = $config['max'];
}
break;
case 'number':
$legacyConfig['type'] = 'number';
break;
case 'password':
$legacyConfig['type'] = 'password';
break;
case 'check':
$legacyConfig['type'] = 'check';
break;
case 'select':
$legacyConfig['type'] = 'select';
if (isset($config['items'])) {
$legacyConfig['items'] = $this->convertSelectItemsToLegacy($config['items']);
}
if (isset($config['itemsProcFunc'])) {
$legacyConfig['itemsProcFunc'] = $config['itemsProcFunc'];
}
break;
case 'language':
$legacyConfig['type'] = 'language';
break;
case 'file':
$legacyConfig['type'] = 'avatar';
break;
case 'button':
$legacyConfig['type'] = 'button';
if (isset($config['buttonLabel'])) {
$legacyConfig['buttonlabel'] = $config['buttonLabel'];
}
if (isset($config['confirm'])) {
$legacyConfig['confirm'] = $config['confirm'];
}
if (isset($config['confirmData'])) {
$legacyConfig['confirmData'] = $config['confirmData'];
}
break;
case 'mfa':
$legacyConfig['type'] = 'mfa';
break;
case 'user':
$legacyConfig['type'] = 'user';
if (isset($config['renderType'])) {
$legacyConfig['userFunc'] = $config['renderType'];
}
break;
default:
$legacyConfig['type'] = 'text';
}
// Copy additional properties
if (isset($config['default'])) {
$legacyConfig['default'] = $config['default'];
}
if (isset($tcaConfig['access'])) {
$legacyConfig['access'] = $tcaConfig['access'];
}
if (!empty($tcaConfig['persistentUpdate'])) {
$legacyConfig['persistentUpdate'] = true;
}
return $legacyConfig;
}
/**
* Converts TCA select items format to legacy format.
* Handles both TCA format ([['label' => '...', 'value' => '...'], ...])
* and legacy format (['value' => 'label', ...]).
*/
private function convertSelectItemsToLegacy(array $items): array
{
$legacyItems = [];
foreach ($items as $key => $item) {
if (is_array($item) && isset($item['value']) && isset($item['label'])) {
// TCA format: [['label' => '...', 'value' => '...'], ...]
$legacyItems[$item['value']] = $item['label'];
} elseif (is_string($item)) {
// Legacy format: ['value' => 'label', ...]
$legacyItems[$key] = $item;
}
}
return $legacyItems;
}
}