diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index a9e3c51f..0ce731f4 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -26,12 +26,14 @@ jobs: with: files: . ignore_path: .markdownlintignore + ignore_files: public/** - name: Perform spell check uses: codespell-project/actions-codespell@v2 with: path: '**/*.md' check_filenames: true + skip: public/** ignore_words_list: tekst build: diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f17ed094..cb27ff31 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -37,6 +37,7 @@ jobs: VALIDATE_YAML: true VALIDATE_XML: true VALIDATE_GITHUB_ACTIONS: true + FILTER_REGEX_EXCLUDE: 'public/**' quality: name: Quality control diff --git a/README.md b/README.md index 1c26f5f0..26096bac 100644 --- a/README.md +++ b/README.md @@ -88,18 +88,20 @@ To explore the module using docker run the below command. This will run an SSP i of the `casserver` module mounted in the container, along with some configuration files. Any code changes you make to your git checkout are "live" in the container, allowing you to test and iterate different things. +Sometimes when working with a dev version of the module you will need a newer version of a dependency than what SSP is +locked to. In that case you can add an additional dependency to the `COMPOSER_REQUIRE` line (e.g ="simplesamlphp/assert:1.8 ") + ```bash -# Note: this currently errors on this module requiring a newer version of `simplesamlphp/xml-common` than what is in the base image docker run --name ssp-casserver-dev \ --mount type=bind,source="$(pwd)",target=/var/simplesamlphp/staging-modules/casserver,readonly \ -e STAGINGCOMPOSERREPOS=casserver \ - -e COMPOSER_REQUIRE="simplesamlphp/simplesamlphp-module-casserver:@dev simplesamlphp/simplesamlphp-module-preprodwarning" + -e COMPOSER_REQUIRE="simplesamlphp/simplesamlphp-module-casserver:@dev simplesamlphp/simplesamlphp-module-preprodwarning" \ -e SSP_ADMIN_PASSWORD=secret1 \ --mount type=bind,source="$(pwd)/docker/ssp/module_casserver.php",target=/var/simplesamlphp/config/module_casserver.php,readonly \ --mount type=bind,source="$(pwd)/docker/ssp/authsources.php",target=/var/simplesamlphp/config/authsources.php,readonly \ --mount type=bind,source="$(pwd)/docker/ssp/config-override.php",target=/var/simplesamlphp/config/config-override.php,readonly \ --mount type=bind,source="$(pwd)/docker/apache-override.cf",target=/etc/apache2/sites-enabled/ssp-override.cf,readonly \ - -p 443:443 cirrusid/simplesamlphp:v2.3.2 + -p 443:443 cirrusid/simplesamlphp:v2.3.5 ``` Visit [https://localhost/simplesaml/](https://localhost/simplesaml/) and confirm you get the default page. diff --git a/composer.json b/composer.json index 6d347e2a..b536eb69 100644 --- a/composer.json +++ b/composer.json @@ -30,38 +30,48 @@ }, "require": { "php": "^8.1", - "ext-ctype": "*", "ext-dom": "*", "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", + "ext-session": "*", + "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/simplesamlphp": "^2.2", - "simplesamlphp/xml-cas": "^v1.3", - "simplesamlphp/xml-common": "^v1.17", - "simplesamlphp/xml-soap": "^v1.5" + "simplesamlphp/simplesamlphp": "^2.3", + "simplesamlphp/xml-cas": "^1.3", + "simplesamlphp/xml-common": "^1.17", + "simplesamlphp/xml-soap": "^1.5", + "symfony/http-foundation": "^6.4", + "symfony/http-kernel": "^6.4", + "simplesamlphp/saml11": "~1.2.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.7", + "maglnet/composer-require-checker": "4.7.1", + "vimeo/psalm": "^5", + "icanhazstring/composer-unused": "^0.8.11" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", "source": "https://github.com/simplesamlphp/simplesamlphp-module-casserver" }, - "suggest": { - "ext-pdo": "*" - }, "scripts": { "validate": [ - "vendor/bin/phpunit --no-coverage --testdox", - "vendor/bin/phpcs -p" + "vendor/bin/phpcs -p", + "vendor/bin/composer-require-checker check --config-file=tools/composer-require-checker.json composer.json", + "vendor/bin/psalm -c psalm-dev.xml", + "vendor/bin/composer-unused", + "vendor/bin/phpunit --no-coverage --testdox" ], "tests": [ "vendor/bin/phpunit --no-coverage" + ], + "propose-fix": [ + "vendor/bin/phpcs --report=diff" ] } } diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 9cfb61cd..7e690160 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -26,10 +26,13 @@ $config = [ 'https://host2.domain:5678/path2/path3', // So is regex '|^https://.*\.domain.com/|', - // Some configuration options can be overridden + // ONLY the FOLLOWING configuration options can be overridden. See OverrideConfigPropertiesEnum. 'https://override.example.com' => [ 'attrname' => 'uid', 'attributes_to_transfer' => ['cn'], + //'attributes' => false, + //'authproc' => [], + //'service_ticket_expire_time' => 5, ], ], diff --git a/locales/en/LC_MESSAGES/casserver.po b/locales/en/LC_MESSAGES/casserver.po index 1c3d6161..2dc803cc 100644 --- a/locales/en/LC_MESSAGES/casserver.po +++ b/locales/en/LC_MESSAGES/casserver.po @@ -30,3 +30,20 @@ msgstr "Logged in" msgid "You are logged in." msgstr "You are logged in." +msgid "{copy}" +msgstr "Copy" + +msgid "{copied}" +msgstr "Copied!" + +msgid "{continue}" +msgstr "Continue" + +msgid "{copyToClipboard}" +msgstr "Copy to clipboard" + +msgid "{success}" +msgstr "Validation Successful" + +msgid "{failure}" +msgstr "Validation Failed" diff --git a/phpcs.xml b/phpcs.xml index 86466c0d..f5fbf382 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -21,6 +21,7 @@ tests/config/* + public/assets/jquery/* diff --git a/phpunit.xml b/phpunit.xml index 9163c388..60deba46 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,6 +4,7 @@ bootstrap="tests/bootstrap.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" backupGlobals="false" + displayDetailsOnTestsThatTriggerWarnings="true" cacheDirectory=".phpunit.cache"> diff --git a/psalm-dev.xml b/psalm-dev.xml index fc2fbd3d..071de9a8 100644 --- a/psalm-dev.xml +++ b/psalm-dev.xml @@ -3,6 +3,10 @@ name="SimpleSAMLphp testsuite" useDocblockTypes="true" errorLevel="4" + resolveFromConfigFile="true" + autoloader="vendor/autoload.php" + findUnusedCode="false" + findUnusedBaselineEntry="true" reportMixedIssues="false" hideExternalErrors="true" allowStringToStandInForClass="true" diff --git a/psalm.xml b/psalm.xml index a7a64d4c..666c0157 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,10 +4,15 @@ useDocblockTypes="true" errorLevel="2" reportMixedIssues="false" + resolveFromConfigFile="true" + autoloader="vendor/autoload.php" + findUnusedCode="false" + findUnusedBaselineEntry="true" + hideExternalErrors="true" + allowStringToStandInForClass="true" > - diff --git a/public/assets/css/casserver.css b/public/assets/css/casserver.css new file mode 100644 index 00000000..c19fb0cd --- /dev/null +++ b/public/assets/css/casserver.css @@ -0,0 +1,89 @@ +/* end baseline CSS */ +.hljs { + background: #F3F3F3; + color: #444 +} +/* Base color: saturation 0; */ +.hljs-subst { + /* default */ + +} +/* purposely ignored */ +.hljs-formula, +.hljs-attr, +.hljs-property, +.hljs-params { + +} +.hljs-comment { + color: #697070 +} +.hljs-tag, +.hljs-punctuation { + color: #444a +} +.hljs-tag .hljs-name, +.hljs-tag .hljs-attr { + color: #444 +} +.hljs-keyword, +.hljs-attribute, +.hljs-selector-tag, +.hljs-meta .hljs-keyword, +.hljs-doctag, +.hljs-name { + font-weight: bold +} +/* User color: hue: 0 */ +.hljs-type, +.hljs-string, +.hljs-number, +.hljs-selector-id, +.hljs-selector-class, +.hljs-quote, +.hljs-template-tag, +.hljs-deletion { + color: #880000 +} +.hljs-title, +.hljs-section { + color: #880000; + font-weight: bold +} +.hljs-regexp, +.hljs-symbol, +.hljs-variable, +.hljs-template-variable, +.hljs-link, +.hljs-selector-attr, +.hljs-operator, +.hljs-selector-pseudo { + color: #ab5656 +} +/* Language color: hue: 90; */ +.hljs-literal { + color: #695 +} +.hljs-built_in, +.hljs-bullet, +.hljs-code, +.hljs-addition { + color: #397300 +} +/* Meta color: hue: 200 */ +.hljs-meta { + color: #1f7199 +} +.hljs-meta .hljs-string { + color: #38a +} +/* Misc effects */ +.hljs-emphasis { + font-style: italic +} +.hljs-strong { + font-weight: bold +} +.top-right-corner { + right: 0; +} diff --git a/public/cas.php b/public/cas.php deleted file mode 100644 index 6c9f6028..00000000 --- a/public/cas.php +++ /dev/null @@ -1,51 +0,0 @@ - 'login', - 'validate' => 'validate', - 'serviceValidate' => 'serviceValidate', - 'logout' => 'logout', - 'proxy' => 'proxy', - 'proxyValidate' => 'proxyValidate', -]; - -$function = substr($_SERVER['PATH_INFO'], 1); - -if (!isset($validFunctions[$function])) { - $message = 'Not a valid function for cas.php.'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - throw new \Exception($message); -} - -/** @psalm-suppress UnresolvableInclude */ -include(dirname(__FILE__) . '/' . strval($validFunctions[$function]) . '.php'); diff --git a/public/loggedIn.php b/public/loggedIn.php deleted file mode 100644 index 295f4821..00000000 --- a/public/loggedIn.php +++ /dev/null @@ -1,32 +0,0 @@ -send(); diff --git a/public/loggedOut.php b/public/loggedOut.php deleted file mode 100644 index 20330c68..00000000 --- a/public/loggedOut.php +++ /dev/null @@ -1,36 +0,0 @@ -data['url'] = $_GET['url']; -} - -$t->send(); diff --git a/public/login.php b/public/login.php deleted file mode 100644 index 5b9798ab..00000000 --- a/public/login.php +++ /dev/null @@ -1,240 +0,0 @@ -checkServiceURL(sanitize($serviceUrl)); - if (isset($serviceCasConfig)) { - // Override the cas configuration to use for this service - $casconfig = $serviceCasConfig; - } else { - $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' . - var_export($serviceUrl, true); - Logger::debug('casserver:' . $message); - - throw new \Exception($message); - } -} - -if (array_key_exists('scope', $_GET) && is_string($_GET['scope'])) { - $scopes = $casconfig->getOptionalValue('scopes', []); - - if (array_key_exists($_GET['scope'], $scopes)) { - $idpList = $scopes[$_GET['scope']]; - } else { - $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' . - var_export($_GET['scope'], true); - Logger::debug('casserver:' . $message); - - throw new \Exception($message); - } -} - -if (array_key_exists('language', $_GET) && is_string($_GET['language'])) { - Language::setLanguageCookie($_GET['language']); -} - -/** Initializations */ - -// AuthSource Simple -$as = new Simple($casconfig->getValue('authsource')); - -// Ticket Store -$ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']); -$ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); -/** @var $ticketStore TicketStore */ -/** @psalm-suppress InvalidStringClass */ -$ticketStore = new $ticketStoreClass($casconfig); - -// Ticket Factory -$ticketFactoryClass = Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); -/** @var $ticketFactory TicketFactory */ -/** @psalm-suppress InvalidStringClass */ -$ticketFactory = new $ticketFactoryClass($casconfig); - -// Processing Chain Factory -$processingChaingFactoryClass = Module::resolveClass('casserver:ProcessingChainFactory', 'Cas\Factories'); -/** @var $processingChainFactory ProcessingChainFactory */ -/** @psalm-suppress InvalidStringClass */ -$processingChainFactory = new $processingChaingFactoryClass($casconfig); - -// Attribute Extractor -$attributeExtractor = new AttributeExtractor($casconfig, $processingChainFactory); - -// HTTP Utils -$httpUtils = new Utils\HTTP(); -$session = Session::getSessionFromRequest(); - -$sessionTicket = $ticketStore->getTicket($session->getSessionId()); -$sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; -$requestRenewId = $_REQUEST['renewId'] ?? null; -// Parse the query parameters and return them in an array -$query = parseQueryParameters($sessionTicket); -// Construct the ReturnTo URL -$returnUrl = $httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); - -// Authenticate -if ( - !$as->isAuthenticated() || ($forceAuthn && $sessionRenewId != $requestRenewId) -) { - $params = [ - 'ForceAuthn' => $forceAuthn, - 'isPassive' => $isPassive, - 'ReturnTo' => $returnUrl, - ]; - - if (isset($_GET['entityId'])) { - $params['saml:idp'] = $_GET['entityId']; - } - - if (isset($idpList)) { - if (sizeof($idpList) > 1) { - $params['saml:IDPList'] = $idpList; - } else { - $params['saml:idp'] = $idpList[0]; - } - } - - $as->login($params); -} - -$sessionExpiry = $as->getAuthData('Expire'); - -if (!is_array($sessionTicket) || $forceAuthn) { - $sessionTicket = $ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry); - $ticketStore->addTicket($sessionTicket); -} - -$parameters = []; - -if (array_key_exists('language', $_GET)) { - $oldLanguagePreferred = Language::getLanguageCookie(); - - if (isset($oldLanguagePreferred)) { - $parameters['language'] = $oldLanguagePreferred; - } elseif (is_string($_GET['language'])) { - $parameters['language'] = $_GET['language']; - } -} - -// I am already logged in. Redirect to the logged in endpoint -if (!isset($serviceUrl) && $authProcId === null) { - // LOGGED IN - $httpUtils->redirectTrustedURL( - $httpUtils->addURLParameters(Module::getModuleURL('casserver/loggedIn.php'), $parameters), - ); -} - -$defaultTicketName = isset($_GET['service']) ? 'ticket' : 'SAMLart'; -$ticketName = $casconfig->getOptionalValue('ticketName', $defaultTicketName); - -// Get the state. -// If we come from an authproc filter, we will load the state from the stateId. -// If not, we will get the state from the AuthSource Data -$state = $authProcId !== null ? - $attributeExtractor->manageState($authProcId) : - $as->getAuthDataArray(); - -// Attribute Handler -$state['ReturnTo'] = $returnUrl; -if ($authProcId !== null) { - $state[ProcessingChain::AUTHPARAM] = $authProcId; -} -$mappedAttributes = $attributeExtractor->extractUserAndAttributes($state); - -$serviceTicket = $ticketFactory->createServiceTicket([ - 'service' => $serviceUrl, - 'forceAuthn' => $forceAuthn, - 'userName' => $mappedAttributes['user'], - 'attributes' => $mappedAttributes['attributes'], - 'proxies' => [], - 'sessionId' => $sessionTicket['id'], - ]); - -$ticketStore->addTicket($serviceTicket); - -$parameters[$ticketName] = $serviceTicket['id']; - -$validDebugModes = ['true', 'samlValidate']; - -// DEBUG MODE -if ( - array_key_exists('debugMode', $_GET) && - in_array($_GET['debugMode'], $validDebugModes, true) && - $casconfig->getOptionalBoolean('debugMode', false) -) { - if ($_GET['debugMode'] === 'samlValidate') { - $samlValidate = new SamlValidateResponder(); - $samlResponse = $samlValidate->convertToSaml($serviceTicket); - $soap = $samlValidate->wrapInSoap($samlResponse); - echo '
' . htmlspecialchars(strval($soap)) . '
'; - } else { - $method = 'serviceValidate'; - // Fake some options for validateTicket - $_GET[$ticketName] = $serviceTicket['id']; - // We want to capture the output from echo used in validateTicket - ob_start(); - require_once 'utility/validateTicket.php'; - $casResponse = ob_get_contents(); - ob_end_clean(); - echo '
' . htmlspecialchars($casResponse) . '
'; - } -} elseif ($redirect) { - // GET - $httpUtils->redirectTrustedURL($httpUtils->addURLParameters($serviceUrl, $parameters)); -} else { - // POST - $httpUtils->submitPOSTData($serviceUrl, $parameters); -} diff --git a/public/logout.php b/public/logout.php deleted file mode 100644 index 518c04ec..00000000 --- a/public/logout.php +++ /dev/null @@ -1,91 +0,0 @@ -getOptionalValue('enable_logout', false)) { - $message = 'Logout not allowed'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - throw new \Exception($message); -} - -$skipLogoutPage = $casconfig->getOptionalValue('skip_logout_page', false); - -if ($skipLogoutPage && !array_key_exists('url', $_GET)) { - $message = 'Required URL query parameter [url] not provided. (CAS Server)'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - throw new \Exception($message); -} -/* Load simpleSAMLphp metadata */ - -$as = new \SimpleSAML\Auth\Simple($casconfig->getValue('authsource')); - -$session = \SimpleSAML\Session::getSession(); - -if (!is_null($session)) { - $ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']); - $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketStore->deleteTicket($session->getSessionId()); -} - -$httpUtils = new \SimpleSAML\Utils\HTTP(); - -if ($as->isAuthenticated()) { - \SimpleSAML\Logger::debug('casserver: performing a real logout'); - - if ($casconfig->getOptionalValue('skip_logout_page', false)) { - $as->logout($_GET['url']); - } else { - $as->logout( - $httpUtils->addURLParameters( - \SimpleSAML\Module::getModuleURL('casserver/loggedOut.php'), - array_key_exists('url', $_GET) ? ['url' => $_GET['url']] : [], - ), - ); - } -} else { - \SimpleSAML\Logger::debug('casserver: no session to log out of, performing redirect'); - - if ($casconfig->getOptionalValue('skip_logout_page', false)) { - $httpUtils->redirectTrustedURL($httpUtils->addURLParameters($_GET['url'], [])); - } else { - $httpUtils->redirectTrustedURL( - $httpUtils->addURLParameters( - \SimpleSAML\Module::getModuleURL('casserver/loggedOut.php'), - array_key_exists('url', $_GET) ? ['url' => $_GET['url']] : [], - ), - ); - } -} diff --git a/public/proxy.php b/public/proxy.php deleted file mode 100644 index ae4182f2..00000000 --- a/public/proxy.php +++ /dev/null @@ -1,115 +0,0 @@ -getOptionalValue('legal_target_service_urls', []); - -if ( - array_key_exists('targetService', $_GET) && - checkServiceURL(sanitize($_GET['targetService']), $legal_target_service_urls) && array_key_exists('pgt', $_GET) -) { - $ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']); - $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketFactoryClass = \SimpleSAML\Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); - /** @psalm-suppress InvalidStringClass */ - $ticketFactory = new $ticketFactoryClass($casconfig); - - $proxyGrantingTicket = $ticketStore->getTicket($_GET['pgt']); - - if (!is_null($proxyGrantingTicket) && $ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)) { - $sessionTicket = $ticketStore->getTicket($proxyGrantingTicket['sessionId']); - - if ( - !is_null($sessionTicket) && - $ticketFactory->isSessionTicket($sessionTicket) && - !$ticketFactory->isExpired($sessionTicket) - ) { - $proxyTicket = $ticketFactory->createProxyTicket( - ['service' => $_GET['targetService'], - 'forceAuthn' => $proxyGrantingTicket['forceAuthn'], - 'attributes' => $proxyGrantingTicket['attributes'], - 'proxies' => $proxyGrantingTicket['proxies'], - 'sessionId' => $proxyGrantingTicket['sessionId'], - ], - ); - - $ticketStore->addTicket($proxyTicket); - - echo $protocol->getProxySuccessResponse($proxyTicket['id']); - } else { - $message = 'Ticket ' . var_export($_GET['pgt'], true) . ' has expired'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse('BAD_PGT', $message); - } - } elseif (!$ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)) { - $message = 'Not a valid proxy granting ticket id: ' . var_export($_GET['pgt'], true); - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse('BAD_PGT', $message); - } else { - $message = 'Ticket ' . var_export($_GET['pgt'], true) . ' not recognized'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse('BAD_PGT', $message); - } -} elseif (!array_key_exists('targetService', $_GET)) { - $message = 'Missing target service parameter [targetService]'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message); -} elseif (!checkServiceURL(sanitize($_GET['targetService']), $legal_target_service_urls)) { - $message = 'Target service parameter not listed as a legal service: [targetService] = ' . - var_export($_GET['targetService'], true); - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message); -} else { - $message = 'Missing proxy granting ticket parameter: [pgt]'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message); -} diff --git a/public/proxyValidate.php b/public/proxyValidate.php deleted file mode 100644 index 01ab183d..00000000 --- a/public/proxyValidate.php +++ /dev/null @@ -1,33 +0,0 @@ -(.*)validateAndDeleteTicket($ticketId, $target); -if (!is_array($ticket)) { - throw new \Exception('Error loading ticket'); -} -$samlValidator = new SamlValidateResponder(); -$response = $samlValidator->convertToSaml($ticket); -$soap = $samlValidator->wrapInSoap($response); - -echo strval($soap); diff --git a/public/serviceValidate.php b/public/serviceValidate.php deleted file mode 100644 index 8697a196..00000000 --- a/public/serviceValidate.php +++ /dev/null @@ -1,33 +0,0 @@ - $legal_service_urls]); - $serviceValidator = new ServiceValidator($config); - return $serviceValidator->checkServiceURL($service) !== null; -} - - -/** - * @param string $parameter - * @return string - */ -function sanitize(string $parameter): string -{ - return TicketValidator::sanitize($parameter); -} - - -/** - * Parse the query Parameters from $_GET global and return them in an array. - * - * @param array|null $sessionTicket - * - * @return array - */ -function parseQueryParameters(?array $sessionTicket): array -{ - $forceAuthn = isset($_GET['renew']) && $_GET['renew']; - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - - $query = []; - - if ($sessionRenewId && $forceAuthn) { - $query['renewId'] = $sessionRenewId; - } - - if (isset($_REQUEST['service'])) { - $query['service'] = $_REQUEST['service']; - } - - if (isset($_REQUEST['TARGET'])) { - $query['TARGET'] = $_REQUEST['TARGET']; - } - - if (isset($_REQUEST['method'])) { - $query['method'] = $_REQUEST['method']; - } - - if (isset($_REQUEST['renew'])) { - $query['renew'] = $_REQUEST['renew']; - } - - if (isset($_REQUEST['gateway'])) { - $query['gateway'] = $_REQUEST['gateway']; - } - - if (array_key_exists('language', $_GET)) { - $query['language'] = is_string($_GET['language']) ? $_GET['language'] : null; - } - - if (isset($_REQUEST['debugMode'])) { - $query['debugMode'] = $_REQUEST['debugMode']; - } - - return $query; -} diff --git a/public/utility/validateTicket.php b/public/utility/validateTicket.php deleted file mode 100644 index 0d639d26..00000000 --- a/public/utility/validateTicket.php +++ /dev/null @@ -1,202 +0,0 @@ -getOptionalValue( - 'ticketstore', - ['class' => 'casserver:FileSystemTicketStore'], - ); - $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @var TicketStore $ticketStore */ - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketFactoryClass = Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); - /** @var TicketFactory $ticketFactory */ - /** @psalm-suppress InvalidStringClass */ - $ticketFactory = new $ticketFactoryClass($casconfig); - - $serviceTicket = $ticketStore->getTicket($_GET['ticket']); - - /** - * @psalm-suppress UndefinedGlobalVariable - * @psalm-suppress TypeDoesNotContainType - */ - if ( - !is_null($serviceTicket) && ($ticketFactory->isServiceTicket($serviceTicket) || - ($ticketFactory->isProxyTicket($serviceTicket) && $method === 'proxyValidate')) - ) { - $ticketStore->deleteTicket($_GET['ticket']); - - $attributes = $serviceTicket['attributes']; - - if ( - !$ticketFactory->isExpired($serviceTicket) && - sanitize($serviceTicket['service']) == sanitize($serviceUrl) && - (!$forceAuthn || $serviceTicket['forceAuthn']) - ) { - $protocol->setAttributes($attributes); - - if (isset($_GET['pgtUrl'])) { - $sessionTicket = $ticketStore->getTicket($serviceTicket['sessionId']); - - $pgtUrl = $_GET['pgtUrl']; - - if ( - !is_null($sessionTicket) && $ticketFactory->isSessionTicket($sessionTicket) && - !$ticketFactory->isExpired($sessionTicket) - ) { - $proxyGrantingTicket = $ticketFactory->createProxyGrantingTicket([ - 'userName' => $serviceTicket['userName'], - 'attributes' => $attributes, - 'forceAuthn' => false, - 'proxies' => array_merge([$serviceUrl], $serviceTicket['proxies']), - 'sessionId' => $serviceTicket['sessionId'], - ]); - $httpUtils = new Utils\HTTP(); - try { - $httpUtils->fetch($pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . - '&pgtId=' . $proxyGrantingTicket['id']); - - $protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); - - $ticketStore->addTicket($proxyGrantingTicket); - } catch (Exception $e) { - // Fall through - } - } - } - - echo $protocol->getValidateSuccessResponse($serviceTicket['userName']); - } else { - if ($ticketFactory->isExpired($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' has expired'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - if (sanitize($serviceTicket['service']) != sanitize($serviceUrl)) { - $message = 'Mismatching service parameters: expected ' . - var_export($serviceTicket['service'], true) . - ' but was: ' . var_export($serviceUrl, true); - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - } else { - if ($serviceTicket['forceAuthn'] != $forceAuthn) { - $message = 'Ticket was issue from single sign on session'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - Logger::error('casserver:' . $method . ': internal server error.'); - - echo $protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, 'Unknown internal error'); - } - } - } - } - } else { - if (is_null($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' not recognized'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - /** - * @psalm-suppress UndefinedGlobalVariable - * @psalm-suppress TypeDoesNotContainType - * @psalm-suppress RedundantCondition - */ - if ($ticketFactory->isProxyTicket($serviceTicket) && ($method === 'serviceValidate')) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . - ' is a proxy ticket. Use proxyValidate instead.'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' is not a service ticket'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } - } - } - } catch (Exception $e) { - Logger::error( - 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true), - ); - - echo $protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $e->getMessage()); - } -} else { - if (!array_key_exists('service', $_GET)) { - $message = 'Missing service parameter: [service]'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message); - } else { - $message = 'Missing ticket parameter: [ticket]'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message); - } -} diff --git a/public/validate.php b/public/validate.php deleted file mode 100644 index cb073253..00000000 --- a/public/validate.php +++ /dev/null @@ -1,123 +0,0 @@ -getOptionalValue( - 'ticketstore', - ['class' => 'casserver:FileSystemTicketStore'], - ); - $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketFactoryClass = \SimpleSAML\Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); - /** @psalm-suppress InvalidStringClass */ - $ticketFactory = new $ticketFactoryClass($casconfig); - - $serviceTicket = $ticketStore->getTicket($_GET['ticket']); - - if (!is_null($serviceTicket) && $ticketFactory->isServiceTicket($serviceTicket)) { - $ticketStore->deleteTicket($_GET['ticket']); - - $usernameField = $casconfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); - - if ( - !$ticketFactory->isExpired($serviceTicket) && - sanitize($serviceTicket['service']) == sanitize($_GET['service']) && - (!$forceAuthn || $serviceTicket['forceAuthn']) && - array_key_exists($usernameField, $serviceTicket['attributes']) - ) { - echo $protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]); - } else { - if (!array_key_exists($usernameField, $serviceTicket['attributes'])) { - \SimpleSAML\Logger::error(sprintf( - 'casserver:validate: internal server error. Missing user name attribute: %s', - var_export($usernameField, true), - )); - - echo $protocol->getValidateFailureResponse(); - } else { - if ($ticketFactory->isExpired($serviceTicket)) { - $message = 'Ticket has ' . var_export($_GET['ticket'], true) . ' expired'; - } else { - if (sanitize($serviceTicket['service']) == sanitize($_GET['service'])) { - $message = 'Mismatching service parameters: expected ' . - var_export($serviceTicket['service'], true) . - ' but was: ' . var_export($_GET['service'], true); - } else { - $message = 'Ticket was issue from single sign on session'; - } - } - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(); - } - } - } else { - if (is_null($serviceTicket)) { - $message = 'ticket: ' . var_export($_GET['ticket'], true) . ' not recognized'; - } else { - $message = 'ticket: ' . var_export($_GET['ticket'], true) . ' is not a service ticket'; - } - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(); - } - } catch (\Exception $e) { - \SimpleSAML\Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true)); - - echo $protocol->getValidateFailureResponse(); - } -} else { - if (!array_key_exists('service', $_GET)) { - $message = 'Missing service parameter: [service]'; - } else { - $message = 'Missing ticket parameter: [ticket]'; - } - - SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(); -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php new file mode 100644 index 00000000..999a4a60 --- /dev/null +++ b/routing/routes/routes.php @@ -0,0 +1,71 @@ +add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) + ->controller([Cas10Controller::class, 'validate']); + $routes->add(RoutesEnum::ServiceValidate->name, RoutesEnum::ServiceValidate->value) + ->controller([Cas20Controller::class, 'serviceValidate']) + ->methods(['GET']); + $routes->add(RoutesEnum::ProxyValidate->name, RoutesEnum::ProxyValidate->value) + ->controller([Cas20Controller::class, 'proxyValidate']) + ->methods(['GET']); + $routes->add(RoutesEnum::Proxy->name, RoutesEnum::Proxy->value) + ->controller([Cas20Controller::class, 'proxy']) + ->methods(['GET']); + $routes->add(RoutesEnum::SamlValidate->name, RoutesEnum::SamlValidate->value) + ->controller([Cas30Controller::class, 'samlValidate']) + ->methods(['POST']); + $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value) + ->controller([LogoutController::class, 'logout']); + $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) + ->controller([LoggedOutController::class, 'main']); + $routes->add(RoutesEnum::Login->name, RoutesEnum::Login->value) + ->controller([LoginController::class, 'login']); + $routes->add(RoutesEnum::LoggedIn->name, RoutesEnum::LoggedIn->value) + ->controller([LoggedInController::class, 'main']); + + // Legacy Routes + $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) + ->controller([Cas10Controller::class, 'validate']); + $routes->add(LegacyRoutesEnum::LegacyServiceValidate->name, LegacyRoutesEnum::LegacyServiceValidate->value) + ->controller([Cas20Controller::class, 'serviceValidate']) + ->methods(['GET']); + $routes->add(LegacyRoutesEnum::LegacyProxyValidate->name, LegacyRoutesEnum::LegacyProxyValidate->value) + ->controller([Cas20Controller::class, 'proxyValidate']) + ->methods(['GET']); + $routes->add(LegacyRoutesEnum::LegacyProxy->name, LegacyRoutesEnum::LegacyProxy->value) + ->controller([Cas20Controller::class, 'proxy']) + ->methods(['GET']); + $routes->add(LegacyRoutesEnum::LegacySamlValidate->name, LegacyRoutesEnum::LegacySamlValidate->value) + ->controller([Cas30Controller::class, 'samlValidate']) + ->methods(['POST']); + $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value) + ->controller([LogoutController::class, 'logout']); + $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) + ->controller([LoggedOutController::class, 'main']); + $routes->add(LegacyRoutesEnum::LegacyLogin->name, LegacyRoutesEnum::LegacyLogin->value) + ->controller([LoginController::class, 'login']); + $routes->add(LegacyRoutesEnum::LegacyLoggedIn->name, LegacyRoutesEnum::LegacyLoggedIn->value) + ->controller([LoggedInController::class, 'main']); +}; diff --git a/routing/services/services.yml b/routing/services/services.yml new file mode 100644 index 00000000..7348ffa9 --- /dev/null +++ b/routing/services/services.yml @@ -0,0 +1,38 @@ +--- + +services: + # default configuration for services in *this* file + _defaults: + public: false + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + SimpleSAML\Module\casserver\Controller\: + resource: '../../src/Controller/*' + exclude: + - '../../src/Controller/Traits/*' + public: true + tags: [ 'controller.service_arguments' ] + + + # Explicit service definitions for CasServer Controllers + SimpleSAML\Module\casserver\Controller\Cas10Controller: + public: true + + SimpleSAML\Module\casserver\Controller\Cas20Controller: + public: true + + SimpleSAML\Module\casserver\Controller\Cas30Controller: + public: true + + SimpleSAML\Module\casserver\Controller\LogoutController: + public: true + + SimpleSAML\Module\casserver\Controller\LoggedOutController: + public: true + + SimpleSAML\Module\casserver\Controller\LoggedInController: + public: true + + SimpleSAML\Module\casserver\Controller\LoginController: + public: true \ No newline at end of file diff --git a/src/Cas/AttributeExtractor.php b/src/Cas/AttributeExtractor.php index 275f38d8..291574e2 100644 --- a/src/Cas/AttributeExtractor.php +++ b/src/Cas/AttributeExtractor.php @@ -55,7 +55,7 @@ public function __construct( * @param array|null $state * * @return array - * @throws Exception + * @throws \Exception */ public function extractUserAndAttributes(?array $state): array { @@ -76,12 +76,11 @@ public function extractUserAndAttributes(?array $state): array throw new \Exception("No cas user defined for attribute $casUsernameAttribute"); } + $casAttributes = []; if ($this->casconfig->getOptionalValue('attributes', true)) { $attributesToTransfer = $this->casconfig->getOptionalValue('attributes_to_transfer', []); if (sizeof($attributesToTransfer) > 0) { - $casAttributes = []; - foreach ($attributesToTransfer as $key) { if (\array_key_exists($key, $attributes)) { $casAttributes[$key] = $attributes[$key]; @@ -90,8 +89,6 @@ public function extractUserAndAttributes(?array $state): array } else { $casAttributes = $attributes; } - } else { - $casAttributes = []; } return [ diff --git a/src/Cas/Protocol/Cas20.php b/src/Cas/Protocol/Cas20.php index 4c63d1c8..23077392 100644 --- a/src/Cas/Protocol/Cas20.php +++ b/src/Cas/Protocol/Cas20.php @@ -185,7 +185,7 @@ public function getValidateFailureResponse(string $errorCode, string $explanatio public function getProxySuccessResponse(string $proxyTicketId): ServiceResponse { $proxyTicket = new ProxyTicket($proxyTicketId); - $proxySucces = new ProxySuccess($proxyTicket); + $proxySuccess = new ProxySuccess($proxyTicket); $serviceResponse = new ServiceResponse($proxySuccess); return $serviceResponse; diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index f6dd8897..d3cb6732 100644 --- a/src/Cas/ServiceValidator.php +++ b/src/Cas/ServiceValidator.php @@ -6,6 +6,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module\casserver\Codebooks\OverrideConfigPropertiesEnum; /** * Validates if a CAS service can use server @@ -29,53 +30,81 @@ public function __construct(Configuration $mainConfig) /** * Check that the $service is allowed, and if so return the configuration to use. - * @param string $service The service url. Assume to already be url decoded + * + * @param string $service The service url. Assume to already be url decoded + * * @return Configuration|null Return the configuration to use for this service, or null if service is not allowed + * @throws \ErrorException */ public function checkServiceURL(string $service): ?Configuration { $isValidService = false; $legalUrl = 'undefined'; $configOverride = null; - foreach ($this->mainConfig->getOptionalArray('legal_service_urls', []) as $index => $value) { + $legalServiceUrlsConfig = $this->mainConfig->getOptionalArray('legal_service_urls', []); + + foreach ($legalServiceUrlsConfig as $index => $value) { // Support two styles: 0 => 'https://example' and 'https://example' => [ extra config ] - if (is_int($index)) { - $legalUrl = $value; - $configOverride = null; - } else { - $legalUrl = $index; - $configOverride = $value; - } + $legalUrl = \is_int($index) ? $value : $index; if (empty($legalUrl)) { Logger::warning("Ignoring empty CAS legal service url '$legalUrl'."); continue; } - if (!ctype_alnum($legalUrl[0])) { - // Probably a regex. Suppress errors incase the format is invalid - $result = @preg_match($legalUrl, $service); - if ($result === 1) { - $isValidService = true; - break; - } elseif ($result === false) { - Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error()); + + $configOverride = \is_int($index) ? null : $value; + + // URL String + if (str_starts_with($service, $legalUrl)) { + $isValidService = true; + break; + } + + // Regex + // Since "If the regex pattern passed does not compile to a valid regex, an E_WARNING is emitted. " + // we will throw an exception if the warning is emitted and use try-catch to handle it + set_error_handler(static function ($severity, $message, $file, $line) { + throw new \ErrorException($message, $severity, $severity, $file, $line); + }, E_WARNING); + + try { + $result = preg_match($legalUrl, $service); + if ($result !== 1) { + throw new \RuntimeException('Service URL does not match legal service URL.'); } - } elseif (strpos($service, $legalUrl) === 0) { $isValidService = true; break; + } catch (\RuntimeException $e) { + // do nothing + Logger::warning($e->getMessage()); + } catch (\Exception $e) { + // do nothing + Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error()); + } finally { + restore_error_handler(); } } - if ($isValidService) { - $serviceConfig = $this->mainConfig->toArray(); - // Return contextual information about which url rule triggered the validation - $serviceConfig['casService'] = [ - 'matchingUrl' => $legalUrl, - 'serviceUrl' => $service, - ]; - if ($configOverride) { - $serviceConfig = array_merge($serviceConfig, $configOverride); - } - return Configuration::loadFromArray($serviceConfig); + + if (!$isValidService) { + return null; + } + + $serviceConfig = $this->mainConfig->toArray(); + // Return contextual information about which url rule triggered the validation + $serviceConfig['casService'] = [ + 'matchingUrl' => $legalUrl, + 'serviceUrl' => $service, + ]; + if ($configOverride !== null) { + // We need to remove all the unsupported configuration keys + $supportedProperties = array_column(OverrideConfigPropertiesEnum::cases(), 'value'); + $configOverride = array_filter( + $configOverride, + static fn($property) => \in_array($property, $supportedProperties, true), + ARRAY_FILTER_USE_KEY, + ); + // Merge the configurations + $serviceConfig = array_merge($serviceConfig, $configOverride); } - return null; + return Configuration::loadFromArray($serviceConfig); } } diff --git a/src/Cas/Ticket/SQLTicketStore.php b/src/Cas/Ticket/SQLTicketStore.php index 5ab0d844..0603ed8c 100644 --- a/src/Cas/Ticket/SQLTicketStore.php +++ b/src/Cas/Ticket/SQLTicketStore.php @@ -321,9 +321,11 @@ private function get(string $key): ?array /** - * @param string $key - * @param array $value - * @param int|null $expire + * @param string $key + * @param array $value + * @param int|null $expire + * + * @throws Exception */ private function set(string $key, array $value, int $expire = null): void { diff --git a/src/Codebooks/LegacyRoutesEnum.php b/src/Codebooks/LegacyRoutesEnum.php new file mode 100644 index 00000000..36f6bf31 --- /dev/null +++ b/src/Codebooks/LegacyRoutesEnum.php @@ -0,0 +1,19 @@ +casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + $this->cas10Protocol = new Cas10($this->casConfig); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); + $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); + } + + /** + * @param Request $request + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary credentials. + * It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login. + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued + * + * @return Response + */ + public function validate( + Request $request, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $service = null, + ): Response { + $forceAuthn = $renew; + // Check if any of the required query parameters are missing + // Even though we can delegate the check to Symfony's `MapQueryParameter` we cannot return + // the failure response needed. As a result, we allow a default value, and we handle the missing + // values afterward. + if ($service === null || $ticket === null) { + $messagePostfix = $service === null ? 'service' : 'ticket'; + Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]"); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST, + ); + } + + try { + // Get the service ticket + // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // unserialization handlers. + $serviceTicket = $this->ticketStore->getTicket($ticket); + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + } catch (\Exception $e) { + Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true)); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $failed = false; + $message = ''; + if (empty($serviceTicket)) { + // No ticket + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { + // This is not a service ticket + $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; + $failed = true; + } elseif ($this->ticketFactory->isExpired($serviceTicket)) { + // the ticket has expired + $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; + $failed = true; + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($service)) { + // The service url we passed to the query parameters does not match the one in the ticket. + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($service, true); + $failed = true; + } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { + // If `forceAuthn` is required but not set in the ticket + $message = 'Ticket was issued from single sign on session'; + $failed = true; + } + + if ($failed) { + Logger::error('casserver:validate: ' . $message); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST, + ); + } + + // Fail if the username is not present in the ticket + if (empty($serviceTicket['userName'])) { + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST, + ); + } + + // Successful validation + return new Response( + $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['userName']), + Response::HTTP_OK, + ); + } + + /** + * Used by the unit tests + * + * @return TicketStore + */ + public function getTicketStore(): TicketStore + { + return $this->ticketStore; + } +} diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php new file mode 100644 index 00000000..6b875ed5 --- /dev/null +++ b/src/Controller/Cas20Controller.php @@ -0,0 +1,240 @@ +casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + $this->cas20Protocol = new Cas20($this->casConfig); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); + $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); + } + + /** + * @param Request $request + * @param string|null $TARGET Query parameter name for "service" used by older CAS clients' + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary + * credentials. It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback + * + * @return XmlResponse + */ + public function serviceValidate( + Request $request, + #[MapQueryParameter] ?string $TARGET = null, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $pgtUrl = null, + ): XmlResponse { + return $this->validate( + request: $request, + method: 'serviceValidate', + renew: $renew, + target: $TARGET, + ticket: $ticket, + service: $service, + pgtUrl: $pgtUrl, + ); + } + + /** + * /proxy provides proxy tickets to services that have + * acquired proxy-granting tickets and will be proxying authentication to back-end services. + * + * @param Request $request + * @param string|null $targetService [REQUIRED] - the service identifier of the back-end service. + * @param string|null $pgt [REQUIRED] - the proxy-granting ticket acquired by the service + * during service ticket or proxy ticket validation. + * + * @return XmlResponse + * @throws \ErrorException + */ + public function proxy( + Request $request, + #[MapQueryParameter] ?string $targetService = null, + #[MapQueryParameter] ?string $pgt = null, + ): XmlResponse { + // NOTE: Here we do not override the configuration + $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []); + // Fail if + $message = match (true) { + // targetService parameter is not defined + $targetService === null => 'Missing target service parameter [targetService]', + // pgt parameter is not defined + $pgt === null => 'Missing proxy granting ticket parameter: [pgt]', + !$this->checkServiceURL($this->sanitize($targetService), $legal_target_service_urls) => + "Target service parameter not listed as a legal service: [targetService] = {$targetService}", + default => null, + }; + + if (!empty($message)) { + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Get the ticket + $proxyGrantingTicket = $this->ticketStore->getTicket($pgt); + $message = match (true) { + // targetService parameter is not defined + $proxyGrantingTicket === null => "Ticket {$pgt} not recognized", + // pgt parameter is not defined + !$this->ticketFactory->isProxyGrantingTicket($proxyGrantingTicket) + => "Not a valid proxy granting ticket id: {$pgt}", + default => null, + }; + + if (!empty($message)) { + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse('BAD_PGT', $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Get the session id from the ticket + $sessionTicket = $this->ticketStore->getTicket($proxyGrantingTicket['sessionId']); + + if ( + $sessionTicket === null + || $this->ticketFactory->isSessionTicket($sessionTicket) === false + || $this->ticketFactory->isExpired($sessionTicket) + ) { + $message = "Ticket {$pgt} has expired"; + Logger::debug('casserver:' . $message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse('BAD_PGT', $message), + Response::HTTP_BAD_REQUEST, + ); + } + + $proxyTicket = $this->ticketFactory->createProxyTicket( + [ + 'service' => $targetService, + 'forceAuthn' => $proxyGrantingTicket['forceAuthn'], + 'attributes' => $proxyGrantingTicket['attributes'], + 'proxies' => $proxyGrantingTicket['proxies'], + 'sessionId' => $proxyGrantingTicket['sessionId'], + ], + ); + + $this->ticketStore->addTicket($proxyTicket); + + return new XmlResponse( + (string)$this->cas20Protocol->getProxySuccessResponse($proxyTicket['id']), + Response::HTTP_OK, + ); + } + + /** + * @param Request $request + * @param string|null $TARGET Query parameter name for "service" used by older CAS clients' + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary + * credentials. It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback + * + * @return XmlResponse + */ + public function proxyValidate( + Request $request, + #[MapQueryParameter] ?string $TARGET = null, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $pgtUrl = null, + ): XmlResponse { + return $this->validate( + request: $request, + method: 'proxyValidate', + renew: $renew, + target: $TARGET, + ticket: $ticket, + service: $service, + pgtUrl: $pgtUrl, + ); + } + + /** + * Used by the unit tests + * + * @return TicketStore + */ + public function getTicketStore(): TicketStore + { + return $this->ticketStore; + } +} diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php new file mode 100644 index 00000000..6765e7a5 --- /dev/null +++ b/src/Controller/Cas30Controller.php @@ -0,0 +1,133 @@ +casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + $this->cas20Protocol = new Cas20($this->casConfig); + $this->ticketValidator = $ticketValidator ?? new TicketValidator($this->casConfig); + $this->validateResponder = new SamlValidateResponder(); + } + + + /** + * POST /casserver/samlValidate?TARGET= + * Host: cas.example.com + * Content-Length: 491 + * Content-Type: text/xml + * + * @param Request $request + * @param string $TARGET URL encoded service identifier of the back-end service. + * + * @throw SimpleSAML\SAML11\Exception\ProtocolViolationException + * @throw SimpleSAML\XML\Exception\MissingAttributeException + * @throw \RuntimeException + * @return XmlResponse + * @link https://apereo.github.io/cas/7.1.x/protocol/CAS-Protocol-Specification.html#42-samlvalidate-cas-30 + */ + public function samlValidate( + Request $request, + #[MapQueryParameter] string $TARGET, + ): XmlResponse { + $postBody = $request->getContent(); + if (empty($postBody)) { + throw new \RuntimeException('samlValidate expects a soap body.'); + } + + // SAML request values + // + // samlp:Request + // - RequestID [REQUIRED] - unique identifier for the request + // - IssueInstant [REQUIRED] - timestamp of the request + // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service + + $documentBody = DOMDocumentFactory::fromString($postBody); + $envelope = Envelope::fromXML($documentBody->documentElement); + + // The SOAP Envelope must have only one ticket + $elements = $envelope->getBody()->getElements(); + if (count($elements) > 1 || count($elements) < 1) { + throw new ProtocolViolationException('samlValidate expects a soap body with only one ticket.'); + } + + // Request Element + $samlpRequestParsed = SamlRequest::fromXML($elements[0]->getXML()); + // Assertion Artifact Element + $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0]; + + $ticketId = $assertionArtifactParsed->getContent(); + Logger::debug('samlvalidate: Checking ticket ' . $ticketId); + + try { + // validateAndDeleteTicket might throw a CasException. In order to avoid third party modules + // dependencies, we will catch and rethrow the Exception. + $ticket = $this->ticketValidator->validateAndDeleteTicket($ticketId, $TARGET); + } catch (\Exception $e) { + throw new \RuntimeException($e->getMessage()); + } + if (!\is_array($ticket)) { + throw new \RuntimeException('Error loading ticket'); + } + + $response = $this->validateResponder->convertToSaml($ticket); + $soap = $this->validateResponder->wrapInSoap($response); + + return new XmlResponse( + (string)$soap, + Response::HTTP_OK, + ); + } +} diff --git a/src/Controller/LoggedInController.php b/src/Controller/LoggedInController.php new file mode 100644 index 00000000..e4049f65 --- /dev/null +++ b/src/Controller/LoggedInController.php @@ -0,0 +1,45 @@ +config = $config ?? Configuration::getInstance(); + } + + /** + * Show Log out view. + * + * @param Request $request + * @return Response + * @throws \Exception + */ + public function main(Request $request): Response + { + session_cache_limiter('nocache'); + return new Template($this->config, 'casserver:loggedIn.twig'); + } +} diff --git a/src/Controller/LoggedOutController.php b/src/Controller/LoggedOutController.php new file mode 100644 index 00000000..b645a3fb --- /dev/null +++ b/src/Controller/LoggedOutController.php @@ -0,0 +1,48 @@ +config = $config ?? Configuration::getInstance(); + } + + /** + * Show Log out view. + * + * @param Request $request + * @return Response + * @throws \Exception + */ + public function main(Request $request): Response + { + $t = new Template($this->config, 'casserver:loggedOut.twig'); + if ($request->query->has('url')) { + $t->data['url'] = $request->query->get('url'); + } + return $t; + } +} diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php new file mode 100644 index 00000000..b678ca99 --- /dev/null +++ b/src/Controller/LoginController.php @@ -0,0 +1,453 @@ +casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + // Saml Validate Responsder + $this->samlValidateResponder = new SamlValidateResponder(); + // Service Validator needs the generic casserver configuration. + $this->serviceValidator = new ServiceValidator($this->casConfig); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); + } + + /** + * + * @param Request $request + * @param bool $renew + * @param bool $gateway + * @param string|null $service + * @param string|null $TARGET Query parameter name for "service" used by older CAS clients' + * @param string|null $scope + * @param string|null $language + * @param string|null $entityId + * @param string|null $debugMode + * @param string|null $method + * + * @return RunnableResponse|Template + * @throws ConfigurationError + * @throws NoState + */ + public function login( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] bool $gateway = false, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $TARGET = null, + #[MapQueryParameter] ?string $scope = null, + #[MapQueryParameter] ?string $language = null, + #[MapQueryParameter] ?string $entityId = null, + #[MapQueryParameter] ?string $debugMode = null, + #[MapQueryParameter] ?string $method = null, + ): RunnableResponse|Template { + $forceAuthn = $renew; + $serviceUrl = $service ?? $TARGET ?? null; + $redirect = !(isset($method) && $method === 'POST'); + + // Set initial configurations, or fail + $this->handleServiceConfiguration($serviceUrl); + // Instantiate the classes that rely on the override configuration. + // We do not do this in the constructor since we do not have the correct values yet. + $this->instantiateClassDependencies(); + $this->handleScope($scope); + $this->handleLanguage($language); + + // Get the ticket from the session + $session = $this->getSession(); + $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); + $sessionRenewId = $sessionTicket['renewId'] ?? null; + $requestRenewId = $this->getRequestParam($request, 'renewId'); + // if this parameter is true, single sign-on will be bypassed and authentication will be enforced + $requestForceAuthenticate = $forceAuthn && $sessionRenewId !== $requestRenewId; + + if ($request->query->has(ProcessingChain::AUTHPARAM)) { + $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); + } + + // Construct the ReturnTo URL + // This will be used to come back from the AuthSource login or from the Processing Chain + $returnToUrl = $this->getReturnUrl($request, $sessionTicket); + + // Authenticate + if ( + $requestForceAuthenticate || !$this->authSource->isAuthenticated() + ) { + $params = [ + 'ForceAuthn' => $forceAuthn, + 'isPassive' => $gateway, + 'ReturnTo' => $returnToUrl, + ]; + + if (isset($entityId)) { + $params['saml:idp'] = $entityId; + } + + if (isset($this->idpList)) { + if (sizeof($this->idpList) > 1) { + $params['saml:IDPList'] = $this->idpList; + } else { + $params['saml:idp'] = $this->idpList[0]; + } + } + + /* + * REDIRECT TO AUTHSOURCE LOGIN + * */ + return new RunnableResponse( + [$this->authSource, 'login'], + [$params], + ); + } + + // We are Authenticated. + + $sessionExpiry = $this->authSource->getAuthData('Expire'); + // Create a new ticket if we do not have one alreday, or if we are in a forced Authentitcation mode + if (!\is_array($sessionTicket) || $forceAuthn) { + $sessionTicket = $this->ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry); + $this->ticketStore->addTicket($sessionTicket); + } + + /* + * We are done. REDIRECT TO LOGGEDIN + * */ + if (!isset($serviceUrl) && $this->authProcId === null) { + $loggedInUrl = Module::getModuleURL('casserver/loggedIn'); + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$loggedInUrl, $this->postAuthUrlParameters], + ); + } + + // Get the state. + $state = $this->getState(); + $state['ReturnTo'] = $returnToUrl; + if ($this->authProcId !== null) { + $state[ProcessingChain::AUTHPARAM] = $this->authProcId; + } + // Attribute Handler + $mappedAttributes = $this->attributeExtractor->extractUserAndAttributes($state); + $serviceTicket = $this->ticketFactory->createServiceTicket([ + 'service' => $serviceUrl, + 'forceAuthn' => $forceAuthn, + 'userName' => $mappedAttributes['user'], + 'attributes' => $mappedAttributes['attributes'], + 'proxies' => [], + 'sessionId' => $sessionTicket['id'], + ]); + $this->ticketStore->addTicket($serviceTicket); + + // Check if we are in debug mode. + if ($debugMode !== null && $this->casConfig->getOptionalBoolean('debugMode', false)) { + [$templateName, $statusCode, $DebugModeXmlString] = $this->handleDebugMode( + $request, + $debugMode, + $serviceTicket, + ); + $t = new Template($this->sspConfig, (string)$templateName); + $t->data['debugMode'] = $debugMode === 'true' ? 'Default' : $debugMode; + if (!str_contains('error', (string)$templateName)) { + $t->data['DebugModeXml'] = $DebugModeXmlString; + } + $t->data['statusCode'] = $statusCode; + // Return an HTML View that renders the result + return $t; + } + + $ticketName = $this->calculateTicketName($service); + $this->postAuthUrlParameters[$ticketName] = $serviceTicket['id']; + + // GET + if ($redirect) { + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$serviceUrl, $this->postAuthUrlParameters], + ); + } + // POST + return new RunnableResponse( + [$this->httpUtils, 'submitPOSTData'], + [$serviceUrl, $this->postAuthUrlParameters], + ); + } + + /** + * @param Request $request + * @param string|null $debugMode + * @param array $serviceTicket + * + * @return array [] + */ + public function handleDebugMode( + Request $request, + ?string $debugMode, + array $serviceTicket, + ): array { + // Check if the debugMode is supported + if (!\in_array($debugMode, self::DEBUG_MODES, true)) { + return ['casserver:error.twig', Response::HTTP_BAD_REQUEST, 'Invalid/Unsupported Debug Mode']; + } + + if ($debugMode === 'true') { + // Service validate CAS20 + $xmlResponse = $this->validate( + request: $request, + method: 'serviceValidate', + renew: $request->get('renew', false), + target: $request->get('target'), + ticket: $serviceTicket['id'], + service: $request->get('service'), + pgtUrl: $request->get('pgtUrl'), + ); + return ['casserver:validate.twig', $xmlResponse->getStatusCode(), $xmlResponse->getContent()]; + } + + // samlValidate Mode + $samlResponse = $this->samlValidateResponder->convertToSaml($serviceTicket); + return [ + 'casserver:validate.twig', + Response::HTTP_OK, + (string)$this->samlValidateResponder->wrapInSoap($samlResponse), + ]; + } + + /** + * @return array|null + * @throws \SimpleSAML\Error\NoState + */ + public function getState(): ?array + { + // If we come from an authproc filter, we will load the state from the stateId. + // If not, we will get the state from the AuthSource Data + + return $this->authProcId !== null ? + $this->attributeExtractor->manageState($this->authProcId) : + $this->authSource->getAuthDataArray(); + } + + /** + * Construct the ticket name + * + * @param string|null $service + * + * @return string + */ + public function calculateTicketName(?string $service): string + { + $defaultTicketName = $service !== null ? 'ticket' : 'SAMLart'; + return $this->casConfig->getOptionalValue('ticketName', $defaultTicketName); + } + + /** + * @param Request $request + * @param array|null $sessionTicket + * + * @return string + */ + public function getReturnUrl(Request $request, ?array $sessionTicket): string + { + // Parse the query parameters and return them in an array + $query = $this->parseQueryParameters($request, $sessionTicket); + // Construct the ReturnTo URL + return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); + } + + /** + * @param string|null $serviceUrl + * + * @return void + * @throws \RuntimeException + */ + public function handleServiceConfiguration(?string $serviceUrl): void + { + if ($serviceUrl === null) { + return; + } + $serviceCasConfig = $this->serviceValidator->checkServiceURL($this->sanitize($serviceUrl)); + if (!isset($serviceCasConfig)) { + $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' . + var_export($serviceUrl, true); + Logger::debug('casserver:' . $message); + + throw new \RuntimeException($message); + } + + // Override the cas configuration to use for this service + $this->casConfig = $serviceCasConfig; + } + + /** + * @param string|null $language + * + * @return void + */ + public function handleLanguage(?string $language): void + { + // If null, do nothing + if ($language === null) { + return; + } + + $this->postAuthUrlParameters['language'] = $language; + } + + /** + * @param string|null $scope + * + * @return void + * @throws \RuntimeException + */ + public function handleScope(?string $scope): void + { + // If null, do nothing + if ($scope === null) { + return; + } + + // Get the scopes from the configuration + $scopes = $this->casConfig->getOptionalValue('scopes', []); + + // Fail + if (!isset($scopes[$scope])) { + $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' . + var_export($scope, true); + Logger::debug('casserver:' . $message); + + throw new \RuntimeException($message); + } + + // Set the idplist from the scopes + $this->idpList = $scopes[$scope]; + } + + /** + * Get the Session + * + * @return Session|null + * @throws \Exception + */ + public function getSession(): ?Session + { + return Session::getSessionFromRequest(); + } + + /** + * @return TicketStore + */ + public function getTicketStore(): TicketStore + { + return $this->ticketStore; + } + + /** + * @return void + * @throws \Exception + */ + private function instantiateClassDependencies(): void + { + $this->cas20Protocol = new Cas20($this->casConfig); + + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); + // Ticket Store + $this->ticketStore = new $ticketStoreClass($this->casConfig); + // Processing Chain Factory + $processingChainFactory = new ProcessingChainFactory($this->casConfig); + // Attribute Extractor + $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory); + } +} diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php new file mode 100644 index 00000000..c0922a2d --- /dev/null +++ b/src/Controller/LogoutController.php @@ -0,0 +1,157 @@ +casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); + + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); + $this->ticketStore = new $ticketStoreClass($this->casConfig); + } + + /** + * + * @param Request $request + * @param string|null $url + * + * @return RunnableResponse + */ + public function logout( + Request $request, + #[MapQueryParameter] ?string $url = null, + ): RunnableResponse { + if (!$this->casConfig->getOptionalValue('enable_logout', false)) { + $this->handleExceptionThrown('Logout not allowed'); + } + + // Skip Logout Page configuration + $skipLogoutPage = $this->casConfig->getOptionalValue('skip_logout_page', false); + + if ($skipLogoutPage && $url === null) { + $this->handleExceptionThrown('Required URL query parameter [url] not provided. (CAS Server)'); + } + + // Construct the logout redirect url + if ($skipLogoutPage) { + $logoutRedirectUrl = $url; + $params = []; + } else { + $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut'); + $params = $url === null ? [] + : ['url' => $url]; + } + + // Delete the ticket from the session + $session = $this->getSession(); + if ($session !== null) { + $this->ticketStore->deleteTicket($session->getSessionId()); + } + + // Redirect + if (!$this->authSource->isAuthenticated()) { + return new RunnableResponse([$this->httpUtils, 'redirectTrustedURL'], [$logoutRedirectUrl, $params]); + } + + // Logout and redirect + return new RunnableResponse( + [$this->authSource, 'logout'], + [$logoutRedirectUrl], + ); + } + + /** + * @return TicketStore + */ + public function getTicketStore(): TicketStore + { + return $this->ticketStore; + } + + /** + * @param string $message + * + * @return void + */ + protected function handleExceptionThrown(string $message): void + { + Logger::debug('casserver:' . $message); + throw new \RuntimeException($message); + } + + /** + * Get the Session + * + * @return Session|null + */ + protected function getSession(): ?Session + { + return Session::getSession(); + } +} diff --git a/src/Controller/Traits/TicketValidatorTrait.php b/src/Controller/Traits/TicketValidatorTrait.php new file mode 100644 index 00000000..f4d7709c --- /dev/null +++ b/src/Controller/Traits/TicketValidatorTrait.php @@ -0,0 +1,184 @@ +cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + try { + // Get the service ticket + // `getTicket` uses the unserializable method and Objects may throw "Throwables" in their + // un-serialization handlers. + $serviceTicket = $this->ticketStore->getTicket($ticket); + } catch (\Exception $e) { + $messagePostfix = ''; + if (!empty($e->getMessage())) { + $messagePostfix = ': ' . var_export($e->getMessage(), true); + } + $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; + Logger::error(__METHOD__ . '::' . $message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $message), + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $failed = false; + $message = ''; + // Below, we do not have a ticket or the ticket does not meet the very basic criteria that allow + // any further handling + if (empty($serviceTicket)) { + // No ticket + $message = 'Ticket ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif ($method === 'proxyValidate' && !$this->ticketFactory->isProxyTicket($serviceTicket)) { + // proxyValidate but not a proxy ticket + $message = 'Ticket ' . var_export($ticket, true) . ' is not a proxy ticket.'; + $failed = true; + } elseif ($method === 'serviceValidate' && !$this->ticketFactory->isServiceTicket($serviceTicket)) { + // serviceValidate but not a service ticket + $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket.'; + $failed = true; + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error(__METHOD__ . '::' . $finalMessage); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + + // Check if the ticket + // - has expired + // - does not pass sanitization + // - forceAutnn criteria are not met + if ($this->ticketFactory->isExpired($serviceTicket)) { + // the ticket has expired + $message = 'Ticket ' . var_export($ticket, true) . ' has expired'; + $failed = true; + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { + // The service url we passed to the query parameters does not match the one in the ticket. + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($serviceUrl, true); + $failed = true; + } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { + // If `forceAuthn` is required but not set in the ticket + $message = 'Ticket was issued from single sign on session'; + $failed = true; + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error(__METHOD__ . '::' . $finalMessage); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + $attributes = $serviceTicket['attributes']; + $this->cas20Protocol->setAttributes($attributes); + + if (isset($pgtUrl)) { + $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']); + if ( + $sessionTicket !== null + && $this->ticketFactory->isSessionTicket($sessionTicket) + && !$this->ticketFactory->isExpired($sessionTicket) + ) { + $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket( + [ + 'userName' => $serviceTicket['userName'], + 'attributes' => $attributes, + 'forceAuthn' => false, + 'proxies' => array_merge( + [$serviceUrl], + $serviceTicket['proxies'], + ), + 'sessionId' => $serviceTicket['sessionId'], + ], + ); + try { + // Here we assume that the fetch will throw on any error. + // The generation of the proxy-granting-ticket or the corresponding proxy granting ticket IOU may + // fail due to the proxy callback url failing to meet the minimum security requirements such as + // failure to establish trust between peers or unresponsiveness of the endpoint, etc. + // In case of failure, no proxy-granting ticket will be issued and the CAS service response + // as described in Section 2.5.2 MUST NOT contain a block. + // At this point, the issuance of a proxy-granting ticket is halted and service ticket + // validation will fail. + $data = $this->httpUtils->fetch( + $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], + ); + Logger::debug(__METHOD__ . '::data: ' . var_export($data, true)); + $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); + $this->ticketStore->addTicket($proxyGrantingTicket); + } catch (\Exception $e) { + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse( + C::ERR_INVALID_SERVICE, + 'Proxy callback url is failing.', + ), + Response::HTTP_BAD_REQUEST, + ); + } + } + } + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), + Response::HTTP_OK, + ); + } +} diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php new file mode 100644 index 00000000..a2192f1d --- /dev/null +++ b/src/Controller/Traits/UrlTrait.php @@ -0,0 +1,78 @@ + $legal_service_urls]); + $serviceValidator = new ServiceValidator($config); + return $serviceValidator->checkServiceURL($service) !== null; + } + + /** + * @param string $parameter + * @return string + */ + public function sanitize(string $parameter): string + { + return TicketValidator::sanitize($parameter); + } + + /** + * Parse the query Parameters from $_GET global and return them in an array. + * + * @param Request $request + * @param array|null $sessionTicket + * + * @return array + */ + public function parseQueryParameters(Request $request, ?array $sessionTicket): array + { + $forceAuthn = $this->getRequestParam($request, 'renew'); + $sessionRenewId = !empty($sessionTicket['renewId']) ? $sessionTicket['renewId'] : null; + + $queryParameters = $request->query->all(); + $requestParameters = $request->request->all(); + + $query = array_merge($requestParameters, $queryParameters); + + if ($sessionRenewId && $forceAuthn) { + $query['renewId'] = $sessionRenewId; + } + + if (isset($query['language'])) { + $query['language'] = is_string($query['language']) ? $query['language'] : null; + } + + return $query; + } + + /** + * @param Request $request + * @param string $paramName + * + * @return mixed + */ + public function getRequestParam(Request $request, string $paramName): mixed + { + return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; + } +} diff --git a/src/Http/XmlResponse.php b/src/Http/XmlResponse.php new file mode 100644 index 00000000..23e00c0e --- /dev/null +++ b/src/Http/XmlResponse.php @@ -0,0 +1,17 @@ + 'text/xml; charset=UTF-8', + ])); + } +} diff --git a/templates/error.twig b/templates/error.twig new file mode 100644 index 00000000..67517a17 --- /dev/null +++ b/templates/error.twig @@ -0,0 +1,8 @@ +{% set pagetitle = 'Debug Mode'|trans %} + +{% extends "base.twig" %} + +{% block content -%} +

{{ pagetitle }} - {{ debugMode|trans }}

+
Debug Method NOT Supported
+{% endblock %} diff --git a/templates/validate.twig b/templates/validate.twig new file mode 100644 index 00000000..6c42bf36 --- /dev/null +++ b/templates/validate.twig @@ -0,0 +1,30 @@ +{% set pagetitle = 'Debug Mode'|trans %} +{% set copied = '{copied}'|trans %} +{% set copy = '{copy}'|trans %} +{% set success = '{success}'|trans %} +{% set failure = '{failure}'|trans %} +{% set copyToClipboard = '{copyToClipboard}'|trans %} + +{% extends "base.twig" %} + +{% block preload %} + +{% endblock %} + +{% block content -%} +

{{ pagetitle }} - {{ debugMode|trans }}

+ {%- if statusCode == 200 -%} +
{{ success }}
+ {%- else -%} +
{{ failure }}
+ {%- endif -%} +
+
+ +
+
{{ DebugModeXml }}
+
+{%- endblock %} diff --git a/tests/public/LoginIntegrationTest.php b/tests/public/LoginIntegrationTest.php deleted file mode 100644 index a431e985..00000000 --- a/tests/public/LoginIntegrationTest.php +++ /dev/null @@ -1,527 +0,0 @@ -server = new BuiltInServer( - 'configLoader', - dirname(__FILE__, 3) . '/vendor/simplesamlphp/simplesamlphp/public', - ); - $this->server_addr = $this->server->start(); - $this->server_pid = $this->server->getPid(); - $this->shared_file = sys_get_temp_dir() . '/' . $this->server_pid . '.lock'; - $this->cookies_file = sys_get_temp_dir() . '/' . $this->server_pid . '.cookies'; - - $this->updateConfig([ - 'baseurlpath' => '/', - 'secretsalt' => 'abc123', - - 'tempdir' => sys_get_temp_dir(), - 'loggingdir' => sys_get_temp_dir(), - 'logging.handler' => 'file', - - 'module.enable' => [ - 'casserver' => true, - 'exampleauth' => true, - ], - ]); - } - - - /** - * The tear down method that is executed after all tests in this class. - * Removes the lock file and cookies file - */ - protected function tearDown(): void - { - @unlink($this->shared_file); - @unlink($this->cookies_file); // remove it if it exists - $this->server->stop(); - } - - - /** - * @param array $config - */ - protected function updateConfig(array $config): void - { - @unlink($this->shared_file); - $file = "shared_file, $file); - - Configuration::setPreloadedConfig(Configuration::loadFromArray($config)); - } - - - /** - * Test authenticating to the login endpoint with no parameters.' - */ - public function testNoQueryParameters(): void - { - $resp = $this->server->get( - self::$LINK_URL, - [], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - CURLOPT_FOLLOWLOCATION => true, - ], - ); - $this->assertEquals(200, $resp['code']); - - $this->assertStringContainsString( - 'You are logged in.', - $resp['body'], - 'Login with no query parameters should make you authenticate and then take you to the login page.', - ); - } - - - /** - * Test incorrect service url - */ - public function testWrongServiceUrl(): void - { - $resp = $this->server->get( - self::$LINK_URL, - ['service' => 'http://not-legal'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - CURLOPT_FOLLOWLOCATION => true, - ], - ); - $this->assertEquals(500, $resp['code']); - - $this->assertStringContainsString( - 'CAS server is not listed as a legal service', - $resp['body'], - 'Illegal cas service urls should be rejected', - ); - } - - - /** - * Test a valid service URL - * @param string $serviceParam The name of the query parameter to use for the service url - * @param string $ticketParam The name of the query parameter that will contain the ticket - */ - #[DataProvider('validServiceUrlProvider')] - public function testValidServiceUrl(string $serviceParam, string $ticketParam): void - { - $service_url = 'http://host1.domain:1234/path1'; - - $this->authenticate(); - - $resp = $this->server->get( - self::$LINK_URL, - [$serviceParam => $service_url], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(303, $resp['code']); - - $this->assertStringStartsWith( - $service_url . '?' . $ticketParam . '=ST-', - $resp['headers']['Location'], - 'Ticket should be part of the redirect.', - ); - - // Config ticket can be validated - $matches = []; - $this->assertEquals(1, preg_match("@$ticketParam=(.*)@", $resp['headers']['Location'], $matches)); - $ticket = $matches[1]; - $resp = $this->server->get( - self::$VALIDATE_URL, - [ - $serviceParam => $service_url, - 'ticket' => $ticket, - ], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - - $expectedXml = simplexml_load_string( - file_get_contents(\dirname(__FILE__, 2) . '/resources/xml/testValidServiceUrl.xml'), - ); - - $actualXml = simplexml_load_string($resp['body']); - - // We will remove the cas:authenticationDate element since we know that it will fail. The dates will not match - $authenticationNodeToDeleteExpected = $expectedXml->xpath('//cas:authenticationDate')[0]; - $authenticationNodeToDeleteActual = $actualXml->xpath('//cas:authenticationDate')[0]; - unset($authenticationNodeToDeleteExpected[0], $authenticationNodeToDeleteActual[0]); - - $this->assertEquals(200, $resp['code']); - - $this->assertEquals( - $expectedXml->xpath('//cas:serviceResponse')[0]->asXML(), - $actualXml->xpath('//cas:serviceResponse')[0]->asXML(), - ); - } - - public static function validServiceUrlProvider(): array - { - return [ - ['service', 'ticket'], - ['TARGET', 'SAMLart'], - ]; - } - - /** - * Test changing the ticket name - */ - public function testValidTicketNameOverride(): void - { - $service_url = 'http://changeTicketParam/abc'; - - $this->authenticate(); - - $resp = $this->server->get( - self::$LINK_URL, - ['TARGET' => $service_url], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(303, $resp['code']); - - $this->assertStringStartsWith( - $service_url . '?myTicket=ST-', - $resp['headers']['Location'], - 'Ticket should be part of the redirect.', - ); - } - - - /** - * Test outputting user info instead of redirecting - */ - public function testDebugOutput(): void - { - $service_url = 'http://host1.domain:1234/path1'; - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url, 'debugMode' => 'true'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(200, $resp['code']); - - $this->assertStringContainsString( - '<cas:eduPersonPrincipalName>testuser@example.com</cas:eduPersonPrincipalName>', - $resp['body'], - 'Attributes should have been printed.', - ); - } - - - /** - * Test outputting user info instead of redirecting - */ - public function testDebugOutputSamlValidate(): void - { - $service_url = 'http://host1.domain:1234/path1'; - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url, 'debugMode' => 'samlValidate'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(200, $resp['code']); - - - $this->assertStringContainsString( - 'testuser@example.com</saml:NameIdentifier', - $resp['body'], - 'Attributes should have been printed.', - ); - } - - - /** - * Test outputting user info instead of redirecting - */ - public function testAlternateServiceConfigUsed(): void - { - $service_url = 'https://override.example.com/somepath'; - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url, 'debugMode' => 'true'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(200, $resp['code']); - $this->assertStringContainsString( - '<cas:user>testuser</cas:user>', - $resp['body'], - 'cas:user attribute should have been overridden', - ); - $this->assertStringContainsString( - '<cas:cn>Test User</cas:cn>', - $resp['body'], - 'Attributes should have been printed with alternate attribute release', - ); - } - - - /** - * test a valid service URL with Post - */ - public function testValidServiceUrlWithPost(): void - { - $service_url = 'http://host1.domain:1234/path1'; - - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - [ - 'service' => $service_url, - 'method' => 'POST', - ], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - - // POST responds with a form that is uses JavaScript to submit - $this->assertEquals(200, $resp['code']); - - // Validate the form contains the required elements - $body = $resp['body']; - $dom = new DOMDocument(); - $dom->loadHTML($body); - $form = $dom->getElementsByTagName('form'); - $item = $form->item(0); - if (is_null($item)) { - $this->fail('Unable to parse response.'); - return; - } - - $this->assertEquals($service_url, $item->getAttribute('action')); - $formInputs = $dom->getElementsByTagName('input'); - //note: $formInputs[0] is ''. See the post.php template from SSP - $item = $formInputs->item(1); - if (is_null($item)) { - $this->fail('Unable to parse response.'); - return; - } - $this->assertEquals( - 'ticket', - $item->getAttribute('name'), - ); - $this->assertStringStartsWith( - 'ST-', - $item->getAttribute('value'), - '', - ); - } - - - /** - */ - public function testSamlValidate(): void - { - $service_url = 'http://host1.domain:1234/path1'; - $this->authenticate(); - - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(303, $resp['code']); - - $this->assertStringStartsWith( - $service_url . '?ticket=ST-', - $resp['headers']['Location'], - 'Ticket should be part of the redirect.', - ); - - $location = $resp['headers']['Location']; - $matches = []; - $this->assertEquals(1, preg_match('@ticket=(.*)@', $location, $matches)); - $ticket = $matches[1]; - $soapRequest = << - - - - $ticket - - - -SOAP; - - $resp = $this->post( - self::$SAMLVALIDATE_URL, - $soapRequest, - [ - 'TARGET' => $service_url, - ], - ); - - $this->assertEquals(200, $resp['code']); - $this->assertStringContainsString('testuser@example.com', $resp['body']); - } - - - /** - * Sets up an authenticated session for the cookie $jar - */ - private function authenticate(): void - { - // Use cookies Jar to store auth session cookies - $resp = $this->server->get( - self::$LINK_URL, - [], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - CURLOPT_FOLLOWLOCATION => true, - ], - ); - $this->assertEquals(200, $resp['code'], $resp['body']); - } - - - /** - * TODO: migrate into BuiltInServer - * @param \CurlHandle $ch - * @return array - */ - private function execAndHandleCurlResponse(CurlHandle $ch): array - { - $resp = curl_exec($ch); - if ($resp === false) { - throw new \Exception('curl error: ' . curl_error($ch)); - } - - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - list($header, $body) = explode("\r\n\r\n", $resp, 2); - $raw_headers = explode("\r\n", $header); - array_shift($raw_headers); - $headers = []; - foreach ($raw_headers as $header) { - list($name, $value) = explode(':', $header, 2); - $headers[trim($name)] = trim($value); - } - curl_close($ch); - return [ - 'code' => $code, - 'headers' => $headers, - 'body' => $body, - ]; - } - - - /** - * TODO: migrate into BuiltInServer - * @param string $query The path at the embedded server to query - * @param string|array $body The content to post - * @param array $parameters Any query parameters to add. - * @param array $curlopts Additional curl options - * @return array The response code, headers and body - */ - public function post($query, $body, $parameters = [], $curlopts = []): array - { - $ch = curl_init(); - $url = 'http://' . $this->server_addr . $query; - $url .= (!empty($parameters)) ? '?' . http_build_query($parameters) : ''; - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => 1, - CURLOPT_HEADER => 1, - CURLOPT_POST => 1, - ]); - - // body may be multi dimensional, so we convert it ourselves - // https://stackoverflow.com/a/21111209/54396 - $postParam = is_string($body) ? $body : http_build_query($body); - curl_setopt($ch, CURLOPT_POSTFIELDS, $postParam); - curl_setopt_array($ch, $curlopts); - return $this->execAndHandleCurlResponse($ch); - } -} diff --git a/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb new file mode 100644 index 00000000..f04b8568 --- /dev/null +++ b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb @@ -0,0 +1 @@ +a:8:{s:2:"id";s:45:"ST-892424614ae738153cf6fda6ea372f54489870b8eb";s:11:"validBefore";i:9939632015;s:7:"service";s:233:"https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3Ahttps%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver";s:10:"forceAuthn";b:0;s:8:"userName";s:32:"107159103605108465131@google.com";s:10:"attributes";a:1:{s:22:"eduPersonPrincipalName";a:1:{i:0;s:32:"107159103605108465131@google.com";}}s:7:"proxies";a:0:{}s:9:"sessionId";s:26:"23n62fa26kck94olh1cpcftsq5";} \ No newline at end of file diff --git a/tests/resources/saml/samlRequest.xml b/tests/resources/saml/samlRequest.xml new file mode 100644 index 00000000..6de610ab --- /dev/null +++ b/tests/resources/saml/samlRequest.xml @@ -0,0 +1,8 @@ + + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php new file mode 100644 index 00000000..0b37f18a --- /dev/null +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -0,0 +1,335 @@ +sspConfig = Configuration::getConfig('config.php'); + $this->sessionId = session_create_id(); + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->ticket = [ + 'id' => 'ST-' . $this->sessionId, + 'validBefore' => 1731111111, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + } + + public static function setUpBeforeClass(): void + { + // Some of the constructs in this test cause a Configuration to be created prior to us + // setting the one we want to use for the test. + Configuration::clearInternalState(); + + // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work... + global $_SERVER; + $_SERVER['REQUEST_URI'] = '/'; + } + + public static function queryParameterValues(): array + { + return [ + 'Has Service' => [ + ['service' => 'https://myservice.com/abcd'], + ], + 'Has Ticket' => [ + ['ticket' => '1234567'], + ], + 'Has Neither Service Nor Ticket' => [ + [], + ], + ]; + } + + /** + * @param array $params + * + * @return void + * @throws Exception + */ + #[DataProvider('queryParameterValues')] + public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturn500OnDeleteTicketThatThrows(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $ticketStore = new class ($config) extends FileSystemTicketStore { + public function getTicket(string $ticketId): ?array + { + throw new Exception(); + } + }; + + /** @psalm-suppress InvalidArgument */ + $cas10Controller = new Cas10Controller($this->sspConfig, $config, $ticketStore); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturnBadRequestOnTicketNotExist(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturnBadRequestOnTicketExpired(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturnBadRequestOnTicketNotService(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $this->ticket['id'] = $this->sessionId; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturnBadRequestOnTicketMissingUsernameField(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + $this->ticket['validBefore'] = 9999999999; + $this->ticket['userName'] = ''; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/failservice', + ]; + $this->ticket['validBefore'] = 9999999999; + $this->ticket['attributes'] = []; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + 'renew' => true, + ]; + $this->ticket['validBefore'] = 9999999999; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + /** + * @return void + * @throws Exception + */ + public function testSuccessfullValidation(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $this->ticket['validBefore'] = 9999999999; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($this->sspConfig, $config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("yes\nusername@google.com\n", $response->getContent()); + } +} diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php new file mode 100644 index 00000000..679c275c --- /dev/null +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -0,0 +1,723 @@ +sspConfig = Configuration::getConfig('config.php'); + $this->sessionId = session_create_id(); + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + // Hard code the ticket store + $this->ticketStore = new FileSystemTicketStore(Configuration::loadFromArray($this->moduleConfig)); + + $this->ticketValidatorMock = $this->getMockBuilder(TicketValidator::class) + ->setConstructorArgs([Configuration::loadFromArray($this->moduleConfig)]) + ->onlyMethods(['validateAndDeleteTicket']) + ->getMock(); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->utilsHttpMock = $this->getMockBuilder(Utils\HTTP::class) + ->disableOriginalConstructor() + ->onlyMethods(['fetch']) + ->getMock(); + + $this->ticket = [ + 'id' => 'ST-' . $this->sessionId, + 'validBefore' => 1731111111, + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + + $this->proxyTicket = [ + 'id' => 'PT-' . $this->sessionId, + 'validBefore' => 1731111111, + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + } + + public static function validateMethods(): array + { + return [ + 'Call serviceValidate action' => [ + 'ST-', + 'serviceValidate', + ], + 'Call proxyValidate action' => [ + 'PT-', + 'proxyValidate', + ], + ]; + } + + #[DataProvider('validateMethods')] + public function testProxyValidatePassesTheCorrectMethodToValidate(string $prefix, string $method): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $requestParameters = [ + 'renew' => false, + 'service' => 'https://myservice.com/abcd', + 'ticket' => $prefix . $this->sessionId, + ]; + + $request = Request::create( + uri: 'https://myservice.com/abcd', + parameters: $requestParameters, + ); + + $expectedArguments = [ + 'request' => $request, + 'method' => $method, + 'renew' => false, + 'target' => null, + 'ticket' => $prefix . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + 'pgtUrl' => null, + ]; + + $controllerMock = $this->getMockBuilder(Cas20Controller::class) + ->setConstructorArgs([$this->sspConfig, $casconfig]) + ->onlyMethods(['validate']) + ->getMock(); + + $controllerMock->expects($this->once())->method('validate') + ->with(...$expectedArguments); + $controllerMock->$method($request, ...$requestParameters); + } + + public static function queryParameterValues(): array + { + return [ + 'Only targetService query parameter' => [ + ['targetService' => 'https://myservice.com/abcd', 'pgt' => null], + 'Missing proxy granting ticket parameter: [pgt]', + ], + 'Only pgt query parameter' => [ + ['pgt' => '1234567', 'targetService' => null], + 'Missing target service parameter [targetService]', + ], + 'Has Neither pgt Nor targetService query parameters' => [ + ['pgt' => null, 'targetService' => null], + 'Missing target service parameter [targetService]', + ], + 'Target service query parameter not listed' => [ + ['pgt' => 'pgt', 'targetService' => 'https://myservice.com/abcd'], + 'Target service parameter not listed as a legal service: [targetService] = https://myservice.com/abcd', + ], + ]; + } + + #[DataProvider('queryParameterValues')] + public function testProxyRequestFails(array $params, string $message): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_REQUEST, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public function testProxyRequestFailsWhenPgtNotRecognized(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $params = ['pgt' => 'pgt', 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']); + $this->assertEquals( + 'Ticket pgt not recognized', + $xml->xpath('//cas:authenticationFailure')[0], + ); + } + + public function testProxyRequestFailsWhenPgtNotValid(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $params = ['pgt' => $this->ticket['id'], 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $this->ticketStore->addTicket(['id' => $this->ticket['id']]); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']); + $this->assertEquals( + 'Not a valid proxy granting ticket id: ' . $this->ticket['id'], + $xml->xpath('//cas:authenticationFailure')[0], + ); + } + + public function testProxyRequestFailsWhenPgtExpired(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $ticket = [ + 'id' => 'PGT-' . $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'sessionId' => $this->sessionId, + ]; + $params = ['pgt' => $ticket['id'], 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $this->ticketStore->addTicket($ticket); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']); + $this->assertEquals( + "Ticket {$ticket['id']} has expired", + $xml->xpath('//cas:authenticationFailure')[0], + ); + } + + public function testProxyReturnsProxyTicket(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $ticket = [ + 'id' => 'PGT-' . $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'sessionId' => $this->sessionId, + 'forceAuthn' => false, + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + ]; + $sessionTicket = [ + 'id' => $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'sessionId' => $this->sessionId, + ]; + $params = ['pgt' => $ticket['id'], 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $this->ticketStore->addTicket($ticket); + $this->ticketStore->addTicket($sessionTicket); + $ticketFactory = new TicketFactory($casconfig); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertNotNull($xml->xpath('//cas:proxySuccess')); + $ticketId = (string)$xml->xpath('//cas:proxyTicket')[0]; + $proxyTicket = $this->ticketStore->getTicket($ticketId); + $this->assertTrue(filter_var($ticketFactory->isProxyTicket($proxyTicket), FILTER_VALIDATE_BOOLEAN)); + } + + public static function validateFailsForEmptyServiceTicket(): array + { + return [ + 'Service URL & TARGET is empty' => [ + ['ticket' => 'ST-12334Q45W4563'], + 'casserver: Missing service parameter: [service]', + ], + 'Ticket is empty but Service is not' => [ + ['service' => 'http://localhost'], + 'casserver: Missing ticket parameter: [ticket]', + ], + 'Ticket is empty but TARGET is not' => [ + ['TARGET' => 'http://localhost'], + 'casserver: Missing ticket parameter: [ticket]', + ], + ]; + } + + #[DataProvider('validateFailsForEmptyServiceTicket')] + public function testServiceValidateFailing(array $requestParams, string $message): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $request = Request::create( + uri: '/', + parameters: $requestParams, + ); + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->serviceValidate($request, ...$requestParams); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public function testReturn500OnDeleteTicketThatThrows(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $ticketStore = new class ($config) extends FileSystemTicketStore { + public function getTicket(string $ticketId): ?array + { + throw new \Exception(); + } + }; + + /** @psalm-suppress InvalidArgument */ + $cas20Controller = new Cas20Controller($this->sspConfig, $config, $ticketStore); + $response = $cas20Controller->serviceValidate($request, ...$params); + + $message = 'casserver:serviceValidate: internal server error'; + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INTERNAL_ERROR, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public static function validateOnDifferentQueryParameterCombinations(): array + { + $sessionId = session_create_id(); + return [ + 'Returns Bad Request on Ticket Not Recognised/Exists' => [ + [ + 'ticket' => $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket '{$sessionId}' not recognized", + 'ST-' . $sessionId, + ], + 'Returns Bad Request on Ticket is Proxy' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'PT-{$sessionId}' is not a service ticket.", + 'PT-' . $sessionId, + ], + 'Returns Bad Request on Ticket Expired' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'ST-{$sessionId}' has expired", + 'ST-' . $sessionId, + ], + 'Returns Bad Request on Ticket Not A Service Ticket' => [ + [ + 'ticket' => $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket '{$sessionId}' is not a service ticket", + $sessionId, + ], + 'Returns Bad Request on Ticket Issued By Single SignOn Session' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + 'renew' => true, + ], + 'Ticket was issued from single sign on session', + 'ST-' . $sessionId, + 9999999999, + ], + 'Returns Success' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + 'username@google.com', + 'ST-' . $sessionId, + 9999999999, + ], + ]; + } + + #[DataProvider('validateOnDifferentQueryParameterCombinations')] + public function testServiceValidate( + array $requestParams, + string $message, + string $ticketId, + int $validBefore = 1111111111, + ): void { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $requestParams, + ); + + $cas20Controller = new Cas20Controller($this->sspConfig, $config); + + if (!empty($ticketId)) { + $ticketStore = $cas20Controller->getTicketStore(); + $ticket = $this->ticket; + $ticket['id'] = $ticketId; + $ticket['validBefore'] = $validBefore; + $ticketStore->addTicket($ticket); + } + + $response = $cas20Controller->serviceValidate($request, ...$requestParams); + + if ($message === 'username@google.com') { + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + 'username@google.com', + $xml->xpath('//cas:authenticationSuccess/cas:user')[0][0], + ); + } else { + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + } + + + public static function validateOnDifferentQueryParameterCombinationsProxyValidate(): array + { + $sessionId = session_create_id(); + return [ + 'Returns Bad Request on Ticket Not Recognised/Exists' => [ + [ + 'ticket' => $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket '{$sessionId}' not recognized", + 'PT-' . $sessionId, + ], + 'Returns Bad Request on Ticket Expired' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'PT-{$sessionId}' has expired", + 'PT-' . $sessionId, + ], + 'Returns Bad Request on Ticket is A Service Ticket' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'ST-{$sessionId}' is not a proxy ticket.", + 'ST-' . $sessionId, + ], + 'Returns Bad Request on Ticket Issued By Single SignOn Session' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + 'renew' => true, + ], + 'Ticket was issued from single sign on session', + 'PT-' . $sessionId, + 9999999999, + ], + 'Returns Success' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + 'username@google.com', + 'PT-' . $sessionId, + 9999999999, + ], + ]; + } + + #[DataProvider('validateOnDifferentQueryParameterCombinationsProxyValidate')] + public function testProxyValidate( + array $requestParams, + string $message, + string $ticketId, + int $validBefore = 1111111111, + ): void { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $requestParams, + ); + + $cas20Controller = new Cas20Controller($this->sspConfig, $config); + + if (!empty($ticketId)) { + $ticketStore = $cas20Controller->getTicketStore(); + $ticket = $this->proxyTicket; + $ticket['id'] = $ticketId; + $ticket['validBefore'] = $validBefore; + $ticketStore->addTicket($ticket); + } + + $response = $cas20Controller->proxyValidate($request, ...$requestParams); + + if ($message === 'username@google.com') { + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + 'username@google.com', + $xml->xpath('//cas:authenticationSuccess/cas:user')[0][0], + ); + } else { + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + } + + public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/failservice', + ]; + $this->ticket['validBefore'] = 9999999999; + $this->ticket['attributes'] = []; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas20Controller = new Cas20Controller($this->sspConfig, $config); + $ticketStore = $cas20Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas20Controller->serviceValidate($request, ...$params); + + $message = "Mismatching service parameters: expected 'https://myservice.com/abcd'" . + " but was: 'https://myservice.com/failservice'"; + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public function testThrowOnProxyServiceIdentityFail(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + 'pgtUrl' => 'https://myservice.com/proxy', + ]; + $this->ticket['validBefore'] = 9999999999; + $sessionTicket = $this->ticket; + $sessionTicket['id'] = $this->sessionId; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $this->utilsHttpMock->expects($this->once()) + ->method('fetch') + ->willThrowException(new \Exception()); + + $cas20Controller = new Cas20Controller( + sspConfig: $this->sspConfig, + casConfig: $config, + httpUtils: $this->utilsHttpMock, + ); + $ticketStore = $cas20Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $ticketStore->addTicket($sessionTicket); + $response = $cas20Controller->serviceValidate($request, ...$params); + + $message = 'Proxy callback url is failing.'; + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } +} diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php new file mode 100644 index 00000000..d1518aaf --- /dev/null +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -0,0 +1,351 @@ +sspConfig = Configuration::getConfig('config.php'); + $this->sessionId = session_create_id(); + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + // Hard code the ticket store + $this->ticketStore = new FileSystemTicketStore(Configuration::loadFromArray($this->moduleConfig)); + + $this->ticketValidatorMock = $this->getMockBuilder(TicketValidator::class) + ->setConstructorArgs([Configuration::loadFromArray($this->moduleConfig)]) + ->onlyMethods(['validateAndDeleteTicket']) + ->getMock(); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->ticket = [ + 'id' => 'ST-' . $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + } + + /** + * @return void + * @throws \Exception + */ + public function testNoSoapBody(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: '', + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('samlValidate expects a soap body.'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public static function soapEnvelopes(): array + { + return [ + 'Body Missing RequestID Attribute' => [ + << + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + +SOAP, + "Missing 'RequestID' attribute on samlp:Request.", + 'SimpleSAML\XML\Exception\MissingAttributeException', + ], + 'Body Missing IssueInstant Attribute' => [ + << + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + +SOAP, + "Missing 'IssueInstant' attribute on samlp:Request.", + 'SimpleSAML\XML\Exception\MissingAttributeException', + ], + 'Body Missing Ticket Id' => [ + << + + + + + + + +SOAP, + 'Expected a non-whitespace string. Got: ""', + 'SimpleSAML\SAML11\Exception\ProtocolViolationException', + ], + ]; + } + + #[DataProvider('soapEnvelopes')] + public function testSoapMessageIsInvalid( + string $soapMessage, + string $exceptionMessage, + string $exceptionClassName, + ): void { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = $soapMessage; + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException($exceptionClassName); + $this->expectExceptionMessage($exceptionMessage); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + + /** + * @return void + * @throws \Exception + */ + public function testCasValidateAndDeleteTicketThrowsException(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + +SOAP; + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + /** @psalm-suppress UndefinedMethod */ + $this->ticketValidatorMock + ->expects($this->once()) + ->method('validateAndDeleteTicket') + ->willThrowException(new \RuntimeException('Cas validateAndDeleteTicket failed')); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + $this->ticketValidatorMock, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cas validateAndDeleteTicket failed'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + /** + * @return void + * @throws \Exception + */ + public function testUnableToLoadTicket(): void + { + $this->ticketStore->addTicket(['id' => $this->ticket['id']]); + // We add the ticket. We need to make + + $ticketId = $this->ticket['id']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + $ticketId + + + +SOAP; + + $target = 'https://myservice.com/abcd'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + /** @psalm-suppress UndefinedMethod */ + $this->ticketValidatorMock + ->expects($this->once()) + ->method('validateAndDeleteTicket') + ->willReturn('i am a string'); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + $this->ticketValidatorMock, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Error loading ticket'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + /** + * @return void + * @throws \Exception + */ + public function testSuccessfullValidation(): void + { + $this->ticketStore->addTicket($this->ticket); + $ticketId = $this->ticket['id']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + $ticketId + + + +SOAP; + + $target = 'https://myservice.com/abcd'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + $resp = $cas30Controller->samlValidate($this->samlValidateRequest, $target); + $this->assertEquals($resp->getStatusCode(), Response::HTTP_OK); + $this->assertStringContainsString( + 'eduPersonPrincipalName@google.com', + $resp->getContent(), + ); + } +} diff --git a/tests/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php new file mode 100644 index 00000000..96a36e78 --- /dev/null +++ b/tests/src/Controller/LoginControllerTest.php @@ -0,0 +1,323 @@ +authSimpleMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['getAuthData', 'isAuthenticated', 'login', 'getAuthDataArray']) + ->getMock(); + + $this->sspContainer = $this->getMockBuilder(SspContainer::class) + ->disableOriginalConstructor() + ->onlyMethods(['redirect']) + ->getMock(); + + $this->httpUtils = $this->getMockBuilder(Utils\HTTP::class) + ->disableOriginalConstructor() + ->onlyMethods(['redirectTrustedURL']) + ->getMock(); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + 'authsource' => 'sso', + 'legal_service_urls' => [ + 'https://example.com/ssp/module.php/cas/linkback.php', + ], + 'debugMode' => false, + ]; + + $this->sspConfig = Configuration::getConfig('config.php'); + } + + public static function setUpBeforeClass(): void + { + // Some of the constructs in this test cause a Configuration to be created prior to us + // setting the one we want to use for the test. + Configuration::clearInternalState(); + + // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work... + global $_SERVER; + $_SERVER['REQUEST_URI'] = '/'; + } + + public static function loginParameters(): array + { + return [ + 'Wrong Service Url' => [ + ['service' => 'http://not-legal'], + // phpcs:ignore Generic.Files.LineLength.TooLong + "Service parameter provided to CAS server is not listed as a legal service: [service] = 'http://not-legal'", + ], + 'Invalid Scope' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'scope' => 'illegalscope', + ], + "Scope parameter provided to CAS server is not listed as legal scope: [scope] = 'illegalscope'", + + ], + ]; + } + + /** + * Test incorrect service url + * @throws \Exception + */ + #[DataProvider('loginParameters')] + public function testLoginFails(array $params, string $message): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $params, + ); + + $loginController = new LoginController( + $this->sspConfig, + $casconfig, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + + $loginController->login($loginRequest, ...$params); + } + + public static function loginOnAuthenticateParameters(): array + { + return [ + 'No EntityId Query Parameter' => [ + ['service' => 'https://example.com/ssp/module.php/cas/linkback.php'], + [ + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php', + ], + [], + ], + 'With EntityId Set' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'entityId' => 'http://localhost/entityId/sso', + ], + [ + 'saml:idp' => 'http://localhost/entityId/sso', + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&entityId=http%3A%2F%2Flocalhost%2FentityId%2Fsso', + ], + [], + ], + 'With Valid Scope List Set - More than 1 items' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'scope' => 'desktop', + ], + [ + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&scope=desktop', + 'saml:IDPList' => [ + 'http://localhost/entityId/sso/scope/A', + 'http://localhost/entityId/sso/scope/B', + ], + ], + [ + 'desktop' => [ + 'http://localhost/entityId/sso/scope/A', + 'http://localhost/entityId/sso/scope/B', + ], + ], + + ], + 'With Valid Scope List Set - 1 item' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'scope' => 'desktop', + ], + [ + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&scope=desktop', + 'saml:idp' => 'http://localhost/entityId/sso/scope/A', + ], + [ + 'desktop' => ['http://localhost/entityId/sso/scope/A'], + ], + + ], + ]; + } + + #[DataProvider('loginOnAuthenticateParameters')] + public function testAuthSourceLogin(array $requestParameters, array $loginParameters, array $scopes): void + { + $moduleConfig = $this->moduleConfig; + $moduleConfig['scopes'] = $scopes; + $casconfig = Configuration::loadFromArray($moduleConfig); + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $requestParameters, + ); + + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + $sessionId = session_create_id(); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); + + $response = $controllerMock->login($loginRequest, ...$requestParameters); + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('login', $callable[1] ?? ''); + } + + /** + * Check authenticated with debugMode false + */ + public function testIsAuthenticatedRedirectsToLoggedIn(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $sessionId = session_create_id(); + $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId); + + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); + $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999); + + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: [], + ); + + $response = $controllerMock->login($loginRequest); + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); + } + + public static function validServiceUrlProvider(): array + { + return [ + "'ticket' Query Parameter" => [ + 'service', + 'https://example.com/ssp/module.php/cas/linkback.php?ticket=ST-', + false, + ], + "'SAMLart' Query Parameter" => [ + 'TARGET', + 'https://example.com/ssp/module.php/cas/linkback.php?SAMLart=ST-', + false, + ], + "'myTicket' Query Parameter for Ticket Name Override" => [ + 'TARGET', + 'https://example.com/ssp/module.php/cas/linkback.php?SAMLart=ST-', + true, + ], + ]; + } + + /** + * Test a valid service URL + * + * @param string $serviceParam The name of the query parameter to use for the service url + * @param string $redirectURL + * @param bool $hasTicketNameOverride + * + * @throws \Exception + */ + #[DataProvider('validServiceUrlProvider')] + public function testValidServiceUrl(string $serviceParam, string $redirectURL, bool $hasTicketNameOverride): void + { + $state['Attributes'] = [ + 'eduPersonPrincipalName' => ['testuser@example.com'], + 'additionalAttribute' => ['Taco Club'], + 'Expire' => 9999999999, + ]; + $moduleConfig = $this->moduleConfig; + if ($hasTicketNameOverride) { + $moduleConfig['legal_service_urls']['http://changeTicketParam'] = [ + 'ticketName' => 'myTicket', + ]; + } + $casconfig = Configuration::loadFromArray($moduleConfig); + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $sessionId = session_create_id(); + $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId); + $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999); + $this->authSimpleMock->expects($this->once())->method('getAuthDataArray')->willReturn($state); + + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->authSimpleMock->expects($this->any())->method('isAuthenticated')->willReturn(true); + $queryParameters = [$serviceParam => 'https://example.com/ssp/module.php/cas/linkback.php']; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $queryParameters, + ); + + /** @psalm-suppress InvalidArgument */ + $response = $controllerMock->login($loginRequest, ...$queryParameters); + $this->assertInstanceOf(RunnableResponse::class, $response); + $arguments = $response->getArguments(); + $this->assertEquals('https://example.com/ssp/module.php/cas/linkback.php', $arguments[0]); + $this->assertStringStartsWith('ST-', array_values($arguments[1])[0] ?? []); + $callable = (array)$response->getCallable(); + $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); + } +} diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php new file mode 100644 index 00000000..60d6490b --- /dev/null +++ b/tests/src/Controller/LogoutControllerTest.php @@ -0,0 +1,224 @@ +authSimpleMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['logout', 'isAuthenticated']) + ->getMock(); + + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + $this->httpUtils = new Utils\HTTP(); + $this->sspConfig = Configuration::getConfig('config.php'); + } + + public static function setUpBeforeClass(): void + { + // Some of the constructs in this test cause a Configuration to be created prior to us + // setting the one we want to use for the test. + Configuration::clearInternalState(); + + // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work... + global $_SERVER; + $_SERVER['REQUEST_URI'] = '/'; + } + + public function testLogoutNotAllowed(): void + { + $this->moduleConfig['enable_logout'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Logout not allowed'); + + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock); + $controller->logout(Request::create('/')); + } + + public function testLogoutNoRedirectUrlOnSkipLogout(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = true; + $config = Configuration::loadFromArray($this->moduleConfig); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Required URL query parameter [url] not provided. (CAS Server)'); + + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock); + $controller->logout(Request::create('/')); + } + + public function testLogoutWithRedirectUrlOnSkipLogout(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = true; + $config = Configuration::loadFromArray($this->moduleConfig); + $urlParam = 'https://example.com/test'; + + // Unauthenticated + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); + + $logoutUrl = Module::getModuleURL('casserver/logout.php'); + + $request = Request::create( + uri: $logoutUrl, + parameters: ['url' => $urlParam], + ); + $response = $controller->logout($request, $urlParam); + + $callable = $response->getCallable(); + $method = is_array($callable) ? $callable[1] : 'unknown'; + $arguments = $response->getArguments(); + $this->assertEquals('redirectTrustedURL', $method); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($urlParam, $arguments[0]); + } + + public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + // Unauthenticated + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); + $response = $controller->logout(Request::create('/')); + + $callable = $response->getCallable(); + $method = is_array($callable) ? $callable[1] : 'unknown'; + $arguments = $response->getArguments(); + $this->assertEquals('redirectTrustedURL', $method); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('http://localhost/module.php/casserver/loggedOut', $arguments[0]); + } + + public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + $urlParam = 'https://example.com/test'; + $logoutUrl = Module::getModuleURL('casserver/loggedOut'); + + // Unauthenticated + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); + $request = Request::create( + uri: $logoutUrl, + parameters: ['url' => $urlParam], + ); + $response = $controller->logout($request, $urlParam); + + $callable = $response->getCallable(); + $method = is_array($callable) ? $callable[1] : 'unknown'; + $arguments = $response->getArguments(); + $this->assertEquals('redirectTrustedURL', $method); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('http://localhost/module.php/casserver/loggedOut', $arguments[0]); + } + + public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + // Unauthenticated + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); + + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); + $queryParameters = ['url' => 'http://localhost/module.php/casserver/loggedOut']; + $logoutRequest = Request::create( + uri: Module::getModuleURL('casserver/loggedOut'), + parameters: $queryParameters, + ); + + $response = $controller->logout($logoutRequest, ...$queryParameters); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('logout', $callable[1] ?? ''); + } + + public function testTicketIdGetsDeletedOnLogout(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + $controllerMock = $this->getMockBuilder(LogoutController::class) + ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $ticketStore = $controllerMock->getTicketStore(); + $sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $sessionId = session_create_id(); + $sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); + + $ticket = [ + 'id' => $sessionId, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'service' => 'https://localhost/ssp/module.php/cas/linkback.php?stateId=_332b2b157041f4fc70dd290339a05a4e915674c1f2%3Ahttps%3A%2F%2Flocalhost%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver', + 'forceAuthn' => false, + 'userName' => 'test1@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'test@google.com', + ], + ], + 'proxies' => + [ + ], + ]; + + $ticketStore->addTicket($ticket); + $controllerMock->expects($this->once())->method('getSession')->willReturn($sessionMock); + + $controllerMock->logout(Request::create('/')); + // The Ticket has been successfully deleted + $this->assertEquals(null, $ticketStore->getTicket($ticket['id'])); + } +} diff --git a/tests/public/UtilsTest.php b/tests/src/Controller/Traits/UrlTraitTest.php similarity index 58% rename from tests/public/UtilsTest.php rename to tests/src/Controller/Traits/UrlTraitTest.php index 138b94a3..25b5dbb1 100644 --- a/tests/public/UtilsTest.php +++ b/tests/src/Controller/Traits/UrlTraitTest.php @@ -1,51 +1,17 @@ assertEquals($allowed, checkServiceURL(sanitize($service), $legalServices), "$service validated wrong"); - } - + use UrlTrait; /** * @return array @@ -87,4 +53,78 @@ public static function checkServiceURLProvider(): array ['', false], ]; } + + /** + * @param string $service the service url to check + * @param bool $allowed is the service url allowed? + */ + #[DataProvider('checkServiceURLProvider')] + public function testCheckServiceURL(string $service, bool $allowed): void + { + $legalServices = [ + // Regular prefix match + 'https://myservice.com', + 'https://anotherservice.com/', + 'https://anotherservice.com:8080/', + 'http://sub.domain.com/path/a/b/c', + 'https://query.param/secure?apple=red', + 'https://encode.com/space test/', + + // Regex match + '|^https://.*\.subdomain.com/|', + '#^https://.*-someprefix.com/#', + + // Invalid settings don't blow up + '|invalid-regex', + '', + ]; + + $this->assertEquals( + $allowed, + $this->checkServiceURL($this->sanitize($service), $legalServices), + "$service validated wrong", + ); + } + + public static function requestParameters(): array + { + return [ + [ + ['renew' => true, 'language' => 'Greek', 'debugMode' => true], + ['renew' => true, 'language' => 'Greek', 'debugMode' => true], + [], + ], + [ + ['renew' => true, 'language' => 'Greek', 'debugMode' => true], + ['renew' => true, 'language' => 'Greek', 'debugMode' => true, 'renewId' => '1234'], + [ + 'renewId' => '1234', + ], + ], + [ + ['renew' => true, 'language' => 'Greek', 'debugMode' => true, 'TARGET' => 'http://localhost'], + [ + 'renew' => true, + 'language' => 'Greek', + 'debugMode' => true, + 'renewId' => '1234', + 'TARGET' => 'http://localhost', + ], + [ + 'renewId' => '1234', + ], + ], + ]; + } + + #[DataProvider('requestParameters')] + public function testParseQueryParameters(array $requestParams, array $query, array $sessionTicket): void + { + $request = Request::create( + uri: '/', + parameters: $requestParams, + ); + + $this->assertEquals($query, $this->parseQueryParameters($request, $sessionTicket)); + } }