diff --git a/.gitignore b/.gitignore index 42c24f1..ee4e287 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -composer.lock -public/ -vendor/ +/composer.lock +/public/ +/vendor/ diff --git a/Classes/Backend/ConfirmationController.php b/Classes/Backend/ConfirmationController.php index 394aa1e..9375421 100644 --- a/Classes/Backend/ConfirmationController.php +++ b/Classes/Backend/ConfirmationController.php @@ -28,6 +28,7 @@ use TYPO3\CMS\Core\Http\HtmlResponse; use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; @@ -43,6 +44,8 @@ class ConfirmationController implements LoggerAwareInterface use LoggerAwareTrait; use LoggerAccessorTrait; + private const LANGUAGE_PREFIX = 'LLL:EXT:sudo_mode/Resources/Private/Language/locallang.xlf'; + protected const FLAG_INVALID_PASSWORD = 1; /** @@ -60,10 +63,21 @@ class ConfirmationController implements LoggerAwareInterface */ protected $uriBuilder; - public function __construct(ConfirmationHandler $handler = null, BackendUserAuthentication $user = null, UriBuilder $uriBuilder = null) + /** + * @var LanguageService + */ + protected $languageService; + + public function __construct( + ConfirmationHandler $handler = null, + BackendUserAuthentication $user = null, + UriBuilder $uriBuilder = null, + LanguageService $languageService = null + ) { $this->handler = $handler ?? GeneralUtility::makeInstance(ConfirmationHandler::class); $this->uriBuilder = $uriBuilder ?? GeneralUtility::makeInstance(UriBuilder::class); + $this->languageService = $languageService ?? GeneralUtility::makeInstance(LanguageService::class); $this->user = $user ?? $GLOBALS['BE_USER']; } @@ -90,23 +104,44 @@ public function mainAction(ServerRequestInterface $request): ResponseInterface protected function requestAction(ServerRequestInterface $request, ConfirmationBundle $bundle): ResponseInterface { + $isJsonRequest = $this->isJsonRequest($request); $flags = (int)($request->getQueryParams()['flags'] ?? 0); - if (!$this->isJsonRequest($request)) { - $view = $this->createView('Request'); - $view->assignMultiple([ - 'bundle' => $bundle, - 'verifyUri' => (string)$this->buildActionUriFromBundle('verify', $bundle), - 'flagInvalidPassword' => $flags & self::FLAG_INVALID_PASSWORD, - ]); - if (!empty($bundle->getRequestMetaData()->getReturnUrl())) { - $view->assign('cancelUri', (string)$this->buildActionUriFromBundle('cancel', $bundle)); - } + $verifyUri = (string)$this->buildActionUriFromBundle('verify', $bundle); + $cancelUri = (string)$this->buildActionUriFromBundle('cancel', $bundle); + + $view = $this->createView('Request'); + $view->assignMultiple([ + 'bundle' => $bundle, + 'verifyUri' => $isJsonRequest ? '#' : $verifyUri, + 'flagInvalidPassword' => $flags & self::FLAG_INVALID_PASSWORD, + 'layout' => $isJsonRequest ? 'None' : 'Module', + 'isJsonRequest' => $isJsonRequest, + ]); + // @todo Cannot cancel without knowing where to redirect to afterwards + if (!empty($bundle->getRequestMetaData()->getReturnUrl())) { + $view->assign('cancelUri', $cancelUri); + } + + if (!$isJsonRequest) { $this->applyAdditionalJavaScriptModules(); return new HtmlResponse($view->render()); } - // @todo Add JSON handling - return new JsonResponse([], 500); + return new JsonResponse([ + 'formId' => 'confirm-sudo', + 'invalidId' => 'invalid-sudo', + 'severity' => 1, // warning in Modal/Severity, + 'title' => $this->resolveLabel('sudoPasswordConfirm'), + 'content' => $this->reduceSpaces($view->render()), + 'uri' => [ + 'verify' => $verifyUri, + 'cancel' => $cancelUri, + ], + 'button' => [ + 'cancel' => $this->resolveLabel('cancel'), + 'confirm' => $this->resolveLabel('confirm'), + ], + ], 200); } /** @@ -120,43 +155,50 @@ protected function applyAdditionalJavaScriptModules(): void protected function verifyAction(ServerRequestInterface $request, ConfirmationBundle $bundle): ResponseInterface { $parsedBody = $request->getParsedBody(); + $isJsonRequest = $this->isJsonRequest($request); $confirmationPassword = empty($parsedBody['confirmationPasswordInternal']) ? (string)($parsedBody['confirmationPassword'] ?? '') // default field : $parsedBody['confirmationPasswordInternal']; // filled e.g. by `ext:rsaauth` $loggerContext = $this->createLoggerContext($bundle, $this->user); - if (!$this->isJsonRequest($request)) { - if ($this->isValidPassword($confirmationPassword)) { - $this->handler->grantSubjects($bundle, $this->user); - $this->logger->info('Password verification succeeded', $loggerContext); + if ($this->isValidPassword($confirmationPassword)) { + $this->handler->grantSubjects($bundle, $this->user); + $this->logger->info('Password verification succeeded', $loggerContext); + if (!$isJsonRequest) { throw new ServerRequestInstructionException($bundle->getRequestInstruction()); + } else { + return new JsonResponse(['status' => true], 200); } + } - $this->logger->warning('Password verification failed', $loggerContext); - $uri = $this->buildActionUriFromBundle('request', $bundle, self::FLAG_INVALID_PASSWORD); + $this->logger->warning('Password verification failed', $loggerContext); + $uri = $this->buildActionUriFromBundle('request', $bundle, self::FLAG_INVALID_PASSWORD); + + if (!$isJsonRequest) { return new RedirectResponse($uri, 401); } - // @todo Add JSON handling - return new JsonResponse([], 500); + return new JsonResponse(['status' => false], 401); } protected function cancelAction(ServerRequestInterface $request, ConfirmationBundle $bundle): ResponseInterface { $loggerContext = $this->createLoggerContext($bundle, $this->user); + $this->handler->removeConfirmationBundle($bundle, $this->user); + $this->logger->notice('Password verification cancelled', $loggerContext); + if (!$this->isJsonRequest($request)) { - $this->handler->removeConfirmationBundle($bundle, $this->user); - $this->logger->notice('Password verification cancelled', $loggerContext); return new RedirectResponse($bundle->getRequestMetaData()->getReturnUrl(), 401); } - // @todo Add JSON handling - return new JsonResponse([], 500); + return new JsonResponse(['status' => true], 200); } protected function errorAction(ServerRequestInterface $request): ResponseInterface { $view = $this->createView('Error'); - $view->assign('returnUrl', $request->getQueryParams()['returnUrl'] ?? ''); + $view->assignMultiple([ + 'returnUrl' => $request->getQueryParams()['returnUrl'] ?? '', + ]); return new HtmlResponse($view->render()); } @@ -164,18 +206,20 @@ public function buildActionUriFromBundle(string $actionName, ConfirmationBundle { return $this->buildActionUri( $actionName, - $bundle->getRequestMetaData()->getReturnUrl(), $bundle->getIdentifier(), + $bundle->getRequestMetaData()->getReturnUrl(), + $bundle->getRequestMetaData()->getScope(), $flags ); } - protected function buildActionUri(string $actionName, string $returnUrl, string $bundleIdentifier, int $flags = null): UriInterface + protected function buildActionUri(string $actionName, string $bundleIdentifier, string $returnUrl = null, string $scope = null, int $flags = null): UriInterface { $parameters = [ 'action' => $actionName, - 'returnUrl' => $returnUrl, 'bundle' => $bundleIdentifier, + 'returnUrl' => $returnUrl, + 'scope' => $scope, 'flags' => $flags, ]; $parameters['hmac'] = $this->signParameters($parameters); @@ -195,7 +239,7 @@ protected function resolveUriParameters(ServerRequestInterface $request): array protected function filterParameters(array $parameters): array { - return array_intersect_key($parameters, array_flip(['action', 'returnUrl', 'bundle', 'flags', 'hmac'])); + return array_intersect_key($parameters, array_flip(['action', 'bundle', 'returnUrl', 'scope', 'flags', 'hmac'])); } protected function signParameters(array $parameters): string @@ -280,6 +324,18 @@ protected function getAuthServices(string $subType, array $loginData, array $aut protected function isJsonRequest(ServerRequestInterface $request): bool { - return strpos($request->getHeaderLine('content-type'), 'application/json') === 0; + return strpos($request->getHeaderLine('content-type'), 'application/json') === 0 + || ($request->getQueryParams()['scope'] ?? null) === 'json'; + } + + protected function reduceSpaces(string $value): string + { + $value = preg_replace('#\s{2,}#', ' ', $value); + return trim($value); + } + + private function resolveLabel(string $identifier): string + { + return $this->languageService->sL(self::LANGUAGE_PREFIX . ':' . $identifier); } } diff --git a/Classes/Backend/ConfirmationFactory.php b/Classes/Backend/ConfirmationFactory.php index 4478873..eedc1f7 100644 --- a/Classes/Backend/ConfirmationFactory.php +++ b/Classes/Backend/ConfirmationFactory.php @@ -100,9 +100,18 @@ public function createRequestFromArray(array $data): ConfirmationRequest public function createRequestMetaDataFromArray(array $data): RequestMetaData { $target = GeneralUtility::makeInstance(RequestMetaData::class); + if (isset($data['scope'])) { + $target = $target->withScope($data['scope']); + } if (isset($data['returnUrl'])) { $target = $target->withReturnUrl($data['returnUrl']); } + if (isset($data['eventName'])) { + $target = $target->withEventName($data['eventName']); + } + if (isset($data['jsonData'])) { + $target = $target->withJsonData($data['jsonData']); + } return $target; } } diff --git a/Classes/Backend/ConfirmationHandler.php b/Classes/Backend/ConfirmationHandler.php index 2ce8bee..098c8dd 100644 --- a/Classes/Backend/ConfirmationHandler.php +++ b/Classes/Backend/ConfirmationHandler.php @@ -52,6 +52,11 @@ class ConfirmationHandler implements LoggerAwareInterface */ protected $currentTimestamp; + /** + * @var bool + */ + private $immediatePurge = false; + public function __construct(ConfirmationFactory $factory = null, Behavior $behavior = null) { $this->factory = $factory ?? GeneralUtility::makeInstance(ConfirmationFactory::class); @@ -159,7 +164,7 @@ protected function purgeSubjects(AbstractUserAuthentication $user, array $sessio continue; } foreach ($sessionData[$type] as $key => $item) { - if (is_int($item) && $item >= $this->currentTimestamp) { + if (!$this->immediatePurge && is_int($item) && $item >= $this->currentTimestamp) { continue; } unset($sessionData[$type][$key]); @@ -174,7 +179,7 @@ protected function purgeBundles(AbstractUserAuthentication $user, array $session $purged = false; foreach ($sessionData as $key => $item) { $expirationTimestamp = $item['expiration'] ?? 0; - if ($expirationTimestamp >= $this->currentTimestamp) { + if (!$this->immediatePurge && $expirationTimestamp >= $this->currentTimestamp) { continue; } $data = json_decode($item['bundle'] ?? '', true); diff --git a/Classes/Backend/RequestMetaData.php b/Classes/Backend/RequestMetaData.php index c058253..f2930e0 100644 --- a/Classes/Backend/RequestMetaData.php +++ b/Classes/Backend/RequestMetaData.php @@ -17,16 +17,38 @@ class RequestMetaData implements \JsonSerializable { + /** + * @var string|null + */ + protected $scope; + /** * @var string|null */ protected $returnUrl; + /** + * @var string|null + */ + protected $eventName; + + /** + * @var array|null + */ + protected $jsonData; + public function jsonSerialize(): array { return get_object_vars($this); } + public function withScope(string $scope): self + { + $target = clone $this; + $target->scope = $scope; + return $target; + } + public function withReturnUrl(string $returnUrl): self { $target = clone $this; @@ -34,6 +56,28 @@ public function withReturnUrl(string $returnUrl): self return $target; } + public function withEventName(string $eventName): self + { + $target = clone $this; + $target->eventName = $eventName; + return $target; + } + + public function withJsonData(array $jsonData): self + { + $target = clone $this; + $target->jsonData = $jsonData; + return $target; + } + + /** + * @return string|null + */ + public function getScope(): ?string + { + return $this->scope; + } + /** * @return string|null */ @@ -41,4 +85,20 @@ public function getReturnUrl(): ?string { return $this->returnUrl; } + + /** + * @return string|null + */ + public function getEventName(): ?string + { + return $this->eventName; + } + + /** + * @return array|null + */ + public function getJsonData(): ?array + { + return $this->jsonData; + } } diff --git a/Classes/Backend/RouteManager.php b/Classes/Backend/RouteManager.php index 5bb6936..970e744 100644 --- a/Classes/Backend/RouteManager.php +++ b/Classes/Backend/RouteManager.php @@ -15,7 +15,8 @@ * The TYPO3 project - inspiring people to share! */ -use FriendsOfTYPO3\SudoMode\Scope\CoreRouteHandler; +use FriendsOfTYPO3\SudoMode\Scope\CoreHtmlRouteHandler; +use FriendsOfTYPO3\SudoMode\Scope\CoreJsonRouteHandler; use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Backend\Routing\Route; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -24,10 +25,11 @@ class RouteManager { /** * @var RouteHandlerInterface[] - // @todo Add possibility to register 3rd party handlers + * @todo Add possibility to register 3rd party handlers */ protected $handlerClassNames = [ - CoreRouteHandler::class, + CoreHtmlRouteHandler::class, + CoreJsonRouteHandler::class, ]; /** diff --git a/Classes/Hook/BackendResourceHook.php b/Classes/Hook/BackendResourceHook.php new file mode 100644 index 0000000..d0783db --- /dev/null +++ b/Classes/Hook/BackendResourceHook.php @@ -0,0 +1,35 @@ +loadRequireJsModule('TYPO3/CMS/SudoMode/BackendEventListener'); + // load RSA auth JavaScript modules (if applicable) + GeneralUtility::makeInstance(ExternalServiceAdapter::class)->applyRsaAuthModules(); + } +} diff --git a/Classes/Middleware/RequestHandlerGuard.php b/Classes/Middleware/RequestHandlerGuard.php index 8c40f7d..7ff1cfb 100644 --- a/Classes/Middleware/RequestHandlerGuard.php +++ b/Classes/Middleware/RequestHandlerGuard.php @@ -34,6 +34,7 @@ use TYPO3\CMS\Backend\Routing\Router; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Http\RedirectResponse; use TYPO3\CMS\Core\Http\ServerRequestFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -99,7 +100,22 @@ protected function processBundle(ConfirmationBundle $bundle): ResponseInterface ->commitConfirmationBundle($bundle, $this->getBackendUser()); $uri = GeneralUtility::makeInstance(ConfirmationController::class) ->buildActionUriFromBundle('request', $bundle); - return GeneralUtility::makeInstance(RedirectResponse::class, $uri, 401); + + if ($bundle->getRequestMetaData()->getScope() === 'json') { + $eventName = $bundle->getRequestMetaData()->getEventName(); + $eventData = $bundle->getRequestMetaData()->getJsonData(); + return GeneralUtility::makeInstance( + JsonResponse::class, + [ + 'uri' => (string)$uri, + 'data' => $eventData, + ], + 403, + $eventName ? ['X-TYPO3-EmitEvent' => $eventName] : [] + ); + } else { + return GeneralUtility::makeInstance(RedirectResponse::class, $uri, 401); + } } protected function resolveRoute(ServerRequestInterface $request): ?Route diff --git a/Classes/Scope/CoreRouteHandler.php b/Classes/Scope/CoreHtmlRouteHandler.php similarity index 82% rename from Classes/Scope/CoreRouteHandler.php rename to Classes/Scope/CoreHtmlRouteHandler.php index d1e8882..cc3235f 100644 --- a/Classes/Scope/CoreRouteHandler.php +++ b/Classes/Scope/CoreHtmlRouteHandler.php @@ -22,12 +22,9 @@ use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Core\Utility\GeneralUtility; -class CoreRouteHandler implements RouteHandlerInterface +class CoreHtmlRouteHandler implements RouteHandlerInterface { - /** - * @var \Closure[] - */ - protected $handlers; + use RouteHandlerTrait; public function __construct() { @@ -39,6 +36,12 @@ public function __construct() $queryParams = $request->getQueryParams(); return GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? null); }, + // \TYPO3\CMS\Backend\Controller\SimpleDataHandlerController::mainAction + '/record/commit' => function(Route $route, ServerRequestInterface $request): string { + $parsedBody = $request->getParsedBody(); + $queryParams = $request->getQueryParams(); + return GeneralUtility::sanitizeLocalUrl($parsedBody['redirect'] ?? $queryParams['redirect'] ?? null); + }, // \TYPO3\CMS\Setup\Controller\SetupModuleController '/module/user/setup' => function(Route $route, ServerRequestInterface $request): string { $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); @@ -71,11 +74,8 @@ public function resolveMetaData(ServerRequestInterface $request, Route $route): { $routePath = $this->normalizeRoutePath($route); $returnUrl = $this->handlers[$routePath]($route, $request); - return (new RequestMetaData())->withReturnUrl($returnUrl); - } - - protected function normalizeRoutePath(Route $route): string - { - return rtrim($route->getPath(), '/'); + return (new RequestMetaData()) + ->withScope('html') + ->withReturnUrl($returnUrl); } } diff --git a/Classes/Scope/CoreJsonRouteHandler.php b/Classes/Scope/CoreJsonRouteHandler.php new file mode 100644 index 0000000..2a159be --- /dev/null +++ b/Classes/Scope/CoreJsonRouteHandler.php @@ -0,0 +1,51 @@ +handlers = [ + // \TYPO3\CMS\Backend\Controller\SimpleDataHandlerController::processAjaxRequest + '/ajax/record/process' => function(Route $route, ServerRequestInterface $request, RequestMetaData $metaData): RequestMetaData { + return $metaData + ->withEventName('sudo-mode:confirmation-request') + ->withJsonData([]); + }, + ]; + } + + public function canHandle(ServerRequestInterface $request, Route $route): bool + { + $routePath = $this->normalizeRoutePath($route); + return in_array($routePath, array_keys($this->handlers), true); + } + + public function resolveMetaData(ServerRequestInterface $request, Route $route): RequestMetaData + { + $routePath = $this->normalizeRoutePath($route); + $metaData = (new RequestMetaData())->withScope('json'); + return $this->handlers[$routePath]($route, $request, $metaData); + } +} diff --git a/Classes/Scope/RouteHandlerTrait.php b/Classes/Scope/RouteHandlerTrait.php new file mode 100644 index 0000000..7dc8497 --- /dev/null +++ b/Classes/Scope/RouteHandlerTrait.php @@ -0,0 +1,31 @@ +getPath(), '/'); + } +} diff --git a/Resources/Private/Layouts/Backend/Module.html b/Resources/Private/Layouts/Backend/Module.html index 40bca53..c56bc84 100644 --- a/Resources/Private/Layouts/Backend/Module.html +++ b/Resources/Private/Layouts/Backend/Module.html @@ -5,7 +5,23 @@ - + + + diff --git a/Resources/Private/Layouts/Backend/None.html b/Resources/Private/Layouts/Backend/None.html new file mode 100644 index 0000000..15c3167 --- /dev/null +++ b/Resources/Private/Layouts/Backend/None.html @@ -0,0 +1,8 @@ + + + + + diff --git a/Resources/Private/Partials/Backend/InvalidPasswordMessageBlock.html b/Resources/Private/Partials/Backend/InvalidPasswordMessageBlock.html new file mode 100644 index 0000000..501ec53 --- /dev/null +++ b/Resources/Private/Partials/Backend/InvalidPasswordMessageBlock.html @@ -0,0 +1,11 @@ + + +
+ +
+ + diff --git a/Resources/Private/Partials/Backend/PasswordInputBlock.html b/Resources/Private/Partials/Backend/PasswordInputBlock.html new file mode 100644 index 0000000..edf80a1 --- /dev/null +++ b/Resources/Private/Partials/Backend/PasswordInputBlock.html @@ -0,0 +1,19 @@ + + + +
+
+ + + +
+
+
+ + diff --git a/Resources/Private/Templates/Backend/Error.html b/Resources/Private/Templates/Backend/Error.html index fe9c7d4..3f1ec26 100644 --- a/Resources/Private/Templates/Backend/Error.html +++ b/Resources/Private/Templates/Backend/Error.html @@ -4,34 +4,26 @@ xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers" data-namespace-typo3-fluid="true"> - + - - + + + -