TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Tree\TreeNode;
use TYPO3\CMS\Backend\Tree\TreeNodeCollection;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent;
use TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This event listener deals with tree data security which reacts on a PSR-14 event
* on data object initialization.
*
* The aspect defines category mount points according to BE User permissions.
*
* @internal This class is TYPO3-internal hook and is not considered part of the Public TYPO3 API.
*/
final class CategoryPermissionsAspect
{
/**
* @var string
*/
private $categoryTableName = 'sys_category';
/**
* The listener for the event in DatabaseTreeDataProvider, which only affects the TYPO3 Backend
*/
#[AsEventListener('backend-user-permissions')]
public function addUserPermissionsToCategoryTreeData(ModifyTreeDataEvent $event): void
{
// Only evaluate this in the backend
if (!($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface
|| !ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend()
) {
return;
}
/** @var DatabaseTreeDataProvider $dataProvider */
$dataProvider = $event->getProvider();
$treeData = $event->getTreeData();
if (!$GLOBALS['BE_USER']->isAdmin() && $dataProvider->getTableName() === $this->categoryTableName) {
// Get User permissions related to category
$categoryMountPoints = $GLOBALS['BE_USER']->getCategoryMountPoints();
// Backup child nodes to be processed.
$treeNodeCollection = $treeData->getChildNodes();
if (!empty($categoryMountPoints) && $treeData->hasChildNodes()) {
$shallRepopulateTree = false;
// Check the rootline against categoryMountPoints when tree was filtered
foreach ($dataProvider->getStartingPoints() as $startingPoint) {
if (!in_array($startingPoint, $categoryMountPoints)) {
$shallRepopulateTree = true;
break;
}
$uidsInRootline = $this->findUidsInRootline($startingPoint);
if (empty(array_intersect($categoryMountPoints, $uidsInRootline))) {
$shallRepopulateTree = true;
break;
}
}
if ($shallRepopulateTree) {
// First, remove all child nodes which must be analyzed to be considered as "secure".
// The nodes were backed up in variable $treeNodeCollection beforehand.
$treeData->removeChildNodes();
// Create an empty tree node collection to receive the secured nodes.
$securedTreeNodeCollection = GeneralUtility::makeInstance(TreeNodeCollection::class);
foreach ($categoryMountPoints as $categoryMountPoint) {
$treeNode = $this->lookUpCategoryMountPointInTreeNodes((int)$categoryMountPoint, $treeNodeCollection);
if ($treeNode !== null) {
$securedTreeNodeCollection->append($treeNode);
}
}
// Reset child nodes.
$treeData->setChildNodes($securedTreeNodeCollection);
}
}
}
}
/**
* Recursively look up for a category mount point within a tree.
*/
private function lookUpCategoryMountPointInTreeNodes(int $categoryMountPoint, TreeNodeCollection $treeNodeCollection): ?TreeNode
{
$result = null;
// If any User permission, recursively traverse the tree and set tree part as mount point
foreach ($treeNodeCollection as $treeNode) {
/** @var TreeNode $treeNode */
if ((int)$treeNode->getId() === $categoryMountPoint) {
$result = $treeNode;
break;
}
if ($treeNode->hasChildNodes()) {
$node = $this->lookUpCategoryMountPointInTreeNodes($categoryMountPoint, $treeNode->getChildNodes());
if ($node !== null) {
$result = $node;
break;
}
}
}
return $result;
}
/**
* Find parent uids in rootline
*/
private function findUidsInRootline(int $uid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($this->categoryTableName);
$row = $queryBuilder
->select('parent')
->from($this->categoryTableName)
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))
)
->executeQuery()
->fetchAssociative();
$parentUids = [];
if ($row['parent'] > 0) {
$parentUids = $this->findUidsInRootline($row['parent']);
$parentUids[] = $row['parent'];
}
return $parentUids;
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\ContentSecurityPolicy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Uid\UuidV4;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\NullResponse;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\InvestigateMutationsEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ModelService;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportAttribute;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportDemand;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportRepository;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportStatus;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Resolution;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ResolutionRepository;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\SummarizedReport;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
/**
* AJAX endpoint for the CSP backend module, providing access to persisted CSP reports & resolutions.
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class CspAjaxController
{
public function __construct(
protected ModelService $modelService,
protected PolicyProvider $policyProvider,
protected ReportRepository $reportRepository,
protected ResolutionRepository $resolutionRepository,
protected EventDispatcherInterface $eventDispatcher,
protected HashService $hashService,
) {}
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
if ($request->getMethod() === 'GET') {
return (new NullResponse())->withStatus(400);
}
if (!$this->isSystemMaintainer()) {
return (new NullResponse())->withStatus(403);
}
return $this->dispatchAction($request)
?? (new NullResponse())->withStatus(500);
}
protected function dispatchAction(ServerRequestInterface $request): ?ResponseInterface
{
$parsedBody = $request->getParsedBody();
$hmac = $parsedBody['hmac'] ?? null;
$action = $parsedBody['action'] ?? null;
$summaries = $parsedBody['summaries'] ?? [];
$scope = Scope::tryFrom($parsedBody['scope'] ?? '');
$uuid = $parsedBody['uuid'] ?? null;
if ($uuid !== null) {
$uuid = Uuidv4::fromString($uuid);
}
if (!empty($parsedBody['suggestion'])) {
$suggestion = $this->modelService->buildMutationSuggestionFromArray($parsedBody['suggestion']);
}
// reports
if ($action === 'fetchReports') {
return $this->fetchReportsAction($scope);
}
if ($action === 'muteReport' && is_array($summaries)) {
return $this->muteReportAction(...$summaries);
}
if ($action === 'deleteReport' && is_array($summaries)) {
return $this->deleteReportAction(...$summaries);
}
if ($action === 'deleteReports') {
return $this->deleteReportsAction($scope);
}
if ($action === 'handleReport' && $uuid !== null) {
return $this->handleReportAction($uuid);
}
if ($action === 'mutateReport'
&& $scope !== null
&& is_array($summaries)
&& isset($suggestion)
&& hash_equals($suggestion->hmac(), $hmac)
) {
return $this->mutateReportAction($scope, $suggestion, ...$summaries);
}
return null;
}
protected function fetchReportsAction(?Scope $scope): ResponseInterface
{
$demand = ReportDemand::create();
$demand->scope = $scope;
$reports = $this->reportRepository->findAllSummarized($demand);
// @todo not sure whether this is a good idea performance-wise
$reports = array_map(
function (SummarizedReport $report): SummarizedReport {
$event = $this->dispatchInvestigateMutationsEvent($report);
if ($event->getMutationSuggestions() !== []) {
$mutationHashes = array_map(
static fn(MutationSuggestion $suggestion): string => $suggestion->hash(),
$event->getMutationSuggestions()
);
$report = $report->withMutationHashes(...$mutationHashes)
->withAttribute(ReportAttribute::fixable);
}
return $report;
},
$reports
);
return new JsonResponse($reports);
}
protected function muteReportAction(string ...$summaries): ResponseInterface
{
$reports = $this->reportRepository->findBySummary(...$summaries);
$uuids = array_map(static fn(Report $report): UuidV4 => $report->uuid, $reports);
$this->reportRepository->updateStatus(ReportStatus::Muted, ...$uuids);
return new JsonResponse(['uuids' => $uuids]);
}
protected function deleteReportAction(string ...$summaries): ResponseInterface
{
$reports = $this->reportRepository->findBySummary(...$summaries);
$reportUuids = $this->resolveReportUuids(...$reports);
$this->reportRepository->updateStatus(ReportStatus::Deleted, ...$reportUuids);
return new JsonResponse(['uuids' => $reportUuids]);
}
protected function deleteReportsAction(?Scope $scope): ResponseInterface
{
$amount = $this->reportRepository->removeAll($scope);
return new JsonResponse(['amount' => $amount]);
}
protected function handleReportAction(UuidV4 $uuid): ResponseInterface
{
$report = $this->reportRepository->findByUuid($uuid);
if ($report === null) {
return new JsonResponse();
}
$event = $this->dispatchInvestigateMutationsEvent($report);
$suggestions = $event->getMutationSuggestions();
// reverse sort by priority (higher priorities take precedence)
usort($suggestions, static fn(MutationSuggestion $a, MutationSuggestion $b) => $b->priority <=> $a->priority);
return new JsonResponse($suggestions);
}
protected function mutateReportAction(Scope $scope, MutationSuggestion $suggestion, string ...$initiators): ResponseInterface
{
$summary = $this->generateResolutionSummary($scope, $suggestion);
$resolution = $this->resolutionRepository->findBySummary($summary);
$reports = $this->reportRepository->findBySummary(...$initiators);
if ($resolution !== null || $reports === []) {
return new JsonResponse();
}
$resolution = new Resolution($summary, $scope, $suggestion->identifier, $suggestion->collection, ['initiators' => $initiators]);
$this->resolutionRepository->add($resolution);
$reportUuids = $this->resolveReportUuids(...$reports);
$this->reportRepository->updateStatus(ReportStatus::Handled, ...$reportUuids);
return new JsonResponse(['initiators' => $initiators, 'uuids' => $reportUuids]);
}
protected function dispatchInvestigateMutationsEvent(Report $report): InvestigateMutationsEvent
{
// @todo for future versions, it might be considered to distinguish `enforce` and `report` in the database
$policy = $this->policyProvider->provideFor($report->scope, $report->details->resolveDisposition());
$event = new InvestigateMutationsEvent($policy, $report);
$this->eventDispatcher->dispatch($event);
return $event;
}
protected function generateResolutionSummary(Scope $scope, MutationSuggestion $suggestion): string
{
return $this->hashService->hmac(
json_encode([
$scope,
$suggestion->identifier,
$suggestion->collection,
]),
self::class,
);
}
protected function resolveReportUuids(Report ...$reports): array
{
return array_map(static fn(Report $report): UuidV4 => $report->uuid, $reports);
}
protected function isSystemMaintainer(): bool
{
$backendUser = $GLOBALS['BE_USER'] ?? null;
return $backendUser instanceof BackendUserAuthentication && $backendUser->isSystemMaintainer();
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\ContentSecurityPolicy;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ScopeRepository;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
/**
* Content-Security-Policy backend module view, loading the CSP lit-element and providing the current context.
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class CspModuleController
{
public function __construct(
protected Features $features,
protected UriBuilder $uriBuilder,
protected PageRenderer $pageRenderer,
protected ScopeRepository $scopeRepository,
protected ModuleTemplateFactory $moduleTemplateFactory,
protected IconFactory $iconFactory,
protected ComponentFactory $componentFactory,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$this->registerDocHeaderButtons($view, $request->getAttribute('module'));
$view->assignMultiple([
'configurationStatus' => $this->getConfigurationStatus(),
'scopes' => array_map(strval(...), $this->scopeRepository->findAll()),
'controlUri' => $this->uriBuilder->buildUriFromRoutePath('/ajax/security/csp/control'),
'extLowlevelAvailable' => ExtensionManagementUtility::isLoaded('lowlevel'),
]);
return $view->renderResponse('Security/CspModule');
}
protected function registerDocHeaderButtons(ModuleTemplate $view, ModuleInterface $currentModule): void
{
$view->getDocHeaderComponent()->setShortcutContext(
$currentModule->getIdentifier(),
$this->getLanguageService()->translate('title', 'backend.modules.content_security_policy')
);
$view->getDocHeaderComponent()->disableAutomaticReloadButton();
$reloadButton = $this->componentFactory
->createReloadButton((string)$this->uriBuilder->buildUriFromRoute($currentModule->getIdentifier()))
->setDataAttributes(['csp-reports-handler' => 'refresh']);
$view->addButtonToButtonBar($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT);
}
protected function getConfigurationStatus(): array
{
return [
'featureDisabled' => array_filter([
'backend' => [],
'frontend' => !$this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')
&& !$this->features->isFeatureEnabled('security.frontend.reportContentSecurityPolicy')
? ['enforce', 'report']
: [],
]),
'customReporting' => array_filter([
'BE' => $GLOBALS['TYPO3_CONF_VARS']['BE']['contentSecurityPolicyReportingUrl'] ?? '',
'FE' => $GLOBALS['TYPO3_CONF_VARS']['FE']['contentSecurityPolicyReportingUrl'] ?? '',
]),
];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mime\Exception\RfcComplianceException;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedInEvent;
use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
use TYPO3\CMS\Core\Http\ServerRequestFactory;
use TYPO3\CMS\Core\Mail\MailerInterface;
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Sends out an email if a backend user has just been logged in.
*
* Relevant settings:
* $GLOBALS['TYPO3_CONF_VARS']['BE']['warning_mode']
* $GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr']
* $BE_USER->getUserSettings()->isEmailMeAtLoginEnabled()
*
* @internal this is not part of TYPO3 API as this is an internal hook
*/
final class EmailLoginNotification
{
private int $warningMode = 0;
private string $warningEmailRecipient = '';
/**
* @var ServerRequestInterface
*/
private $request;
public function __construct(
private readonly MailerInterface $mailer,
private readonly TemplatedEmailFactory $emailFactory,
private readonly LoggerInterface $logger,
) {
$this->warningMode = (int)($GLOBALS['TYPO3_CONF_VARS']['BE']['warning_mode'] ?? 0);
$this->warningEmailRecipient = $GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr'] ?? '';
}
/**
* Sends an email notification to warning_email_address and/or the logged-in user's email address.
*/
#[AsEventListener('typo3/cms-backend/login-notification')]
public function emailAtLogin(AfterUserLoggedInEvent $event): void
{
if (!$event->getUser() instanceof BackendUserAuthentication) {
return;
}
$currentUser = $event->getUser();
$user = $currentUser->user;
$genericLoginWarning = $this->warningMode > 0 && !empty($this->warningEmailRecipient);
$userLoginNotification = $currentUser->getUserSettings()->isEmailMeAtLoginEnabled() && GeneralUtility::validEmail($user['email']);
if (!$genericLoginWarning && !$userLoginNotification) {
return;
}
$this->request = $event->getRequest() ?? $GLOBALS['TYPO3_REQUEST'] ?? ServerRequestFactory::fromGlobals()
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
if ($genericLoginWarning) {
$prefix = $currentUser->isAdmin() ? '[AdminLoginWarning]' : '[LoginWarning]';
if ($this->warningMode & 1) {
// First bit: Send warning email on any login
$this->sendEmail($this->warningEmailRecipient, $currentUser, $prefix);
} elseif ($currentUser->isAdmin() && $this->warningMode & 2) {
// Second bit: Only send warning email when an admin logs in
$this->sendEmail($this->warningEmailRecipient, $currentUser, $prefix);
}
}
// Trigger an email to the current BE user, if this has been enabled in the user configuration
if ($userLoginNotification) {
$this->sendEmail($user['email'], $currentUser);
}
}
/**
* Sends an email.
*/
private function sendEmail(string $recipient, AbstractUserAuthentication $user, ?string $subjectPrefix = null): void
{
$headline = 'TYPO3 Backend Login notification';
$recipients = explode(',', $recipient);
$email = $this->emailFactory->create($this->request)
->to(...$recipients)
->setTemplate('Security/LoginNotification')
->assignMultiple([
'user' => $user->user,
'prefix' => $subjectPrefix,
'language' => ($user->user['lang'] ?? '') ?: 'en',
'headline' => $headline,
]);
try {
$this->mailer->send($email);
} catch (TransportException $e) {
$this->logger->warning('Could not send notification email to "{recipient}" due to mailer settings error', [
'recipient' => $recipient,
'userId' => $user->user['uid'] ?? 0,
'recipientList' => $recipients,
'exception' => $e,
]);
} catch (RfcComplianceException $e) {
$this->logger->warning('Could not send notification email to "{recipient}" due to invalid email address', [
'recipient' => $recipient,
'userId' => $user->user['uid'] ?? 0,
'recipientList' => $recipients,
'exception' => $e,
]);
} catch (\Exception $e) {
// Catch all other exceptions, otherwise a failed email login notification will keep
// a user from logging in. See https://forge.typo3.org/issues/103546
$this->logger->error('Could not send notification email to "{recipient}" due to a PHP exception', [
'recipient' => $recipient,
'userId' => $user->user['uid'] ?? 0,
'recipientList' => $recipients,
'exception' => $e,
]);
}
}
}
@@ -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\Backend\Security\SudoMode\Access;
/**
* Representation of a claim (request) to a specific subject, before being granted.
* The user still has to verify, that this claim is correct, by entering their password.
*
* @internal
*/
class AccessClaim implements \JsonSerializable
{
public readonly string $id;
/**
* @var AccessSubjectInterface[]
*/
public readonly array $subjects;
public function __construct(
public readonly ServerRequestInstruction $instruction,
public readonly int $expiration,
public ?string $origin = null,
?string $id = null,
AccessSubjectInterface ...$subjects,
) {
$this->subjects = $subjects;
$this->id = $id ?? bin2hex(random_bytes(20));
}
public function jsonSerialize(): array
{
return [
'subjects' => $this->subjects,
'instruction' => $this->instruction,
'expiration' => $this->expiration,
'origin' => $this->origin,
'id' => $this->id,
];
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Access;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Routing\Route;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Factory to create `AccessClaim`, `AccessGrant` and `RouteAccessSubject` instances.
*
* @internal
*/
#[Autoconfigure(public: true)]
class AccessFactory
{
protected const DEFAULT_CLAIM_LIFETIME = 300;
protected readonly int $currentTimestamp;
public function __construct()
{
$this->currentTimestamp = (int)($GLOBALS['EXEC_TIME'] ?? time());
}
public function buildClaimFromArray(array $data): AccessClaim
{
return GeneralUtility::makeInstance(
AccessClaim::class,
ServerRequestInstruction::buildFromArray($data['instruction']),
$data['expiration'] ?? 0,
$data['origin'] ?? null,
$data['id'] ?? null,
...array_map(fn(array $subject) => $this->buildSubjectFromArray($subject), $data['subjects']),
);
}
public function buildGrantFromArray(array $data): AccessGrant
{
return GeneralUtility::makeInstance(
AccessGrant::class,
$this->buildSubjectFromArray($data['subject']),
$data['expiration']
);
}
public function buildSubjectFromArray(array $data): AccessSubjectInterface
{
$className = $data['class'] ?? '[empty]';
if (is_a($className, AccessSubjectInterface::class, true)) {
return $className::fromArray($data);
}
throw new \LogicException(
sprintf('Subject %s does not implement %s', $className, AccessSubjectInterface::class),
1605861181
);
}
public function buildRouteAccessSubject(ServerRequestInterface $request): RouteAccessSubject
{
/** @var ?Route $route */
$route = $request->getAttribute('route');
if ($route === null) {
throw new \LogicException(
'Missing route request attribute',
1605861905
);
}
$settings = $route->getOption('sudoMode');
return GeneralUtility::makeInstance(
RouteAccessSubject::class,
rtrim($route->getPath(), '/'),
$settings['lifetime'] ?? null,
$settings['group'] ?? null
);
}
public function buildTableAccessSubject(string $tableName, string $fieldName, string $id, array $settings): TableAccessSubject
{
$subjectParts = array_filter(
[$tableName, $fieldName, $id],
static fn(string $part): bool => $part !== ''
);
return GeneralUtility::makeInstance(
TableAccessSubject::class,
implode('.', $subjectParts),
$settings['lifetime'] ?? null,
$settings['group'] ?? null,
$settings['once'] ?? false,
);
}
public function buildClaimForSubjectRequest(ServerRequestInterface $request, ?string $origin, AccessSubjectInterface ...$subjects): AccessClaim
{
return GeneralUtility::makeInstance(
AccessClaim::class,
ServerRequestInstruction::createForServerRequest($request),
$this->currentTimestamp + self::DEFAULT_CLAIM_LIFETIME,
$origin,
null,
...$subjects
);
}
public function buildGrantForSubject(AccessSubjectInterface $subject): AccessGrant
{
return GeneralUtility::makeInstance(
AccessGrant::class,
$subject,
$this->currentTimestamp + $subject->getLifetime()->inSeconds()
);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Access;
/**
* Representation of any granted access to a particular subject, having an expiration time.
* The user successfully verified a previous `AccessClaim` by entering their password.
*
* @internal
*/
readonly class AccessGrant implements \JsonSerializable
{
public function __construct(
public AccessSubjectInterface $subject,
public int $expiration,
) {}
public function jsonSerialize(): array
{
return [
'subject' => $this->subject,
'expiration' => $this->expiration,
];
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Access;
/**
* Defines the lifetime of the sudo mode in a human-readable form.
*/
enum AccessLifetime: string
{
case veryShort = 'veryShort';
case short = 'short';
case medium = 'medium';
case long = 'long';
case veryLong = 'veryLong';
public function inSeconds(): int
{
return self::lifetimes()[$this] * 60;
}
private static function lifetimes(): \WeakMap
{
$map = new \WeakMap();
$map[self::veryShort] = 5;
$map[self::short] = 10;
$map[self::medium] = 15;
$map[self::long] = 30;
$map[self::veryLong] = 60;
return $map;
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Access;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
/**
* Wrapper for storing `AccessClaim` and `AccessGrant` items in the backend user session storage.
*
* @internal
*/
#[Autoconfigure(public: true)]
readonly class AccessStorage
{
protected const CLAIM_KEY = 'backend.sudo-mode.claim';
protected const GRANT_KEY = 'backend.sudo-mode.grant';
protected int $currentTimestamp;
public function __construct(
protected AccessFactory $factory,
protected LoggerInterface $logger,
) {
$this->currentTimestamp = (int)($GLOBALS['EXEC_TIME'] ?? time());
}
public function findGrantsBySubject(AccessSubjectInterface $subject): array
{
$relevantItems = array_filter(
$this->fetchGrants(),
// either group matches (if given), or subject matches
fn(array $item) => $this->subjectMatchesItem($subject, $item)
);
return array_map($this->factory->buildGrantFromArray(...), $relevantItems);
}
public function addGrant(AccessGrant $grant): void
{
$items = $this->fetchGrants();
$identity = $grant->subject->getIdentity();
if (isset($items[$identity])) {
$this->logger->warning(
sprintf('Grant %s does already exist', $identity),
$grant->jsonSerialize()
);
}
$items[$identity] = $grant;
$this->commitItems(self::GRANT_KEY, $items);
}
public function removeGrant(AccessGrant $grant): void
{
$items = $this->fetchGrants();
$identity = $grant->subject->getIdentity();
if (!isset($items[$identity])) {
$this->logger->warning(
sprintf('Grant %s does not exist', $identity),
$grant->jsonSerialize()
);
}
unset($items[$identity]);
$this->commitItems(self::GRANT_KEY, $items);
}
public function findClaimById(string $id): ?AccessClaim
{
$item = $this->fetchClaims()[$id] ?? null;
return !empty($item) ? $this->factory->buildClaimFromArray($item) : null;
}
public function findClaimBySubject(AccessSubjectInterface $subject): ?AccessClaim
{
foreach ($this->fetchClaims() as $item) {
if ($this->subjectMatchesItem($subject, $item)) {
return $this->factory->buildClaimFromArray($item);
}
}
return null;
}
public function addClaim(AccessClaim $claim): void
{
$items = $this->fetchClaims();
$items[$claim->id] = $claim;
$this->commitItems(self::CLAIM_KEY, $items);
}
public function removeClaim(AccessClaim $claim): void
{
$items = $this->fetchClaims();
unset($items[$claim->id]);
$this->commitItems(self::CLAIM_KEY, $items);
}
protected function fetchGrants(): array
{
return $this->fetchItems(self::GRANT_KEY);
}
protected function fetchClaims(): array
{
return $this->fetchItems(self::CLAIM_KEY);
}
protected function fetchItems(string $sessionKey): array
{
$sessionData = $this->getBackendUser()->getSessionData($sessionKey);
$items = json_decode((string)$sessionData, true, 16) ?? [];
$purgedItems = array_filter(
$items,
fn(array $item) => ($item['expiration'] ?? 0) >= $this->currentTimestamp
);
if (count($purgedItems) < count($items)) {
$this->commitItems($sessionKey, $purgedItems);
}
return $purgedItems;
}
protected function commitItems(string $sessionKey, array $items): void
{
// using `json_encode` here, since `UserSession` still uses PHP `serialize`
$this->getBackendUser()->setAndSaveSessionData($sessionKey, json_encode($items, JSON_INVALID_UTF8_SUBSTITUTE));
}
protected function subjectMatchesItem(AccessSubjectInterface $subject, array $item): bool
{
// either group matches (if given), or subject matches
return ($item['subject']['identity'] ?? null) === $subject->getIdentity()
|| ($subject->getGroup() !== null && ($item['subject']['group'] ?? null) === $subject->getGroup());
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -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\Backend\Security\SudoMode\Access;
/**
* Base interface for a subject that shall be handled during the sudo mode process.
* A "subject" can be a resource, a route, a database record, anything.
* Specific implementations of this interface provide the details and behavior.
*/
interface AccessSubjectInterface extends \JsonSerializable
{
/**
* Reconstitutes a subject object from its serialized representation.
*/
public static function fromArray(array $data): static;
/**
* Provides a unique string identifier of the subject.
*/
public function getIdentity(): string;
/**
* Provides the actual subject name (e.g. a route, an aspect, a resource, ...)
*/
public function getSubject(): string;
/**
* If given, grants access to same-group sudo mode subjects.
*/
public function getGroup(): ?string;
/**
* Provides a distinct lifetime type, e.g. XS, S, M, L, XL.
*/
public function getLifetime(): AccessLifetime;
/**
* If true, the subject may only be used once and requires a new grant for the same task.
*/
public function isOnce(): bool;
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Access;
/**
* Representation of a backend route (and implicitly a module) that
* shall be handled during the sudo mode process.
*
* @internal
*/
class RouteAccessSubject implements AccessSubjectInterface
{
/**
* The route subject, e.g. `/module/system/maintenance`
*/
protected string $subject;
/**
* The distinct lifetime type, e.g. XS, S, M, L, XL
*/
protected AccessLifetime $lifetime;
/**
* If given, grants access to same-group sudo mode subjects.
* Example: If access to admin tool route "maintenance" (of group "systemMaintainer")
* was granted, access to other groups, like "settings" or "upgrade" are granted as well.
*/
protected ?string $group;
public static function fromArray(array $data): static
{
$subject = $data['subject'] ?? null;
$lifetime = AccessLifetime::tryFrom($data['lifetime']);
$group = $data['group'] ?? null;
if (!is_string($subject)) {
throw new \LogicException('Property subject must be of type string', 1681111813);
}
if ($lifetime === null) {
throw new \LogicException('Property lifetime cannot be resolved', 1681111814);
}
if ($group !== null && !is_string($group)) {
throw new \LogicException('Property group must be of type string, or omitted', 1681111815);
}
return new static($subject, $lifetime, $group);
}
final public function __construct(string $subject, ?AccessLifetime $lifetime = null, ?string $group = null)
{
$this->subject = $subject;
$this->lifetime = $lifetime ?? AccessLifetime::veryShort;
$this->group = $group;
}
public function jsonSerialize(): array
{
return [
'class' => self::class,
'identity' => $this->getIdentity(),
'subject' => $this->subject,
'lifetime' => $this->lifetime->value,
'group' => $this->group,
];
}
public function getIdentity(): string
{
return sprintf('route:%s', $this->subject);
}
public function getSubject(): string
{
return $this->subject;
}
public function getGroup(): ?string
{
return $this->group;
}
public function getLifetime(): AccessLifetime
{
return $this->lifetime;
}
public function isOnce(): bool
{
return false;
}
}
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Access;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Http\Stream;
use TYPO3\CMS\Core\Http\Uri;
/**
* Reduced representation of `ServerRequest` information, which is used
* to replay the intercepted request later, once access was granted.
*
* @internal
*/
class ServerRequestInstruction implements \JsonSerializable
{
/**
* Attribute names that shall be taken from the original request
*/
protected const KEEP_ATTRIBUTE_NAMES = [
'applicationType',
];
protected string $requestTarget;
protected string $method;
protected UriInterface $uri;
protected StreamInterface $body;
protected ?array $parsedBody;
protected array $queryParams;
protected array $attributes;
protected array $serverParams;
protected array $headers;
public static function createForServerRequest(ServerRequestInterface $request): self
{
$target = new self();
$target->requestTarget = $request->getRequestTarget();
$target->method = $request->getMethod();
$target->uri = self::clone($request->getUri());
$target->body = self::clone($request->getBody());
$target->parsedBody = self::clone($request->getParsedBody());
$target->queryParams = $request->getQueryParams();
$target->serverParams = $request->getServerParams();
$target->headers = $request->getHeaders();
$target->attributes = array_filter(
$request->getAttributes(),
static fn(string $name) => in_array($name, self::KEEP_ATTRIBUTE_NAMES, true),
ARRAY_FILTER_USE_KEY
);
return $target;
}
public static function buildFromArray(array $data): self
{
$target = new self();
$target->requestTarget = $data['requestTarget'];
$target->method = $data['method'];
$target->uri = new Uri($data['uri']);
$target->body = new Stream('php://temp', 'w+b');
$target->body->write($data['body']['contents']);
$target->parsedBody = $data['parsedBody'];
$target->queryParams = $data['queryParams'];
$target->serverParams = $data['serverParams'];
$target->headers = $data['headers'];
$target->attributes = $data['attributes'] ?? [];
return $target;
}
protected static function clone($value)
{
if (is_object($value)) {
return clone $value;
}
return $value;
}
protected function __construct()
{
// avoid creating class instances directly from external
}
protected function __clone()
{
// avoid cloning class instances directly from external
}
public function jsonSerialize(): array
{
return [
'class' => self::class,
'requestTarget' => $this->requestTarget,
'method' => $this->method,
'uri' => (string)$this->uri,
'body' => [
'contents' => (string)$this->body,
],
'parsedBody' => $this->parsedBody,
'queryParams' => $this->queryParams,
'serverParams' => $this->serverParams,
'headers' => $this->headers,
'attributes' => $this->attributes,
];
}
/**
* Applies instructions to given ServerRequest ("replaying the request").
*/
public function applyTo(ServerRequestInterface $request): ServerRequestInterface
{
$request = $request
->withRequestTarget($this->requestTarget)
->withMethod($this->method)
->withUri($this->uri)
->withBody($this->body)
->withParsedBody($this->parsedBody)
->withQueryParams($this->queryParams);
foreach ($this->attributes as $name => $value) {
$request = $request->withAttribute($name, $value);
}
return $request;
}
public function getRequestTarget(): string
{
return $this->requestTarget;
}
public function getMethod(): string
{
return $this->method;
}
public function getUri(): UriInterface
{
return $this->uri;
}
public function getBody(): StreamInterface
{
return $this->body;
}
public function getParsedBody(): ?array
{
return $this->parsedBody;
}
public function getQueryParams(): array
{
return $this->queryParams;
}
public function getServerParams(): array
{
return $this->serverParams;
}
public function getHeaders(): array
{
return $this->headers;
}
public function getAttributes(): array
{
return $this->attributes;
}
}
@@ -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\Backend\Security\SudoMode\Access;
/**
* Representation of a table or table column that
* shall be handled during the sudo mode process.
*
* @internal
*/
class TableAccessSubject implements AccessSubjectInterface
{
/**
* The table column subject, e.g. `tx_foo`, `tx_foo.bar` or `tx_foo.bar.123`
*/
protected string $subject;
/**
* The distinct lifetime type, e.g. XS, S, M, L, XL
*/
protected AccessLifetime $lifetime;
/**
* If given, grants access to same-group sudo mode subjects.
*/
protected ?string $group;
/**
* If true, the subject may only be used once and requires a new grant for the same task.
*/
protected bool $once;
public static function fromArray(array $data): static
{
$subject = $data['subject'] ?? null;
$lifetime = AccessLifetime::tryFrom($data['lifetime']);
$group = $data['group'] ?? null;
$once = $data['once'] ?? null;
if (!is_string($subject)) {
throw new \LogicException('Property subject must be of type string', 1743646793);
}
if ($lifetime === null) {
throw new \LogicException('Property lifetime cannot be resolved', 1743646794);
}
if ($group !== null && !is_string($group)) {
throw new \LogicException('Property group must be of type string, or omitted', 1743646795);
}
if ($once !== null && !is_bool($once)) {
throw new \LogicException('Property once must be of type bool, or omitted', 1743646796);
}
return new static($subject, $lifetime, $group, $once);
}
final public function __construct(
string $subject,
?AccessLifetime $lifetime = null,
?string $group = null,
?bool $once = null,
) {
$this->subject = $subject;
$this->lifetime = $lifetime ?? AccessLifetime::veryShort;
$this->group = $group;
$this->once = $once;
}
public function jsonSerialize(): array
{
return [
'class' => self::class,
'identity' => $this->getIdentity(),
'subject' => $this->subject,
'lifetime' => $this->lifetime->value,
'group' => $this->group,
'once' => $this->once,
];
}
public function getIdentity(): string
{
return sprintf('table:%s', $this->subject);
}
public function getSubject(): string
{
return $this->subject;
}
public function getGroup(): ?string
{
return $this->group;
}
public function getLifetime(): AccessLifetime
{
return $this->lifetime;
}
public function isOnce(): bool
{
return $this->once;
}
}
@@ -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\Backend\Security\SudoMode\Event;
use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessClaim;
final class SudoModeRequiredEvent
{
private bool $verificationRequired = true;
public function __construct(private readonly AccessClaim $claim) {}
public function getClaim(): AccessClaim
{
return $this->claim;
}
public function isVerificationRequired(): bool
{
return $this->verificationRequired;
}
public function setVerificationRequired(bool $verificationRequired): void
{
$this->verificationRequired = $verificationRequired;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode\Event;
use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessClaim;
final class SudoModeVerifyEvent
{
private bool $verified = false;
public function __construct(
private readonly AccessClaim $claim,
#[\SensitiveParameter]
private readonly string $password,
private readonly bool $useInstallToolPassword,
) {}
public function getClaim(): AccessClaim
{
return $this->claim;
}
public function getPassword(): string
{
return $this->password;
}
public function isUseInstallToolPassword(): bool
{
return $this->useInstallToolPassword;
}
public function isVerified(): bool
{
return $this->verified;
}
public function setVerified(bool $verified): void
{
$this->verified = $verified;
}
}
@@ -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\Backend\Security\SudoMode\Exception;
use TYPO3\CMS\Backend\Security\SudoMode\Access\ServerRequestInstruction;
/**
* Exception that signals that the current user must verify the access for a
* particular resource, route, module, etc. by entering their password again.
*
* @internal
*/
final class RequestGrantedException extends \RuntimeException
{
private ServerRequestInstruction $instruction;
public function withInstruction(ServerRequestInstruction $instruction): self
{
$this->instruction = $instruction;
return $this;
}
public function getInstruction(): ServerRequestInstruction
{
return $this->instruction;
}
}
@@ -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\Backend\Security\SudoMode\Exception;
use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessClaim;
/**
* Exception that signals that the verification process was successful, and that
* the user shall be redirected to the URI, that has been requested originally.
*
* @internal
*/
final class VerificationRequiredException extends \RuntimeException
{
private AccessClaim $claim;
public function withClaim(AccessClaim $bundle): self
{
$this->claim = $bundle;
return $this;
}
public function getClaim(): AccessClaim
{
return $this->claim;
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security\SudoMode;
use TYPO3\CMS\Core\Authentication\AbstractAuthenticationService;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Install\Controller\BackendModuleController;
/**
* Service for either verifying the password of the current backend user
* session, or the admin tool password (without actually going through
* the complete authentication process).
*
* @internal
*/
readonly class PasswordVerification
{
public function __construct(
protected PasswordHashFactory $passwordHashFactory
) {}
/**
* Verifies that provided password matches Install Tool password.
*/
public function verifyInstallToolPassword(string $password): bool
{
$installToolPassword = $GLOBALS['TYPO3_CONF_VARS']['BE']['installToolPassword'] ?? null;
if ($password === '') {
return false;
}
try {
return $this->passwordHashFactory
->get($installToolPassword, 'BE')
->checkPassword($password, $installToolPassword);
} catch (InvalidPasswordHashException) {
return false;
}
}
/**
* Verifies that the provided password is actually correct for current backend user
* by stepping through the authentication chain in `$GLOBALS['BE_USER]`.
*/
public function verifyBackendUserPassword(string $password, BackendUserAuthentication $backendUser): bool
{
if ($password === '') {
return false;
}
// clone the current backend user object to avoid
// possible side effects for the real instance
$backendUser = clone $backendUser;
$loginData = [
'status' => 'sudo-mode',
'origin' => BackendModuleController::class,
'uname' => $backendUser->user['username'],
'uident' => $password,
];
// currently there is no dedicated API to perform authentication
// that's why this process partially has to be simulated here
$fakeRequest = new ServerRequest();
$loginData = $backendUser->processLoginData($loginData, $fakeRequest);
$authInfo = $backendUser->getAuthInfoArray($fakeRequest);
$authenticated = false;
/** @var AbstractAuthenticationService $service or any other service (sic!) */
foreach ($this->getAuthServices($backendUser, $loginData, $authInfo) as $service) {
if (!method_exists($service, 'authUser')) {
// The abstract does not cover this method, but the actual implementations do.
// Happy PHPStan, happy life (or so).
continue;
}
$ret = $service->authUser($backendUser->user);
if ($ret <= 0) {
return false;
}
if ($ret >= 200) {
return true;
}
if ($ret < 100) {
$authenticated = true;
}
}
return $authenticated;
}
/**
* Initializes authentication services to be used in a foreach loop
*
* @return \Generator<int, object>
*/
protected function getAuthServices(BackendUserAuthentication $backendUser, array $loginData, array $authInfo): \Generator
{
$serviceChain = [];
$subType = 'authUserBE';
while ($service = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain)) {
if (!$service instanceof AbstractAuthenticationService) {
continue;
}
$serviceChain[] = $service->getServiceKey();
$service->initAuth($subType, $loginData, $authInfo, $backendUser);
yield $service;
}
}
}