From ccc21be9ec6c21f9dcc5d93bd3090c4abc24402a Mon Sep 17 00:00:00 2001 From: Tim van Dijen <tvdijen@gmail.com> Date: Fri, 4 Mar 2022 17:00:44 +0100 Subject: [PATCH] Migrate logout-scripts to controllers --- lib/SimpleSAML/IdP.php | 2 +- modules/core/lib/Controller/Login.php | 22 +- modules/core/lib/Controller/Logout.php | 400 ++++++++++++++++++ modules/core/routing/routes/routes.yml | 18 +- .../core/templates/logout-iframe-wrapper.twig | 2 +- modules/core/templates/logout-iframe.twig | 2 +- modules/core/www/idp/logout-iframe-done.php | 59 --- modules/core/www/idp/logout-iframe-post.php | 60 --- modules/core/www/idp/logout-iframe.js | 110 ----- modules/core/www/idp/logout-iframe.php | 134 ------ modules/core/www/idp/resumelogout.php | 13 - .../modules/core/lib/Controller/LoginTest.php | 70 +-- .../core/lib/Controller/LogoutTest.php | 108 +++++ 13 files changed, 528 insertions(+), 472 deletions(-) create mode 100644 modules/core/lib/Controller/Logout.php delete mode 100644 modules/core/www/idp/logout-iframe-done.php delete mode 100644 modules/core/www/idp/logout-iframe-post.php delete mode 100644 modules/core/www/idp/logout-iframe.js delete mode 100644 modules/core/www/idp/logout-iframe.php delete mode 100644 modules/core/www/idp/resumelogout.php create mode 100644 tests/modules/core/lib/Controller/LogoutTest.php diff --git a/lib/SimpleSAML/IdP.php b/lib/SimpleSAML/IdP.php index 59fc0de1c..92943bf1a 100644 --- a/lib/SimpleSAML/IdP.php +++ b/lib/SimpleSAML/IdP.php @@ -482,7 +482,7 @@ class IdP // terminate the local session $id = Auth\State::saveState($state, 'core:Logout:afterbridge'); - $returnTo = Module::getModuleURL('core/idp/resumelogout.php', ['id' => $id]); + $returnTo = Module::getModuleURL('core/logout-resume', ['id' => $id]); $this->authSource->logout($returnTo); diff --git a/modules/core/lib/Controller/Login.php b/modules/core/lib/Controller/Login.php index c38de3360..5e57eb09a 100644 --- a/modules/core/lib/Controller/Login.php +++ b/modules/core/lib/Controller/Login.php @@ -18,7 +18,6 @@ use SimpleSAML\XHTML\Template; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use function array_key_exists; use function substr; @@ -442,26 +441,6 @@ class Login } - /** - * Log the user out of a given authentication source. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * @param string $as The name of the auth source. - * - * @return \SimpleSAML\HTTP\RunnableResponse A runnable response which will actually perform logout. - * - * @throws \SimpleSAML\Error\CriticalConfigurationError - */ - public function logout(Request $request, string $as): RunnableResponse - { - $auth = new Auth\Simple($as); - $returnTo = $this->getReturnPath($request); - return new RunnableResponse( - [$auth, 'logout'], - [$returnTo] - ); - } - /** * Searches for a valid and allowed ReturnTo URL parameter, * otherwise give the base installation page as a return point. @@ -480,6 +459,7 @@ class Login return $returnTo; } + /** * This clears the user's IdP discovery choices. * diff --git a/modules/core/lib/Controller/Logout.php b/modules/core/lib/Controller/Logout.php new file mode 100644 index 000000000..039cf98cc --- /dev/null +++ b/modules/core/lib/Controller/Logout.php @@ -0,0 +1,400 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\core\Controller; + +use Exception; +use SAML2\Binding; +use SAML2\Constants; +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\IdP; +use SimpleSAML\Logger; +use SimpleSAML\Metadata\MetaDataStorageHandler; +use SimpleSAML\Module\saml\Message; +use SimpleSAML\Stats; +use SimpleSAML\Utils; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; + +use function call_user_func; +use function in_array; +use function method_exists; +use function sha1; +use function substr; +use function time; +use function urldecode; +use function var_export; + +/** + * Controller class for the core module. + * + * This class serves the different views available in the module. + * + * @package simplesamlphp/simplesamlphp + */ +class Logout +{ + /** @var \SimpleSAML\Configuration */ + protected Configuration $config; + + + /** + * Controller constructor. + * + * It initializes the global configuration for the controllers implemented here. + * + * @param \SimpleSAML\Configuration $config The configuration to use by the controllers. + */ + public function __construct( + Configuration $config + ) { + $this->config = $config; + } + + + /** + * Log the user out of a given authentication source. + * + * @param Request $request The request that lead to this logout operation. + * @param string $as The name of the auth source. + * + * @return \SimpleSAML\HTTP\RunnableResponse A runnable response which will actually perform logout. + * + * @throws \SimpleSAML\Error\CriticalConfigurationError + */ + public function logout(Request $request, string $as): RunnableResponse + { + $auth = new Auth\Simple($as); + $returnTo = $this->getReturnPath($request); + return new RunnableResponse( + [$auth, 'logout'], + [$returnTo] + ); + } + + + /** + * Searches for a valid and allowed ReturnTo URL parameter, + * otherwise give the base installation page as a return point. + */ + private function getReturnPath(Request $request): string + { + $httpUtils = new Utils\HTTP(); + + $returnTo = $request->query->get('ReturnTo', false); + if ($returnTo !== false) { + $returnTo = $httpUtils->checkURLAllowed($returnTo); + } + + if (empty($returnTo)) { + return $this->config->getBasePath(); + } + + return $returnTo; + } + + + /** + * @param Request $request The request that lead to this logout operation. + * @return \SimpleSAML\HTTP\RunnableResponse + */ + public function logoutIframeDone(Request $request): RunnableResponse + { + if (!$request->query->has('id')) { + throw new Error\BadRequest('Missing required parameter: id'); + } + $id = $request->query->get('id'); + + /** @psalm-var array $state */ + $state = Auth\State::loadState($id, 'core:Logout-IFrame'); + $idp = IdP::getByState($state); + + $associations = $idp->getAssociations(); + + if (!$request->query->has('cancel')) { + Logger::stats('slo-iframe done'); + Stats::log('core:idp:logout-iframe:page', ['type' => 'done']); + $SPs = $state['core:Logout-IFrame:Associations']; + } else { + // user skipped global logout + Logger::stats('slo-iframe skip'); + Stats::log('core:idp:logout-iframe:page', ['type' => 'skip']); + $SPs = []; // no SPs should have been logged out + $state['core:Failed'] = true; // mark as partial logout + } + + // find the status of all SPs + foreach ($SPs as $assocId => &$sp) { + $spId = 'logout-iframe-' . sha1($assocId); + + if ($request->query->has($spId)) { + $spStatus = $request->query->get($spId); + if ($spStatus === 'completed' || $spStatus === 'failed') { + $sp['core:Logout-IFrame:State'] = $spStatus; + } + } + + if (!isset($associations[$assocId])) { + $sp['core:Logout-IFrame:State'] = 'completed'; + } + } + + + // terminate the associations + foreach ($SPs as $assocId => $sp) { + if ($sp['core:Logout-IFrame:State'] === 'completed') { + $idp->terminateAssociation($assocId); + } else { + Logger::warning('Unable to terminate association with ' . var_export($assocId, true) . '.'); + if (isset($sp['saml:entityID'])) { + $spId = $sp['saml:entityID']; + } else { + $spId = $assocId; + } + Logger::stats('slo-iframe-fail ' . $spId); + Stats::log('core:idp:logout-iframe:spfail', ['sp' => $spId]); + $state['core:Failed'] = true; + } + } + + // we are done + return new RunnableResponse([$idp, 'finishLogout'], [$state]); + } + + + /** + * @param Request $request The request that lead to this logout operation. + * @return \SimpleSAML\HTTP\RunnableResponse + */ + public function logoutIframePost(Request $request): RunnableResponse + { + if (!$request->query->has('idp')) { + throw new Error\BadRequest('Missing required parameter: idp'); + } + + $idp = IdP::getById($request->query->get('idp')); + + if (!$request->query->has('association')) { + throw new Error\BadRequest('Missing required parameter: association'); + } + $assocId = urldecode($request->query->get('association')); + + $relayState = null; + if ($request->query->has('RelayState')) { + $relayState = $request->query->get('RelayState'); + } + + $associations = $idp->getAssociations(); + if (!isset($associations[$assocId])) { + throw new Error\BadRequest('Invalid association id.'); + } + $association = $associations[$assocId]; + + $metadata = MetaDataStorageHandler::getMetadataHandler(); + $idpMetadata = $idp->getConfig(); + $spMetadata = $metadata->getMetaDataConfig($association['saml:entityID'], 'saml20-sp-remote'); + + $lr = Message::buildLogoutRequest($idpMetadata, $spMetadata); + $lr->setSessionIndex($association['saml:SessionIndex']); + $lr->setNameId($association['saml:NameID']); + + $assertionLifetime = $spMetadata->getInteger('assertion.lifetime', null); + if ($assertionLifetime === null) { + $assertionLifetime = $idpMetadata->getInteger('assertion.lifetime', 300); + } + $lr->setNotOnOrAfter(time() + $assertionLifetime); + + $encryptNameId = $spMetadata->getBoolean('nameid.encryption', null); + if ($encryptNameId === null) { + $encryptNameId = $idpMetadata->getBoolean('nameid.encryption', false); + } + if ($encryptNameId) { + $lr->encryptNameId(Message::getEncryptionKey($spMetadata)); + } + + Stats::log('saml:idp:LogoutRequest:sent', [ + 'spEntityID' => $association['saml:entityID'], + 'idpEntityID' => $idpMetadata->getString('entityid'), + ]); + + $bindings = [Constants::BINDING_HTTP_POST]; + + /** @var array $dst */ + $dst = $spMetadata->getDefaultEndpoint('SingleLogoutService', $bindings); + $binding = Binding::getBinding($dst['Binding']); + $lr->setDestination($dst['Location']); + $lr->setRelayState($relayState); + + return new RunnableResponse([$binding, 'send'], [$lr]); + } + + + /** + * @param Request $request The request that lead to this logout operation. + * @return \SimpleSAML\XHTML\Template + */ + public function logoutIframe(Request $request): Template + { + if (!$request->query->has('id')) { + throw new Error\BadRequest('Missing required parameter: id'); + } + $id = $request->query->get('id'); + + $type = 'init'; + if ($request->query->has('type')) { + $type = $request->query->get('type'); + if (!in_array($type, ['init', 'js', 'nojs', 'embed'], true)) { + throw new Error\BadRequest('Invalid value for type.'); + } + } + + if ($type !== 'embed') { + Logger::stats('slo-iframe ' . $type); + Stats::log('core:idp:logout-iframe:page', ['type' => $type]); + } + + /** @psalm-var array $state */ + $state = Auth\State::loadState($id, 'core:Logout-IFrame'); + $idp = IdP::getByState($state); + $mdh = MetaDataStorageHandler::getMetadataHandler(); + + if ($type !== 'init') { + // update association state + foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) { + $spId = sha1($assocId); + + // move SPs from 'onhold' to 'inprogress' + if ($sp['core:Logout-IFrame:State'] === 'onhold') { + $sp['core:Logout-IFrame:State'] = 'inprogress'; + } + + // check for update through request + if ($request->query->has($spId)) { + $s = $request->query->get($spId); + if ($s == 'completed' || $s == 'failed') { + $sp['core:Logout-IFrame:State'] = $s; + } + } + + // check for timeout + if (isset($sp['core:Logout-IFrame:Timeout']) && $sp['core:Logout-IFrame:Timeout'] < time()) { + if ($sp['core:Logout-IFrame:State'] === 'inprogress') { + $sp['core:Logout-IFrame:State'] = 'failed'; + } + } + + // update the IdP + if ($sp['core:Logout-IFrame:State'] === 'completed') { + $idp->terminateAssociation($assocId); + } + + if (!isset($sp['core:Logout-IFrame:Timeout'])) { + if (method_exists($sp['Handler'], 'getAssociationConfig')) { + $assocIdP = IdP::getByState($sp); + $assocConfig = call_user_func([$sp['Handler'], 'getAssociationConfig'], $assocIdP, $sp); + $sp['core:Logout-IFrame:Timeout'] = $assocConfig->getInteger('core:logout-timeout', 5) + time(); + } else { + $sp['core:Logout-IFrame:Timeout'] = time() + 5; + } + } + } + } + + $associations = $idp->getAssociations(); + foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) { + // in case we are refreshing a page + if (!isset($associations[$assocId])) { + $sp['core:Logout-IFrame:State'] = 'completed'; + } + + try { + $assocIdP = IdP::getByState($sp); + $url = call_user_func([$sp['Handler'], 'getLogoutURL'], $assocIdP, $sp, null); + $sp['core:Logout-IFrame:URL'] = $url; + } catch (Exception $e) { + $sp['core:Logout-IFrame:State'] = 'failed'; + } + } + + // get the metadata of the service that initiated logout, if any + $terminated = null; + if ($state['core:TerminatedAssocId'] !== null) { + $mdset = 'saml20-sp-remote'; + + if (substr($state['core:TerminatedAssocId'], 0, 4) === 'adfs') { + $mdset = 'adfs-sp-remote'; + } + + $terminated = $mdh->getMetaDataConfig($state['saml:SPEntityId'], $mdset)->toArray(); + } + + // build an array with information about all services currently logged in + $remaining = []; + foreach ($state['core:Logout-IFrame:Associations'] as $association) { + $key = sha1($association['id']); + $mdset = 'saml20-sp-remote'; + + if (substr($association['id'], 0, 4) === 'adfs') { + $mdset = 'adfs-sp-remote'; + } + + if ($association['core:Logout-IFrame:State'] === 'completed') { + continue; + } + + $remaining[$key] = [ + 'id' => $association['id'], + 'expires_on' => $association['Expires'], + 'entityID' => $association['saml:entityID'], + 'subject' => $association['saml:NameID'], + 'status' => $association['core:Logout-IFrame:State'], + 'metadata' => $mdh->getMetaDataConfig($association['saml:entityID'], $mdset)->toArray(), + ]; + + if (isset($association['core:Logout-IFrame:URL'])) { + $remaining[$key]['logoutURL'] = $association['core:Logout-IFrame:URL']; + } + + if (isset($association['core:Logout-IFrame:Timeout'])) { + $remaining[$key]['timeout'] = $association['core:Logout-IFrame:Timeout']; + } + } + + if ($type === 'nojs') { + $t = new Template($this->config, 'core:logout-iframe-wrapper.twig'); + } else { + $t = new Template($this->config, 'core:logout-iframe.twig'); + } + + $t->data['auth_state'] = Auth\State::saveState($state, 'core:Logout-IFrame'); + $t->data['type'] = $type; + $t->data['terminated_service'] = $terminated; + $t->data['remaining_services'] = $remaining; + + return $t; + } + + + /** + * @param Request $request The request that lead to this logout operation. + * @return \SimpleSAML\HTTP\RunnableResponse + */ + public function resumeLogout(Request $request): RunnableResponse + { + if (!$request->query->has('id')) { + throw new Error\BadRequest('Missing required parameter: id'); + } + $id = $request->query->get('id'); + + /** @psalm-var array $state */ + $state = Auth\State::loadState($id, 'core:Logout:afterbridge'); + $idp = IdP::getByState($state); + + $assocId = $state['core:TerminatedAssocId']; + return new RunnableResponse([$idp->getLogoutHandler(), 'startLogout'], [$state, $assocId]); + } +} diff --git a/modules/core/routing/routes/routes.yml b/modules/core/routing/routes/routes.yml index 2dd3cfe2e..e1cbc08cf 100644 --- a/modules/core/routing/routes/routes.yml +++ b/modules/core/routing/routes/routes.yml @@ -4,9 +4,6 @@ core-welcome: core-account-disco-clearchoices: path: /account/disco/clearchoices defaults: { _controller: 'SimpleSAML\Module\core\Controller\Login:cleardiscochoices' } -core-logout: - path: /logout/{as} - defaults: { _controller: 'SimpleSAML\Module\core\Controller\Login:logout' } core-loginuserpass: path: /loginuserpass defaults: { _controller: 'SimpleSAML\Module\core\Controller\Login:loginuserpass' } @@ -37,3 +34,18 @@ core-legacy-auth: core-legacy-federation: path: /frontpage_federation.php defaults: { _controller: 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction', path: /admin/federation, permanent: true } +core-logout: + path: /logout/{as} + defaults: { _controller: 'SimpleSAML\Module\core\Controller\Logout:logout' } +core-logout-resume: + path: /logout/resume + defaults: { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutResume' } +core-logout-iframe: + path: /logout/iframe + defaults: { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutIframe' } +core-logout-iframe-done: + path: /logout/iframe-done + defaults: { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutIframeDone' } +core-logout-iframe-post: + path: /logout/iframe-post + defaults: { _controller: 'SimpleSAML\Module\core\Controller\Logout:logoutIframePost' } diff --git a/modules/core/templates/logout-iframe-wrapper.twig b/modules/core/templates/logout-iframe-wrapper.twig index a1baa200c..6ff97956a 100644 --- a/modules/core/templates/logout-iframe-wrapper.twig +++ b/modules/core/templates/logout-iframe-wrapper.twig @@ -1,2 +1,2 @@ -{% set pagetitle = '{logout:progress}'|trans %} +{% set pagetitle = 'Logging out...'|trans %} {% extends "@core/logout-iframe.twig" %} diff --git a/modules/core/templates/logout-iframe.twig b/modules/core/templates/logout-iframe.twig index f0b2cb715..7779b0255 100644 --- a/modules/core/templates/logout-iframe.twig +++ b/modules/core/templates/logout-iframe.twig @@ -7,7 +7,7 @@ {%- if type != "init" %} {%- set content = '2' %} {%- if remaining_services|length == 0 %} - {%- set content = '0; url=logout-iframe-done.php?id=' ~ auth_state %} + {%- set content = '0; url=module.php/core/logout/iframe-done?id=' ~ auth_state %} {%- endif %} <meta http-equiv="refresh" content="{{ content }}"> diff --git a/modules/core/www/idp/logout-iframe-done.php b/modules/core/www/idp/logout-iframe-done.php deleted file mode 100644 index 2e5c759b0..000000000 --- a/modules/core/www/idp/logout-iframe-done.php +++ /dev/null @@ -1,59 +0,0 @@ -<?php - -if (!isset($_REQUEST['id'])) { - throw new \SimpleSAML\Error\BadRequest('Missing required parameter: id'); -} - -$state = \SimpleSAML\Auth\State::loadState($_REQUEST['id'], 'core:Logout-IFrame'); -$idp = \SimpleSAML\IdP::getByState($state); - -$associations = $idp->getAssociations(); - -if (!isset($_REQUEST['cancel'])) { - \SimpleSAML\Logger::stats('slo-iframe done'); - \SimpleSAML\Stats::log('core:idp:logout-iframe:page', ['type' => 'done']); - $SPs = $state['core:Logout-IFrame:Associations']; -} else { - // user skipped global logout - \SimpleSAML\Logger::stats('slo-iframe skip'); - \SimpleSAML\Stats::log('core:idp:logout-iframe:page', ['type' => 'skip']); - $SPs = []; // no SPs should have been logged out - $state['core:Failed'] = true; // mark as partial logout -} - -// find the status of all SPs -foreach ($SPs as $assocId => &$sp) { - $spId = 'logout-iframe-' . sha1($assocId); - - if (isset($_REQUEST[$spId])) { - $spStatus = $_REQUEST[$spId]; - if ($spStatus === 'completed' || $spStatus === 'failed') { - $sp['core:Logout-IFrame:State'] = $spStatus; - } - } - - if (!isset($associations[$assocId])) { - $sp['core:Logout-IFrame:State'] = 'completed'; - } -} - - -// terminate the associations -foreach ($SPs as $assocId => $sp) { - if ($sp['core:Logout-IFrame:State'] === 'completed') { - $idp->terminateAssociation($assocId); - } else { - \SimpleSAML\Logger::warning('Unable to terminate association with ' . var_export($assocId, true) . '.'); - if (isset($sp['saml:entityID'])) { - $spId = $sp['saml:entityID']; - } else { - $spId = $assocId; - } - \SimpleSAML\Logger::stats('slo-iframe-fail ' . $spId); - \SimpleSAML\Stats::log('core:idp:logout-iframe:spfail', ['sp' => $spId]); - $state['core:Failed'] = true; - } -} - -// we are done -$idp->finishLogout($state); diff --git a/modules/core/www/idp/logout-iframe-post.php b/modules/core/www/idp/logout-iframe-post.php deleted file mode 100644 index d20af29fc..000000000 --- a/modules/core/www/idp/logout-iframe-post.php +++ /dev/null @@ -1,60 +0,0 @@ -<?php - -if (!isset($_REQUEST['idp'])) { - throw new \SimpleSAML\Error\BadRequest('Missing "idp" parameter.'); -} -$idp = (string) $_REQUEST['idp']; -$idp = \SimpleSAML\IdP::getById($idp); - -if (!isset($_REQUEST['association'])) { - throw new \SimpleSAML\Error\BadRequest('Missing "association" parameter.'); -} -$assocId = urldecode($_REQUEST['association']); - -$relayState = null; -if (isset($_REQUEST['RelayState'])) { - $relayState = (string) $_REQUEST['RelayState']; -} - -$associations = $idp->getAssociations(); -if (!isset($associations[$assocId])) { - throw new \SimpleSAML\Error\BadRequest('Invalid association id.'); -} -$association = $associations[$assocId]; - -$metadata = \SimpleSAML\Metadata\MetaDataStorageHandler::getMetadataHandler(); -$idpMetadata = $idp->getConfig(); -$spMetadata = $metadata->getMetaDataConfig($association['saml:entityID'], 'saml20-sp-remote'); - -$lr = \SimpleSAML\Module\saml\Message::buildLogoutRequest($idpMetadata, $spMetadata); -$lr->setSessionIndex($association['saml:SessionIndex']); -$lr->setNameId($association['saml:NameID']); - -$assertionLifetime = $spMetadata->getOptionalInteger('assertion.lifetime', null); -if ($assertionLifetime === null) { - $assertionLifetime = $idpMetadata->getOptionalInteger('assertion.lifetime', 300); -} -$lr->setNotOnOrAfter(time() + $assertionLifetime); - -$encryptNameId = $spMetadata->getOptionalBoolean('nameid.encryption', null); -if ($encryptNameId === null) { - $encryptNameId = $idpMetadata->getOptionalBoolean('nameid.encryption', false); -} -if ($encryptNameId) { - $lr->encryptNameId(\SimpleSAML\Module\saml\Message::getEncryptionKey($spMetadata)); -} - -\SimpleSAML\Stats::log('saml:idp:LogoutRequest:sent', [ - 'spEntityID' => $association['saml:entityID'], - 'idpEntityID' => $idpMetadata->getString('entityid'), -]); - -$bindings = [\SAML2\Constants::BINDING_HTTP_POST]; - -/** @var array $dst */ -$dst = $spMetadata->getDefaultEndpoint('SingleLogoutService', $bindings); -$binding = \SAML2\Binding::getBinding($dst['Binding']); -$lr->setDestination($dst['Location']); -$lr->setRelayState($relayState); - -$binding->send($lr); diff --git a/modules/core/www/idp/logout-iframe.js b/modules/core/www/idp/logout-iframe.js deleted file mode 100644 index 62e7666b0..000000000 --- a/modules/core/www/idp/logout-iframe.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * This function updates the global logout status. - */ -function updateStatus() -{ - var nFailed = 0; - var nProgress = 0; - for (var sp in window.spStatus) { - switch (window.spStatus[sp]) { - case 'failed': - nFailed += 1; - break; - case 'inprogress': - nProgress += 1; - break; - } - } - - if (nFailed > 0) { - $('#logout-failed-message').show(); - } - - if (nProgress === 0 && nFailed === 0) { - $('#logout-completed').show(); - $('#done-form').submit(); - } -} - -/** - * This function updates the logout status for a given SP. - * - * @param spId The ID of the SP. - * @param status The new status. - * @param reason The reason for the status change. - */ -function updateSPStatus(spId, status, reason) -{ - if (window.spStatus[spId] === status) { - // unchanged - return; - } - - $('#statusimage-' + spId).attr('src', window.stateImage[status]).attr('alt', window.stateText[status]).attr('title', reason); - window.spStatus[spId] = status; - - var formId = 'logout-iframe-' + spId; - var existing = $('input[name="' + formId + '"]'); - if (existing.length === 0) { - // don't have an existing form element, add one - var elementHTML = '<input type="hidden" name="' + formId + '" value="' + status + '" />'; - $('#failed-form , #done-form').append(elementHTML); - } else { - // update existing element - existing.attr('value', status); - } - - updateStatus(); -} - -/** - * Mark logout as completed for an SP. - * - * This method will be called by the SimpleSAML\IdP\IFrameLogoutHandler class upon successful logout from the SP. - * - * @param spId The SP that completed logout successfully. - */ -function logoutCompleted(spId) -{ - updateSPStatus(spId, 'completed', ''); -} - -/** - * Mark logout as failed for an SP. - * - * This method will be called by the SimpleSAML\IdP\IFrameLogoutHandler class upon logout failure from the SP. - * - * @param spId The SP that failed to complete logout. - * @param reason The reason why logout failed. - */ -function logoutFailed(spId, reason) -{ - updateSPStatus(spId, 'failed', reason); -} - -/** - * Set timeouts for all logout operations. - * - * If an SP didn't reply by the timeout, we'll mark it as failed. - */ -function timeoutSPs() -{ - var cTime = ((new Date()).getTime() - window.startTime) / 1000; - for (var sp in window.spStatus) { - if (window.spTimeout[sp] <= cTime && window.spStatus[sp] === 'inprogress') { - logoutFailed(sp, 'Timeout'); - } - } - window.timeoutID = window.setTimeout(timeoutSPs, 1000); -} - -$('document').ready(function () { - window.startTime = (new Date()).getTime(); - if (window.type === 'js') { - window.timeoutID = window.setTimeout(timeoutSPs, 1000); - updateStatus(); - } else if (window.type === 'init') { - $('#logout-type-selector').attr('value', 'js'); - $('#logout-all').focus(); - } -}); diff --git a/modules/core/www/idp/logout-iframe.php b/modules/core/www/idp/logout-iframe.php deleted file mode 100644 index 34c5ae32e..000000000 --- a/modules/core/www/idp/logout-iframe.php +++ /dev/null @@ -1,134 +0,0 @@ -<?php - -if (!isset($_REQUEST['id'])) { - throw new \SimpleSAML\Error\BadRequest('Missing required parameter: id'); -} - -if (isset($_REQUEST['type'])) { - $type = (string) $_REQUEST['type']; - if (!in_array($type, ['init', 'js', 'nojs', 'embed'], true)) { - throw new \SimpleSAML\Error\BadRequest('Invalid value for type.'); - } -} else { - $type = 'init'; -} - -if ($type !== 'embed') { - \SimpleSAML\Logger::stats('slo-iframe ' . $type); - \SimpleSAML\Stats::log('core:idp:logout-iframe:page', ['type' => $type]); -} - -$state = \SimpleSAML\Auth\State::loadState($_REQUEST['id'], 'core:Logout-IFrame'); -$idp = \SimpleSAML\IdP::getByState($state); -$mdh = \SimpleSAML\Metadata\MetaDataStorageHandler::getMetadataHandler(); - -if ($type !== 'init') { - // update association state - foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) { - $spId = sha1($assocId); - - // move SPs from 'onhold' to 'inprogress' - if ($sp['core:Logout-IFrame:State'] === 'onhold') { - $sp['core:Logout-IFrame:State'] = 'inprogress'; - } - - // check for update through request - if (isset($_REQUEST[$spId])) { - $s = $_REQUEST[$spId]; - if ($s == 'completed' || $s == 'failed') { - $sp['core:Logout-IFrame:State'] = $s; - } - } - - // check for timeout - if (isset($sp['core:Logout-IFrame:Timeout']) && $sp['core:Logout-IFrame:Timeout'] < time()) { - if ($sp['core:Logout-IFrame:State'] === 'inprogress') { - $sp['core:Logout-IFrame:State'] = 'failed'; - } - } - - // update the IdP - if ($sp['core:Logout-IFrame:State'] === 'completed') { - $idp->terminateAssociation($assocId); - } - - if (!isset($sp['core:Logout-IFrame:Timeout'])) { - if (method_exists($sp['Handler'], 'getAssociationConfig')) { - $assocIdP = \SimpleSAML\IdP::getByState($sp); - $assocConfig = call_user_func([$sp['Handler'], 'getAssociationConfig'], $assocIdP, $sp); - $sp['core:Logout-IFrame:Timeout'] = $assocConfig->getInteger('core:logout-timeout', 5) + time(); - } else { - $sp['core:Logout-IFrame:Timeout'] = time() + 5; - } - } - } -} - -$associations = $idp->getAssociations(); -foreach ($state['core:Logout-IFrame:Associations'] as $assocId => &$sp) { - // in case we are refreshing a page - if (!isset($associations[$assocId])) { - $sp['core:Logout-IFrame:State'] = 'completed'; - } - - try { - $assocIdP = \SimpleSAML\IdP::getByState($sp); - $url = call_user_func([$sp['Handler'], 'getLogoutURL'], $assocIdP, $sp, null); - $sp['core:Logout-IFrame:URL'] = $url; - } catch (\Exception $e) { - $sp['core:Logout-IFrame:State'] = 'failed'; - } -} - -// get the metadata of the service that initiated logout, if any -$terminated = null; -if ($state['core:TerminatedAssocId'] !== null) { - $mdset = 'saml20-sp-remote'; - if (substr($state['core:TerminatedAssocId'], 0, 4) === 'adfs') { - $mdset = 'adfs-sp-remote'; - } - $terminated = $mdh->getMetaDataConfig($state['saml:SPEntityId'], $mdset)->toArray(); -} - -// build an array with information about all services currently logged in -$remaining = []; -foreach ($state['core:Logout-IFrame:Associations'] as $association) { - $key = sha1($association['id']); - $mdset = 'saml20-sp-remote'; - if (substr($association['id'], 0, 4) === 'adfs') { - $mdset = 'adfs-sp-remote'; - } - - if ($association['core:Logout-IFrame:State'] === 'completed') { - continue; - } - - $remaining[$key] = [ - 'id' => $association['id'], - 'expires_on' => $association['Expires'], - 'entityID' => $association['saml:entityID'], - 'subject' => $association['saml:NameID'], - 'status' => $association['core:Logout-IFrame:State'], - 'metadata' => $mdh->getMetaDataConfig($association['saml:entityID'], $mdset)->toArray(), - ]; - if (isset($association['core:Logout-IFrame:URL'])) { - $remaining[$key]['logoutURL'] = $association['core:Logout-IFrame:URL']; - } - if (isset($association['core:Logout-IFrame:Timeout'])) { - $remaining[$key]['timeout'] = $association['core:Logout-IFrame:Timeout']; - } -} - -$globalConfig = \SimpleSAML\Configuration::getInstance(); -if ($type === 'nojs') { - $t = new \SimpleSAML\XHTML\Template($globalConfig, 'core:logout-iframe-wrapper.twig'); -} else { - $t = new \SimpleSAML\XHTML\Template($globalConfig, 'core:logout-iframe.twig'); -} - -$id = \SimpleSAML\Auth\State::saveState($state, 'core:Logout-IFrame'); -$t->data['auth_state'] = $id; -$t->data['type'] = $type; -$t->data['terminated_service'] = $terminated; -$t->data['remaining_services'] = $remaining; -$t->send(); diff --git a/modules/core/www/idp/resumelogout.php b/modules/core/www/idp/resumelogout.php deleted file mode 100644 index 656885f44..000000000 --- a/modules/core/www/idp/resumelogout.php +++ /dev/null @@ -1,13 +0,0 @@ -<?php - -if (!isset($_REQUEST['id'])) { - throw new \SimpleSAML\Error\BadRequest('Missing id-parameter.'); -} - -$state = \SimpleSAML\Auth\State::loadState($_REQUEST['id'], 'core:Logout:afterbridge'); -$idp = \SimpleSAML\IdP::getByState($state); - -$assocId = $state['core:TerminatedAssocId']; - -$handler = $idp->getLogoutHandler(); -$handler->startLogout($state, $assocId); diff --git a/tests/modules/core/lib/Controller/LoginTest.php b/tests/modules/core/lib/Controller/LoginTest.php index e1e080ece..52d3ed18d 100644 --- a/tests/modules/core/lib/Controller/LoginTest.php +++ b/tests/modules/core/lib/Controller/LoginTest.php @@ -71,77 +71,9 @@ class LoginTest extends ClearStateTestCase $this->assertEquals('core:welcome.twig', $response->getTemplateName()); } - /** - * Test basic operation of the logout controller. - * @TODO check if the passed auth source is correctly used - */ - public function testLogout(): void - { - $request = Request::create( - '/logout', - 'GET', - [], - ); - - $c = new Controller\Login($this->config); - $response = $c->logout($request, 'example-authsource'); - - $this->assertInstanceOf(RunnableResponse::class, $response); - $callable = $response->getCallable(); - $this->assertInstanceOf(\SimpleSAML\Auth\Simple::class, $callable[0]); - $this->assertEquals('logout', $callable[1]); - } - /** */ - public function testLoginUserPassNoState(): void - { - $request = Request::create( - '/loginuserpass', - 'GET', - [], - ); - - $c = new Controller\Login($this->config); - - $this->expectException(Error\BadRequest::class); - $c->loginuserpass($request); - } - - - public function testLogoutReturnToDisallowedUrlRejected(): void - { - $request = Request::create( - '/logout/example-authsource', - 'GET', - ['ReturnTo' => 'https://loeki.tv/asjemenou'], - ); - $_SERVER['REQUEST_URI'] = 'https://example.com/simplesaml/module.php/core/logout/example-authsource'; - - $c = new Controller\Login($this->config); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('URL not allowed: https://loeki.tv/asjemenou'); - $response = $c->logout($request, 'example-authsource'); - } - - public function testLogoutReturnToAllowedUrl(): void - { - $request = Request::create( - '/logout/example-authsource', - 'GET', - ['ReturnTo' => 'https://example.org/something'], - ); - $_SERVER['REQUEST_URI'] = 'https://example.com/simplesaml/module.php/core/logout/example-authsource'; - - $c = new Controller\Login($this->config); - - $response = $c->logout($request, 'example-authsource'); - $this->assertInstanceOf(RunnableResponse::class, $response); - $this->assertEquals('https://example.org/something', $response->getArguments()[0]); - } - public function testClearDiscoChoicesReturnToDisallowedUrlRejected(): void { $request = Request::create( @@ -153,7 +85,7 @@ class LoginTest extends ClearStateTestCase $c = new Controller\Login($this->config); - $this->expectException(Exception::class); + $this->expectException(Error\Exception::class); $this->expectExceptionMessage('URL not allowed: https://loeki.tv/asjemenou'); $response = $c->cleardiscochoices($request); } diff --git a/tests/modules/core/lib/Controller/LogoutTest.php b/tests/modules/core/lib/Controller/LogoutTest.php new file mode 100644 index 000000000..6fb32b175 --- /dev/null +++ b/tests/modules/core/lib/Controller/LogoutTest.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\core\Controller; + +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +//use SimpleSAML\Locale\Localization; +use SimpleSAML\Module\core\Controller; +use SimpleSAML\TestUtils\ClearStateTestCase; +//use SimpleSAML\XHTML\Template; +//use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * Set of tests for the controllers in the "core" module. + * + * For now, this test extends ClearStateTestCase so that it doesn't interfere with other tests. Once every class has + * been made PSR-7-aware, that won't be necessary any longer. + * + * @covers \SimpleSAML\Module\core\Controller\Logout + * @package SimpleSAML\Test + */ +class LogoutTest extends ClearStateTestCase +{ + /** @var \SimpleSAML\Configuration */ + protected Configuration $config; + + /** @var \SimpleSAML\Configuration[] */ + protected array $loadedConfigs; + + + /** + * Set up for each test. + */ + protected function setUp(): void + { + parent::setUp(); + $this->config = Configuration::loadFromArray( + [ + 'baseurlpath' => 'https://example.org/simplesaml', + 'module.enable' => ['exampleauth' => true], + ], + '[ARRAY]', + 'simplesaml' + ); + Configuration::setPreLoadedConfig($this->config, 'config.php'); + } + + + /** + * Test basic operation of the logout controller. + * @TODO check if the passed auth source is correctly used + */ + public function testLogout(): void + { + $request = Request::create( + '/logout', + 'GET', + ); + + $c = new Controller\Logout($this->config); + + $response = $c->logout($request, 'example-authsource'); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = $response->getCallable(); + $this->assertInstanceOf(Auth\Simple::class, $callable[0]); + $this->assertEquals('logout', $callable[1]); + } + + + public function testLogoutReturnToDisallowedUrlRejected(): void + { + $request = Request::create( + '/logout/example-authsource', + 'GET', + ['ReturnTo' => 'https://loeki.tv/asjemenou'], + ); + $_SERVER['REQUEST_URI'] = 'https://example.com/simplesaml/module.php/core/logout/example-authsource'; + + $c = new Controller\Logout($this->config); + + $this->expectException(Error\Exception::class); + $this->expectExceptionMessage('URL not allowed: https://loeki.tv/asjemenou'); + $response = $c->logout($request, 'example-authsource'); + } + + + public function testLogoutReturnToAllowedUrl(): void + { + $request = Request::create( + '/logout/example-authsource', + 'GET', + ['ReturnTo' => 'https://example.org/something'], + ); + $_SERVER['REQUEST_URI'] = 'https://example.com/simplesaml/module.php/core/logout/example-authsource'; + + $c = new Controller\Logout($this->config); + + $response = $c->logout($request, 'example-authsource'); + $this->assertInstanceOf(RunnableResponse::class, $response); + $this->assertEquals('https://example.org/something', $response->getArguments()[0]); + } +} -- GitLab