From 75732ef99b28bae600f259ab32f12aeef02c82a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20B=C3=BCrk?= <stefan@buerk.tech>
Date: Tue, 15 Nov 2022 18:43:07 +0100
Subject: [PATCH] [TASK] Replace instance cache in FormProtectionFactory
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Implementing class internal caches has been done in the
past, which however came with a lot of headaches. Mainly
in tests it's a epic quest to properly clear the state
between test runs, but it's not a good design at all.

Some work has been already done to make static class
`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory`
injectable through the DI container, which moved some
parts from static to the instance part. This allows us
to transform methods from static to non-static.

This change injects the runtime cache, if the class is
instanciated through the DI. Thus the internal static
property cache for instances is removed.

Tests are adjusted. TestCases testing deprecated method
are moved to the UnitDeprecated to avoid failing tests.

Resolves: #99098
Related: #98696
Related: #98627
Releases: main
Change-Id: Ib92bb9f73f3b1bbb4995ef01c53ca973588528bd
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/76634
Tested-by: core-ci <typo3@b13.com>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
---
 .../MfaConfigurationControllerTest.php        |   7 -
 .../Controller/MfaControllerTest.php          |   7 -
 .../FormProtection/FormProtectionFactory.php  | 188 +++++++-----------
 typo3/sysext/core/Classes/ServiceProvider.php |   2 +
 ...098-StaticUsageOfFormProtectionFactory.rst | 140 +++++++++++++
 .../Provider/RecoveryCodesProviderTest.php    |   7 -
 .../BackendUserAuthenticationTest.php         |  12 +-
 .../FormProtectionFactoryTest.php             | 101 +---------
 .../FormProtectionFactoryTest.php             | 167 ++++++++++++++++
 .../Php/MethodCallStaticMatcher.php           |  14 ++
 10 files changed, 416 insertions(+), 229 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst
 create mode 100644 typo3/sysext/core/Tests/UnitDeprecated/FormProtection/FormProtectionFactoryTest.php

diff --git a/typo3/sysext/backend/Tests/Functional/Controller/MfaConfigurationControllerTest.php b/typo3/sysext/backend/Tests/Functional/Controller/MfaConfigurationControllerTest.php
index 00062f189f92..46385fa792eb 100644
--- a/typo3/sysext/backend/Tests/Functional/Controller/MfaConfigurationControllerTest.php
+++ b/typo3/sysext/backend/Tests/Functional/Controller/MfaConfigurationControllerTest.php
@@ -27,7 +27,6 @@ use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Core\Bootstrap;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
-use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
 use TYPO3\CMS\Core\Http\NormalizedParams;
 use TYPO3\CMS\Core\Http\ServerRequest;
 use TYPO3\CMS\Core\Imaging\IconFactory;
@@ -68,12 +67,6 @@ class MfaConfigurationControllerTest extends FunctionalTestCase
         $this->normalizedParams = new NormalizedParams([], [], '', '');
     }
 
-    protected function tearDown(): void
-    {
-        FormProtectionFactory::purgeInstances();
-        parent::tearDown();
-    }
-
     /**
      * @test
      */
diff --git a/typo3/sysext/backend/Tests/Functional/Controller/MfaControllerTest.php b/typo3/sysext/backend/Tests/Functional/Controller/MfaControllerTest.php
index 1bb4fdf0e822..ca59bb603504 100644
--- a/typo3/sysext/backend/Tests/Functional/Controller/MfaControllerTest.php
+++ b/typo3/sysext/backend/Tests/Functional/Controller/MfaControllerTest.php
@@ -28,7 +28,6 @@ use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Core\Bootstrap;
 use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
-use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
 use TYPO3\CMS\Core\Http\ServerRequest;
 use TYPO3\CMS\Core\Log\Logger;
 use TYPO3\CMS\Core\Page\PageRenderer;
@@ -76,12 +75,6 @@ class MfaControllerTest extends FunctionalTestCase
             ->withAttribute('route', new Route('path', ['packageName' => 'typo3/cms-backend']));
     }
 
-    protected function tearDown(): void
-    {
-        FormProtectionFactory::purgeInstances();
-        parent::tearDown();
-    }
-
     /**
      * @test
      */
diff --git a/typo3/sysext/core/Classes/FormProtection/FormProtectionFactory.php b/typo3/sysext/core/Classes/FormProtection/FormProtectionFactory.php
index d72d74b9a1dc..1d2af659cec0 100644
--- a/typo3/sysext/core/Classes/FormProtection/FormProtectionFactory.php
+++ b/typo3/sysext/core/Classes/FormProtection/FormProtectionFactory.php
@@ -19,6 +19,7 @@ namespace TYPO3\CMS\Core\FormProtection;
 
 use Psr\Http\Message\ServerRequestInterface;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
 use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
 use TYPO3\CMS\Core\Localization\LanguageService;
 use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
@@ -41,17 +42,11 @@ use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
  */
 class FormProtectionFactory
 {
-    /**
-     * created instances of form protections using the type as array key
-     *
-     * @var array<string, AbstractFormProtection>
-     */
-    protected static array $instances = [];
-
     public function __construct(
         protected readonly FlashMessageService $flashMessageService,
         protected readonly LanguageServiceFactory $languageServiceFactory,
-        protected readonly Registry $registry
+        protected readonly Registry $registry,
+        protected readonly FrontendInterface $runtimeCache
     ) {
     }
 
@@ -65,12 +60,13 @@ class FormProtectionFactory
         if (!in_array($type, ['installtool', 'frontend', 'backend', 'disabled'], true)) {
             $type = 'disabled';
         }
-        if (isset(self::$instances[$type])) {
-            return self::$instances[$type];
+        $identifier = $this->getIdentifierForType($type);
+        if ($this->runtimeCache->has($identifier)) {
+            return $this->runtimeCache->get($identifier);
         }
         $classNameAndConstructorArguments = $this->getClassNameAndConstructorArguments($type, $GLOBALS['TYPO3_REQUEST'] ?? null);
-        self::$instances[$type] = self::createInstance(...$classNameAndConstructorArguments);
-        return self::$instances[$type];
+        $this->runtimeCache->set($identifier, $this->createInstance(...$classNameAndConstructorArguments));
+        return $this->runtimeCache->get($identifier);
     }
 
     /**
@@ -80,12 +76,13 @@ class FormProtectionFactory
     public function createFromRequest(ServerRequestInterface $request): AbstractFormProtection
     {
         $type = $this->determineTypeFromRequest($request);
-        if (isset(self::$instances[$type])) {
-            return self::$instances[$type];
+        $identifier = $this->getIdentifierForType($type);
+        if ($this->runtimeCache->has($identifier)) {
+            return $this->runtimeCache->get($identifier);
         }
         $classNameAndConstructorArguments = $this->getClassNameAndConstructorArguments($type, $request);
-        self::$instances[$type] = self::createInstance(...$classNameAndConstructorArguments);
-        return self::$instances[$type];
+        $this->runtimeCache->set($identifier, $this->createInstance(...$classNameAndConstructorArguments));
+        return $this->runtimeCache->get($identifier);
     }
 
     /**
@@ -93,13 +90,13 @@ class FormProtectionFactory
      */
     protected function determineTypeFromRequest(ServerRequestInterface $request): string
     {
-        if (self::isInstallToolSession($request)) {
+        if ($this->isInstallToolSession($request)) {
             return 'installtool';
         }
-        if (self::isFrontendSession($request)) {
+        if ($this->isFrontendSession($request)) {
             return 'frontend';
         }
-        if (self::isBackendSession($request)) {
+        if ($this->isBackendSession($request)) {
             return 'backend';
         }
         return 'disabled';
@@ -137,7 +134,7 @@ class FormProtectionFactory
                     BackendFormProtection::class,
                     $user,
                     $this->registry,
-                    self::getMessageClosure(
+                    $this->getMessageClosure(
                         $this->languageServiceFactory->createFromUserPreferences($user),
                         $this->flashMessageService->getMessageQueueByIdentifier(),
                         $isAjaxCall
@@ -151,6 +148,14 @@ class FormProtectionFactory
         ];
     }
 
+    /**
+     * Conveniant method to create a deterministic cache identifier.
+     */
+    protected function getIdentifierForType(string $type): string
+    {
+        return 'formprotection-instance-' . hash('xxh3', $type);
+    }
+
     /**
      * Gets a form protection instance for the requested type or class.
      *
@@ -163,114 +168,82 @@ class FormProtectionFactory
      *                                frontend, backend, installtool
      * @param array<int,mixed> $constructorArguments Arguments for the class-constructor
      * @return \TYPO3\CMS\Core\FormProtection\AbstractFormProtection the requested instance
+     *
+     * @deprecated since v12, will be removed in v13 together with createForTypeWithArguments. Use a instance of FormProtectionFactory directly.
+     * @see self::createFromRequest()
+     * @see self::createForType()
+     * @see self::createForClass()
      */
     public static function get($classNameOrType = 'default', ...$constructorArguments)
     {
-        if (isset(self::$instances[$classNameOrType])) {
-            return self::$instances[$classNameOrType];
-        }
-        if ($classNameOrType === 'default' || $classNameOrType === 'installtool' || $classNameOrType === 'frontend' || $classNameOrType === 'backend') {
-            $classNameAndConstructorArguments = self::getClassNameAndConstructorArgumentsByType($classNameOrType);
-            self::$instances[$classNameOrType] = self::createInstance(...$classNameAndConstructorArguments);
-        } else {
-            self::$instances[$classNameOrType] = self::createInstance($classNameOrType, ...$constructorArguments);
+        trigger_error(
+            __METHOD__ . ' will be removed in TYPO3 v13.0. Use a instance of ' . __CLASS__ . ' directly.',
+            E_USER_DEPRECATED
+        );
+        if (($classNameOrType === 'default' || $classNameOrType === 'installtool' || $classNameOrType === 'frontend' || $classNameOrType === 'backend')) {
+            return GeneralUtility::makeInstance(FormProtectionFactory::class)
+                ->createForType($classNameOrType);
         }
-        return self::$instances[$classNameOrType];
+        return GeneralUtility::makeInstance(FormProtectionFactory::class)
+            ->createForClass($classNameOrType, ...$constructorArguments);
     }
 
     /**
-     * Returns the class name and parameters depending on the given type.
-     * If the type cannot be used currently, protection is disabled.
+     * Create a concrete FormProtection implementation, using the provided arguments as constructor arguments.
+     * Should be used instead of FormProtectionFactory::get() is a custom FormProtection implementation must
+     * be instantiated.
      *
-     * @param string $type Valid types: default, installtool, frontend, backend. "default" makes an autodetection on the current state
-     * @return array Array of arguments
+     * For provided core implementation use
+     *
+     *      // auto-detected based on request
+     *      GeneralUtility::makeInstance(FormProtectionFactory::class)
+     *          ->createFromRequest($GLOBAL['TYPO3_REQUEST']);
+     * or
+     *      // concrete implementation
+     *      GeneralUtility::makeInstance(FormProtectionFactory::class)
+     *          ->createForType($type); // 'installtool', 'backend', 'frontend', 'disabled'
+     *
+     * @param string $className Name of a form protection class
+     * @param array<int,mixed> $constructorArguments Arguments for the class-constructor
+     * @deprecated since v12, will be removed in v13 together with get().
      */
-    protected static function getClassNameAndConstructorArgumentsByType($type, ServerRequestInterface $request = null)
+    protected function createForClass(string $className, ...$constructorArguments): AbstractFormProtection
     {
-        if (self::isInstallToolSession($request) && ($type === 'default' || $type === 'installtool')) {
-            $classNameAndConstructorArguments = [
-                InstallToolFormProtection::class,
-            ];
-        } elseif (self::isFrontendSession($request) && ($type === 'default' || $type === 'frontend')) {
-            $classNameAndConstructorArguments = [
-                FrontendFormProtection::class,
-                $GLOBALS['TSFE']->fe_user,
-            ];
-        } elseif (self::isBackendSession($request) && ($type === 'default' || $type === 'backend')) {
-            $isAjaxCall = false;
-            $request = $request ?? $GLOBALS['TYPO3_REQUEST'] ?? null;
-            if ($request instanceof ServerRequestInterface
-                && (bool)($request->getAttribute('route')?->getOption('ajax'))
-            ) {
-                $isAjaxCall = true;
-            }
-            $classNameAndConstructorArguments = [
-                BackendFormProtection::class,
-                $GLOBALS['BE_USER'],
-                GeneralUtility::makeInstance(Registry::class),
-                self::getMessageClosure(
-                    $GLOBALS['LANG'],
-                    GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier(),
-                    $isAjaxCall
-                ),
-            ];
-        } else {
-            // failed to use preferred type, disable form protection
-            $classNameAndConstructorArguments = [
-                DisabledFormProtection::class,
-            ];
+        $identifier = $this->getIdentifierForType($className);
+        if ($this->runtimeCache->has($identifier)) {
+            return $this->runtimeCache->get($identifier);
         }
-        return $classNameAndConstructorArguments;
+        $this->runtimeCache->set($identifier, $this->createInstance($className, ...$constructorArguments));
+        return $this->runtimeCache->get($identifier);
     }
 
     /**
      * Check if we are in the install tool
      */
-    protected static function isInstallToolSession(?ServerRequestInterface $request = null): bool
+    protected function isInstallToolSession(ServerRequestInterface $request): bool
     {
-        $isInstallTool = false;
-        $request = $request ?? $GLOBALS['TYPO3_REQUEST'] ?? null;
-        if ($request instanceof ServerRequestInterface
-            && (bool)((int)$request->getAttribute('applicationType') & SystemEnvironmentBuilder::REQUESTTYPE_INSTALL)
-        ) {
-            $isInstallTool = true;
-        }
-        return $isInstallTool;
+        return (bool)((int)$request->getAttribute('applicationType') & SystemEnvironmentBuilder::REQUESTTYPE_INSTALL);
     }
 
     /**
      * Checks if a user is logged in and the session is active.
      */
-    protected static function isBackendSession(?ServerRequestInterface $request = null): bool
+    protected function isBackendSession(ServerRequestInterface $request): bool
     {
-        if ($request instanceof ServerRequestInterface) {
-            $user = $request->getAttribute('backend.user', $GLOBALS['BE_USER'] ?? null);
-        } else {
-            $user = $GLOBALS['BE_USER'] ?? null;
-        }
+        $user = $request->getAttribute('backend.user', $GLOBALS['BE_USER'] ?? null);
         return $user instanceof BackendUserAuthentication && isset($user->user['uid']);
     }
 
     /**
      * Checks if a frontend user is logged in and the session is active.
      */
-    protected static function isFrontendSession(?ServerRequestInterface $request = null): bool
+    protected function isFrontendSession(ServerRequestInterface $request): bool
     {
-        if ($request instanceof ServerRequestInterface) {
-            $user = $request->getAttribute('frontend.user');
-        } else {
-            $user = ($GLOBALS['TSFE'] ?? null) instanceof TypoScriptFrontendController ? $GLOBALS['TSFE']->fe_user : null;
-        }
+        $user = $request->getAttribute('frontend.user');
         return $user instanceof FrontendUserAuthentication && isset($user->user['uid']);
     }
 
-    /**
-     * @param LanguageService $languageService
-     * @param FlashMessageQueue $messageQueue
-     * @param bool $isAjaxCall
-     * @return \Closure
-     */
-    protected static function getMessageClosure(LanguageService $languageService, FlashMessageQueue $messageQueue, bool $isAjaxCall)
+    protected function getMessageClosure(LanguageService $languageService, FlashMessageQueue $messageQueue, bool $isAjaxCall): \Closure
     {
         return static function () use ($languageService, $messageQueue, $isAjaxCall) {
             $flashMessage = GeneralUtility::makeInstance(
@@ -288,12 +261,11 @@ class FormProtectionFactory
      * Creates an instance for the requested class $className
      * and stores it internally.
      *
-     * @param string $className
+     * @param class-string $className
      * @param array<int,mixed> $constructorArguments
      * @throws \InvalidArgumentException
-     * @return AbstractFormProtection
      */
-    protected static function createInstance($className, ...$constructorArguments)
+    protected function createInstance(string $className, ...$constructorArguments): AbstractFormProtection
     {
         if (!class_exists($className)) {
             throw new \InvalidArgumentException('$className must be the name of an existing class, but actually was "' . $className . '".', 1285352962);
@@ -309,20 +281,14 @@ class FormProtectionFactory
      * Purges all existing instances.
      *
      * This function is particularly useful when cleaning up in unit testing.
+     *
+     * @deprecated since v12, will be removed in v13. Internal cache has been replaced by runtime cache.
      */
-    public static function purgeInstances()
-    {
-        foreach (self::$instances as $key => $instance) {
-            unset(self::$instances[$key]);
-        }
-    }
-
-    /**
-     * Only used in test for non-static calls
-     * @internal
-     */
-    public function clearInstances(): void
+    public static function purgeInstances(): void
     {
-        self::$instances = [];
+        trigger_error(
+            __METHOD__ . ' will be removed in TYPO3 v13.0. Cache has been replaced with runtime cache.',
+            E_USER_DEPRECATED
+        );
     }
 }
diff --git a/typo3/sysext/core/Classes/ServiceProvider.php b/typo3/sysext/core/Classes/ServiceProvider.php
index 94846244e7a5..d5860759c84b 100644
--- a/typo3/sysext/core/Classes/ServiceProvider.php
+++ b/typo3/sysext/core/Classes/ServiceProvider.php
@@ -23,6 +23,7 @@ use Psr\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\Console\Command\HelpCommand;
 use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as SymfonyEventDispatcherInterface;
+use TYPO3\CMS\Core\Cache\CacheManager;
 use TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\DependencyInjection\ContainerBuilder;
@@ -512,6 +513,7 @@ class ServiceProvider extends AbstractServiceProvider
                 $container->get(Messaging\FlashMessageService::class),
                 $container->get(Localization\LanguageServiceFactory::class),
                 $container->get(Registry::class),
+                $container->get(CacheManager::class)->getCache('runtime'),
             ]
         );
     }
diff --git a/typo3/sysext/core/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst b/typo3/sysext/core/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst
new file mode 100644
index 000000000000..49d43fb0732a
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst
@@ -0,0 +1,140 @@
+.. include:: /Includes.rst.txt
+
+.. _deprecation-99098-1668546853:
+
+===========================================================
+Deprecation: #99098 - Static usage of FormProtectionFactory
+===========================================================
+
+See :issue:`99098`
+
+Description
+===========
+
+:php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory` has been
+constructed in a static class manner in TYPO3 v6.2, using a static property
+based instance cache to avoid recreating instances for a specific typed
+FormProtection implementation. This design made it impossible to retrieve an
+instance of this class via dependency injection. Another side-effect was that
+ensuring properly cleared state between tests has been hard and often
+populated to other tests and thus influencing them.
+
+To mitigate these issues, :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory`
+is now transformed to a non-static class usage with injected services
+and the core runtime cache, removing the static property cache.
+
+Based on these changes, the old static methods :php:`get()` and
+:php:`purgeInstances()` are now deprecated.
+
+There are two general ways to get a specific FormProtection implementation:
+
+* auto-detected from request: :php:`$formProtectionFactory->createFromRequest()`
+* create for a specific type: :php:`$formProtectionFactory->createForType()`
+
+Possible types for :php:`$formProtectionFactory->createForType()` are `frontend`
+`backend`, `installtool` or `disabled`.
+
+
+Impact
+======
+
+Using any of following class methods
+
+* :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory::get()`
+* :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory::purgeInstances()`
+
+will trigger a PHP deprecation notice and will throw a fatal PHP error in
+TYPO3 v13.
+
+
+Affected installations
+======================
+
+The extension scanner will find extensions calling :php:`FormProtectionFactory::get()`
+or :php:`FormProtectionFactory::purgeInstances()` as "strong" matches.
+
+
+Migration
+=========
+
+Provided implementation by TYPO3 core
+-------------------------------------
+
+Before
+
+..  code-block:: php
+
+    // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
+
+    // BackendFormProtection
+    $formProtection = FormProtectionFactory::get(BackendFormProtection::class);
+    $formProtection = FormProtectionFactory::get('backend');
+
+    // FrontendFormProtection
+    $formProtection = FormProtectionFactory::get(FrontedFormProtection::class);
+    $formProtection = FormProtectionFactory::get('frontend');
+
+    // Default / Disabled FormProtection
+    $formProtection = FormProtectionFactory::get(DisabledFormProtection::class);
+    $formProtection = FormProtectionFactory::get('default');
+
+After
+
+It is recommended to use :php:`FormProtectionFactory->createForRequest()` to
+auto-detect which type is needed and return the corresponding instance:
+
+..  code-block:: php
+
+    // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
+
+    // Better: Get FormProtectionFactory injected by DI.
+    $formProtectionFactory = GeneralUtility::makeInstance(FormProtectionFactory::class);
+    // $request is assumed to be available, for instance in controller classes.
+    $formProtection = $formProtectionFactory->createFromRequest($request);
+
+To create a specific type directly, using following replacements:
+
+..  code-block:: php
+
+    // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
+    // Better: Get FormProtectionFactory injected by DI.
+    $formProtectionFactory = GeneralUtility::makeInstance(FormProtectionFactory::class);
+
+    // BackendFormProtection
+    $formProtection = $formProtectionFactory->createFromType('backend');
+
+    // FrontendFormProtection
+    $formProtection = $formProtectionFactory->createFromType('frontend');
+
+    // Default / Disabled FormProtection
+    $formProtection = $formProtectionFactory->createFromType('disabled');
+
+Custom FormProtection based implementation
+------------------------------------------
+
+Before
+
+..  code-block:: php
+
+    // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
+
+    $formProtection = FormProtectionFactory::get(
+        Vendor\ExtensionKey\FormProtection\CustomFormProtection::class,
+        $customService,
+        'someDirectValue',
+        ...
+    );
+
+After
+
+..  code-block:: php
+
+    // Create an instance of the class yourself, take care of an
+    // instance cache if needed.
+    GeneralUtility::makeInstance(
+        Vendor\ExtensionKey\FormProtection\CustomFormProtection::class,
+        $constructorArguments
+    );
+
+
+.. index:: PHP-API, FullyScanned, ext:core
diff --git a/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php
index 014f980db276..83d58079f63b 100644
--- a/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php
+++ b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php
@@ -25,7 +25,6 @@ use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
 use TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodes;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
-use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
 use TYPO3\CMS\Core\Http\PropagateResponseException;
 use TYPO3\CMS\Core\Http\ServerRequest;
 use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
@@ -61,12 +60,6 @@ class RecoveryCodesProviderTest extends FunctionalTestCase
         $this->subject = $this->get(MfaProviderRegistry::class)->getProvider('recovery-codes');
     }
 
-    protected function tearDown(): void
-    {
-        FormProtectionFactory::purgeInstances();
-        parent::tearDown();
-    }
-
     /**
      * @test
      */
diff --git a/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php b/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
index a67ed5824ea0..d24e57e27d64 100644
--- a/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
+++ b/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
@@ -20,6 +20,8 @@ namespace TYPO3\CMS\Core\Tests\Unit\Authentication;
 use Psr\Log\NullLogger;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Authentication\IpLocker;
+use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend;
+use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
 use TYPO3\CMS\Core\Database\Connection;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
@@ -40,12 +42,6 @@ use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 
 class BackendUserAuthenticationTest extends UnitTestCase
 {
-    public function tearDown(): void
-    {
-        FormProtectionFactory::purgeInstances();
-        parent::tearDown();
-    }
-
     /**
      * @test
      */
@@ -63,10 +59,12 @@ class BackendUserAuthenticationTest extends UnitTestCase
         $formProtectionMock = $this->createMock(BackendFormProtection::class);
         $formProtectionMock->expects(self::once())->method('clean');
 
+        $runtimeCache = new VariableFrontend('null', new TransientMemoryBackend('null', ['logger' => new NullLogger()]));
         $formProtectionFactory = new FormProtectionFactory(
             $this->createMock(FlashMessageService::class),
             $this->createMock(LanguageServiceFactory::class),
-            $this->createMock(Registry::class)
+            $this->createMock(Registry::class),
+            $runtimeCache
         );
         GeneralUtility::addInstance(FormProtectionFactory::class, $formProtectionFactory);
         GeneralUtility::addInstance(BackendFormProtection::class, $formProtectionMock);
diff --git a/typo3/sysext/core/Tests/Unit/FormProtection/FormProtectionFactoryTest.php b/typo3/sysext/core/Tests/Unit/FormProtection/FormProtectionFactoryTest.php
index 7f0537106d33..a31b3c3ec6c5 100644
--- a/typo3/sysext/core/Tests/Unit/FormProtection/FormProtectionFactoryTest.php
+++ b/typo3/sysext/core/Tests/Unit/FormProtection/FormProtectionFactoryTest.php
@@ -17,8 +17,12 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Core\Tests\Unit\FormProtection;
 
+use Psr\Log\NullLogger;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend;
+use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
 use TYPO3\CMS\Core\Cache\Frontend\NullFrontend;
+use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
 use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
 use TYPO3\CMS\Core\FormProtection\DisabledFormProtection;
 use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
@@ -34,9 +38,11 @@ use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 class FormProtectionFactoryTest extends UnitTestCase
 {
     protected FormProtectionFactory $subject;
+    protected FrontendInterface $runtimeCacheMock;
 
     protected function setUp(): void
     {
+        $this->runtimeCacheMock = new VariableFrontend('null', new TransientMemoryBackend('null', ['logger' => new NullLogger()]));
         $this->subject = new FormProtectionFactory(
             new FlashMessageService(),
             new LanguageServiceFactory(
@@ -44,104 +50,18 @@ class FormProtectionFactoryTest extends UnitTestCase
                 $this->createMock(LocalizationFactory::class),
                 new NullFrontend('null')
             ),
-            new Registry()
+            new Registry(),
+            $this->runtimeCacheMock
         );
         parent::setUp();
     }
 
     protected function tearDown(): void
     {
-        FormProtectionFactory::purgeInstances();
+        $this->runtimeCacheMock->flush();
         parent::tearDown();
     }
 
-    /////////////////////////
-    // Tests concerning get
-    /////////////////////////
-    /**
-     * @test
-     */
-    public function getForNotExistingClassThrowsException(): void
-    {
-        $this->expectException(\InvalidArgumentException::class);
-        $this->expectExceptionCode(1285352962);
-
-        FormProtectionFactory::get('noSuchClass');
-    }
-
-    /**
-     * @test
-     */
-    public function getForClassThatIsNoFormProtectionSubclassThrowsException(): void
-    {
-        $this->expectException(\InvalidArgumentException::class);
-        $this->expectExceptionCode(1285353026);
-
-        FormProtectionFactory::get(self::class);
-    }
-
-    /**
-     * @test
-     */
-    public function getForTypeBackEndWithExistingBackEndReturnsBackEndFormProtection(): void
-    {
-        $userMock = $this->createMock(BackendUserAuthentication::class);
-        $userMock->user = ['uid' => 4711];
-        self::assertInstanceOf(
-            BackendFormProtection::class,
-            FormProtectionFactory::get(
-                BackendFormProtection::class,
-                $userMock,
-                $this->createMock(Registry::class)
-            )
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function getForTypeBackEndCalledTwoTimesReturnsTheSameInstance(): void
-    {
-        $userMock = $this->createMock(BackendUserAuthentication::class);
-        $userMock->user = ['uid' => 4711];
-        $arguments = [
-            BackendFormProtection::class,
-            $userMock,
-            $this->createMock(Registry::class),
-        ];
-        self::assertSame(
-            FormProtectionFactory::get(...$arguments),
-            FormProtectionFactory::get(...$arguments)
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function getForTypeInstallToolReturnsInstallToolFormProtection(): void
-    {
-        self::assertInstanceOf(
-            InstallToolFormProtection::class,
-            FormProtectionFactory::get(InstallToolFormProtection::class)
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function getForTypeInstallToolCalledTwoTimesReturnsTheSameInstance(): void
-    {
-        self::assertSame(FormProtectionFactory::get(InstallToolFormProtection::class), FormProtectionFactory::get(InstallToolFormProtection::class));
-    }
-
-    /**
-     * @test
-     */
-    public function getForTypesInstallToolAndDisabledReturnsDifferentInstances(): void
-    {
-        self::assertNotSame(FormProtectionFactory::get(InstallToolFormProtection::class), FormProtectionFactory::get(DisabledFormProtection::class));
-    }
-
     /**
      * @test
      */
@@ -225,7 +145,8 @@ class FormProtectionFactoryTest extends UnitTestCase
         $formProtection = $this->subject->createForType('backend');
         // User is now logged in, but we still get the disabled form protection due to the "singleton" concept
         self::assertInstanceOf(DisabledFormProtection::class, $formProtection);
-        $this->subject->clearInstances();
+        // we need to manually flush this here, aas next test should expect a cleared state.
+        $this->runtimeCacheMock->flush();
         $formProtection = $this->subject->createForType('backend');
         // User is now logged in, now we get a backend form protection, due to the "purge instance" concept.
         self::assertInstanceOf(BackendFormProtection::class, $formProtection);
diff --git a/typo3/sysext/core/Tests/UnitDeprecated/FormProtection/FormProtectionFactoryTest.php b/typo3/sysext/core/Tests/UnitDeprecated/FormProtection/FormProtectionFactoryTest.php
new file mode 100644
index 000000000000..6aabb4d68741
--- /dev/null
+++ b/typo3/sysext/core/Tests/UnitDeprecated/FormProtection/FormProtectionFactoryTest.php
@@ -0,0 +1,167 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\UnitDeprecated\FormProtection;
+
+use Psr\Log\NullLogger;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend;
+use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
+use TYPO3\CMS\Core\Cache\Frontend\NullFrontend;
+use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
+use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
+use TYPO3\CMS\Core\FormProtection\DisabledFormProtection;
+use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
+use TYPO3\CMS\Core\FormProtection\InstallToolFormProtection;
+use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
+use TYPO3\CMS\Core\Localization\Locales;
+use TYPO3\CMS\Core\Localization\LocalizationFactory;
+use TYPO3\CMS\Core\Messaging\FlashMessageService;
+use TYPO3\CMS\Core\Registry;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class FormProtectionFactoryTest extends UnitTestCase
+{
+    protected FormProtectionFactory $subject;
+    protected FrontendInterface $runtimeCacheMock;
+
+    protected function setUp(): void
+    {
+        $this->runtimeCacheMock = new VariableFrontend('null', new TransientMemoryBackend('null', ['logger' => new NullLogger()]));
+        $this->subject = new FormProtectionFactory(
+            new FlashMessageService(),
+            new LanguageServiceFactory(
+                new Locales(),
+                $this->createMock(LocalizationFactory::class),
+                new NullFrontend('null')
+            ),
+            new Registry(),
+            $this->runtimeCacheMock
+        );
+        parent::setUp();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->runtimeCacheMock->flush();
+        parent::tearDown();
+    }
+
+    /////////////////////////
+    // Tests concerning get
+    /////////////////////////
+    /**
+     * @test
+     */
+    public function getForNotExistingClassThrowsException(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1285352962);
+
+        FormProtectionFactory::get('noSuchClass');
+    }
+
+    /**
+     * @test
+     */
+    public function getForClassThatIsNoFormProtectionSubclassThrowsException(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1285353026);
+
+        FormProtectionFactory::get(self::class);
+    }
+
+    /**
+     * @test
+     */
+    public function getForTypeBackEndWithExistingBackEndReturnsBackEndFormProtection(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        $userMock = $this->createMock(BackendUserAuthentication::class);
+        $userMock->user = ['uid' => 4711];
+        self::assertInstanceOf(
+            BackendFormProtection::class,
+            FormProtectionFactory::get(
+                BackendFormProtection::class,
+                $userMock,
+                $this->createMock(Registry::class)
+            )
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function getForTypeBackEndCalledTwoTimesReturnsTheSameInstance(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        $userMock = $this->createMock(BackendUserAuthentication::class);
+        $userMock->user = ['uid' => 4711];
+        $arguments = [
+            BackendFormProtection::class,
+            $userMock,
+            $this->createMock(Registry::class),
+        ];
+        self::assertSame(
+            FormProtectionFactory::get(...$arguments),
+            FormProtectionFactory::get(...$arguments)
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function getForTypeInstallToolReturnsInstallToolFormProtection(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        self::assertInstanceOf(
+            InstallToolFormProtection::class,
+            FormProtectionFactory::get(InstallToolFormProtection::class)
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function getForTypeInstallToolCalledTwoTimesReturnsTheSameInstance(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        self::assertSame(
+            FormProtectionFactory::get(InstallToolFormProtection::class),
+            FormProtectionFactory::get(InstallToolFormProtection::class)
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function getForTypesInstallToolAndDisabledReturnsDifferentInstances(): void
+    {
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        GeneralUtility::addInstance(FormProtectionFactory::class, $this->subject);
+        self::assertNotSame(
+            FormProtectionFactory::get(InstallToolFormProtection::class),
+            FormProtectionFactory::get(DisabledFormProtection::class)
+        );
+    }
+}
diff --git a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallStaticMatcher.php b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallStaticMatcher.php
index 06e09a3fe193..0de5e39e5bae 100644
--- a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallStaticMatcher.php
+++ b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallStaticMatcher.php
@@ -1408,4 +1408,18 @@ return [
             'Feature-98487-TCAOptionCtrlsecurityignorePageTypeRestriction.rst',
         ],
     ],
+    'TYPO3\CMS\Core\FormProtection\FormProtectionFactory::get' => [
+        'numberOfMandatoryArguments' => 1,
+        'maximumNumberOfArguments' => 99,
+        'restFiles' => [
+            'Deprecation-99098-StaticUsageOfFormProtectionFactory.rst',
+        ],
+    ],
+    'TYPO3\CMS\Core\FormProtection\FormProtectionFactory::purgeInstances' => [
+        'numberOfMandatoryArguments' => 1,
+        'maximumNumberOfArguments' => 99,
+        'restFiles' => [
+            'Deprecation-99098-StaticUsageOfFormProtectionFactory.rst',
+        ],
+    ],
 ];
-- 
GitLab