From 31c23a2b81ae0ec4fe16a7b639ed903b1b89e07a Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 19 Nov 2024 20:07:23 +0200 Subject: [PATCH 01/48] Move validate.php to symfony controller --- composer.json | 5 +- public/validate.php | 123 ---------------------- routing/routes/routes.php | 25 +++++ routing/services/services.yml | 15 +++ src/Codebooks/LegacyRoutesEnum.php | 19 ++++ src/Codebooks/RoutesEnum.php | 19 ++++ src/Controller/Cas10Controller.php | 159 +++++++++++++++++++++++++++++ src/Controller/Traits/UrlTrait.php | 105 +++++++++++++++++++ 8 files changed, 345 insertions(+), 125 deletions(-) delete mode 100644 public/validate.php create mode 100644 routing/routes/routes.php create mode 100644 routing/services/services.yml create mode 100644 src/Codebooks/LegacyRoutesEnum.php create mode 100644 src/Codebooks/RoutesEnum.php create mode 100644 src/Controller/Cas10Controller.php create mode 100644 src/Controller/Traits/UrlTrait.php diff --git a/composer.json b/composer.json index 6d347e2..a2a72ec 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "config": { "preferred-install": { "simplesamlphp/simplesamlphp": "source", - "*": "dist" + "*": "source" }, "allow-plugins": { "composer/package-versions-deprecated": true, @@ -40,7 +40,8 @@ "simplesamlphp/simplesamlphp": "^2.2", "simplesamlphp/xml-cas": "^v1.3", "simplesamlphp/xml-common": "^v1.17", - "simplesamlphp/xml-soap": "^v1.5" + "simplesamlphp/xml-soap": "^v1.5", + "symfony/cache": "^6.0|^5.0|^4.3|^3.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/public/validate.php b/public/validate.php deleted file mode 100644 index cb07325..0000000 --- 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 0000000..faf4bd3 --- /dev/null +++ b/routing/routes/routes.php @@ -0,0 +1,25 @@ +add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) + ->controller([Cas10Controller::class, 'validate']); + + // Legacy Routes + $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) + ->controller([Cas10Controller::class, 'validate']); +}; diff --git a/routing/services/services.yml b/routing/services/services.yml new file mode 100644 index 0000000..e14851f --- /dev/null +++ b/routing/services/services.yml @@ -0,0 +1,15 @@ +--- + +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 + autowire: true \ No newline at end of file diff --git a/src/Codebooks/LegacyRoutesEnum.php b/src/Codebooks/LegacyRoutesEnum.php new file mode 100644 index 0000000..36f6bf3 --- /dev/null +++ b/src/Codebooks/LegacyRoutesEnum.php @@ -0,0 +1,19 @@ +casConfig = Configuration::getConfig('module_casserver.php'); + $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 ='SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + /** @psalm-suppress InvalidStringClass */ + $this->ticketStore = new $ticketStoreClass($this->casConfig); + } + + /** + * @param Request $request + * + * @return Response + */ + public function validate(Request $request): Response + { + // Check if any of the required query parameters are missing + if(!$request->query->has('service')) { + Logger::debug('casserver: Missing service parameter: [service]'); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST + ); + } else if(!$request->query->has('ticket')) { + Logger::debug('casserver: Missing service parameter: [ticket]'); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST + ); + } + + // Check if we are required to force an authentication + $forceAuthn = $request->query->has('renew') && $request->query->get('renew'); + // Get the ticket + $ticket = $request->query->get('ticket'); + // Get the service + $service = $request->query->get('service'); + + try { + // Get the service ticket + $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 = ''; + // No ticket + if ($serviceTicket === null) { + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + // This is not a service ticket + } else if (!$this->ticketFactory->isServiceTicket($serviceTicket)){ + $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; + $failed = true; + // the ticket has expired + } else if ($this->ticketFactory->isExpired($serviceTicket)) { + $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; + $failed = true; + } else if ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($service, true); + $failed = true; + } else if ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { + $message = 'Ticket was issued from single sign on session'; + $failed = true; + } + + if ($failed) { + Logger::error('casserver:validate: ' . $message, true); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST + ); + } + + // Get the username field + $usernameField = $this->casConfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); + + // Fail if the username field is not present in the attribute list + if (!\array_key_exists($usernameField, $serviceTicket['attributes'])) { + Logger::error( + 'casserver:validate: internal server error. Missing user name attribute: ' + . var_export($usernameField, true), + ); + + } + + // Successful validation + return new Response( + $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]), + Response::HTTP_OK + ); + } +} \ No newline at end of file diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php new file mode 100644 index 0000000..035af12 --- /dev/null +++ b/src/Controller/Traits/UrlTrait.php @@ -0,0 +1,105 @@ + $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 array|null $sessionTicket + * @param Request $request + * + * @return array + */ + public function parseQueryParameters(?array $sessionTicket, Request $request): 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; + } + + /** + * @param Request $request + * + * @return array + */ + public function getRequestParams(Request $request): array + { + $params = []; + if ($request->isMethod('GET')) { + $params = $request->query->all(); + } elseif ($request->isMethod('POST')) { + $params = $request->request->all(); + } + + return $params; + } +} \ No newline at end of file From c497868ba6d643ab6c23a8f320e114e578c98924 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 19 Nov 2024 20:19:50 +0200 Subject: [PATCH 02/48] Fix quality errors --- src/Codebooks/RoutesEnum.php | 2 +- src/Controller/Cas10Controller.php | 33 +++--- src/Controller/Traits/UrlTrait.php | 162 ++++++++++++++--------------- 3 files changed, 97 insertions(+), 100 deletions(-) diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 7e96093..954aa38 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -16,4 +16,4 @@ enum RoutesEnum: string case SamlValidate = 'samlValidate'; case ServiceValidate = 'serviceValidate'; case Validate = 'validate'; -} \ No newline at end of file +} diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index fb17455..6b77586 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -4,8 +4,6 @@ namespace SimpleSAML\Module\casserver\Controller; -use Exception; -use Module\casserver\Cas\Factories; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; @@ -47,8 +45,8 @@ class Cas10Controller * * @throws Exception */ - public function __construct( - ) { + public function __construct() + { $this->casConfig = Configuration::getConfig('module_casserver.php'); $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ @@ -58,7 +56,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass ='SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; /** @psalm-suppress InvalidStringClass */ $this->ticketStore = new $ticketStoreClass($this->casConfig); @@ -72,17 +70,17 @@ public function __construct( public function validate(Request $request): Response { // Check if any of the required query parameters are missing - if(!$request->query->has('service')) { + if (!$request->query->has('service')) { Logger::debug('casserver: Missing service parameter: [service]'); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST + Response::HTTP_BAD_REQUEST, ); - } else if(!$request->query->has('ticket')) { + } elseif (!$request->query->has('ticket')) { Logger::debug('casserver: Missing service parameter: [ticket]'); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST + Response::HTTP_BAD_REQUEST, ); } @@ -102,7 +100,7 @@ public function validate(Request $request): Response Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true)); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_INTERNAL_SERVER_ERROR + Response::HTTP_INTERNAL_SERVER_ERROR, ); } @@ -113,19 +111,19 @@ public function validate(Request $request): Response $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; $failed = true; // This is not a service ticket - } else if (!$this->ticketFactory->isServiceTicket($serviceTicket)){ + } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; $failed = true; // the ticket has expired - } else if ($this->ticketFactory->isExpired($serviceTicket)) { + } elseif ($this->ticketFactory->isExpired($serviceTicket)) { $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; $failed = true; - } else if ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { + } elseif ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { $message = 'Mismatching service parameters: expected ' . var_export($serviceTicket['service'], true) . ' but was: ' . var_export($service, true); $failed = true; - } else if ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { + } elseif ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { $message = 'Ticket was issued from single sign on session'; $failed = true; } @@ -134,7 +132,7 @@ public function validate(Request $request): Response Logger::error('casserver:validate: ' . $message, true); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST + Response::HTTP_BAD_REQUEST, ); } @@ -147,13 +145,12 @@ public function validate(Request $request): Response 'casserver:validate: internal server error. Missing user name attribute: ' . var_export($usernameField, true), ); - } // Successful validation return new Response( $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]), - Response::HTTP_OK + Response::HTTP_OK, ); } -} \ No newline at end of file +} diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 035af12..88a7476 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -10,96 +10,96 @@ trait UrlTrait { - /** - * @deprecated - * @see ServiceValidator - * @param string $service - * @param array $legal_service_urls - * @return bool - */ - public function checkServiceURL(string $service, array $legal_service_urls): bool - { - //delegate to ServiceValidator until all references to this can be cleaned up - $config = Configuration::loadFromArray(['legal_service_urls' => $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 array|null $sessionTicket - * @param Request $request - * - * @return array - */ - public function parseQueryParameters(?array $sessionTicket, Request $request): array - { - $forceAuthn = isset($_GET['renew']) && $_GET['renew']; - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - - $query = []; - - if ($sessionRenewId && $forceAuthn) { - $query['renewId'] = $sessionRenewId; + /** + * @deprecated + * @see ServiceValidator + * @param string $service + * @param array $legal_service_urls + * @return bool + */ + public function checkServiceURL(string $service, array $legal_service_urls): bool + { + //delegate to ServiceValidator until all references to this can be cleaned up + $config = Configuration::loadFromArray(['legal_service_urls' => $legal_service_urls]); + $serviceValidator = new ServiceValidator($config); + return $serviceValidator->checkServiceURL($service) !== null; } - if (isset($_REQUEST['service'])) { - $query['service'] = $_REQUEST['service']; - } - if (isset($_REQUEST['TARGET'])) { - $query['TARGET'] = $_REQUEST['TARGET']; + /** + * @param string $parameter + * @return string + */ + public function sanitize(string $parameter): string + { + return TicketValidator::sanitize($parameter); } - if (isset($_REQUEST['method'])) { - $query['method'] = $_REQUEST['method']; - } - if (isset($_REQUEST['renew'])) { - $query['renew'] = $_REQUEST['renew']; - } + /** + * Parse the query Parameters from $_GET global and return them in an array. + * + * @param array|null $sessionTicket + * @param Request $request + * + * @return array + */ + public function parseQueryParameters(?array $sessionTicket, Request $request): array + { + $forceAuthn = isset($_GET['renew']) && $_GET['renew']; + $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - if (isset($_REQUEST['gateway'])) { - $query['gateway'] = $_REQUEST['gateway']; - } + $query = []; - if (\array_key_exists('language', $_GET)) { - $query['language'] = \is_string($_GET['language']) ? $_GET['language'] : null; - } + if ($sessionRenewId && $forceAuthn) { + $query['renewId'] = $sessionRenewId; + } - if (isset($_REQUEST['debugMode'])) { - $query['debugMode'] = $_REQUEST['debugMode']; - } + if (isset($_REQUEST['service'])) { + $query['service'] = $_REQUEST['service']; + } + + if (isset($_REQUEST['TARGET'])) { + $query['TARGET'] = $_REQUEST['TARGET']; + } - return $query; - } - - /** - * @param Request $request - * - * @return array - */ - public function getRequestParams(Request $request): array - { - $params = []; - if ($request->isMethod('GET')) { - $params = $request->query->all(); - } elseif ($request->isMethod('POST')) { - $params = $request->request->all(); + 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; } - return $params; - } -} \ No newline at end of file + /** + * @param Request $request + * + * @return array + */ + public function getRequestParams(Request $request): array + { + $params = []; + if ($request->isMethod('GET')) { + $params = $request->query->all(); + } elseif ($request->isMethod('POST')) { + $params = $request->request->all(); + } + + return $params; + } +} From cc0dbaf04192e2230a5bbbbea33ef52a9d844fbd Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 19 Nov 2024 22:01:01 +0200 Subject: [PATCH 03/48] Loggout Controller --- public/logout.php | 91 --------------------- routing/routes/routes.php | 5 ++ src/Controller/Cas10Controller.php | 1 - src/Controller/LogoutController.php | 120 ++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 92 deletions(-) delete mode 100644 public/logout.php create mode 100644 src/Controller/LogoutController.php diff --git a/public/logout.php b/public/logout.php deleted file mode 100644 index 518c04e..0000000 --- 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/routing/routes/routes.php b/routing/routes/routes.php index faf4bd3..2a656d7 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; /** @psalm-suppress InvalidArgument */ @@ -18,8 +19,12 @@ // New Routes $routes->add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) ->controller([Cas10Controller::class, 'validate']); + $routes->add(RoutesEnum::Validate->name, RoutesEnum::Logout->value) + ->controller([LogoutController::class, 'logout']); // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) ->controller([Cas10Controller::class, 'validate']); + $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyLogout->value) + ->controller([LogoutController::class, 'logout']); }; diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 6b77586..397047a 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -43,7 +43,6 @@ class Cas10Controller * * It initializes the global configuration for the controllers implemented here. * - * @throws Exception */ public function __construct() { diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php new file mode 100644 index 0000000..1e06a88 --- /dev/null +++ b/src/Controller/LogoutController.php @@ -0,0 +1,120 @@ +casConfig = Configuration::getConfig('module_casserver.php'); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + $this->ticketStore = new $ticketStoreClass($this->casConfig); + $this->authSource = new Simple($this->casConfig->getValue('authsource')); + } + + /** + * + * @param string|null $url + * + * @return RedirectResponse|null + */ + public function logout( + #[MapQueryParameter] ?string $url = null, + ): RedirectResponse|null { + 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 + $logoutRedirectUrl = ($skipLogoutPage || $url === null) ? $url + : $url . '?' . http_build_query(['url' => $url]); + + // Delete the ticket from the session + $session = $this->getSession(); + if ($session !== null) { + $this->ticketStore->deleteTicket($session->getSessionId()); + } + + // Redirect + if (!$this->authSource->isAuthenticated()) { + $this->redirect($logoutRedirectUrl); + } + + // Logout and redirect + $this->authSource->logout($logoutRedirectUrl); + + // We should never get here + return null; + } + + /** + * @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(); + } +} From 8a3aff9fd7b9d2ef18796c6770aa3f0890110ad0 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 17:26:01 +0200 Subject: [PATCH 04/48] LoggedOut Controller --- composer.json | 3 +- public/loggedOut.php | 36 -------------- routing/routes/routes.php | 11 +++-- routing/services/services.yml | 12 ++++- src/Controller/Cas10Controller.php | 9 +--- src/Controller/LoggedOutController.php | 48 +++++++++++++++++++ src/Controller/LogoutController.php | 15 +++++- tests/src/Controller/LogoutControllerTest.php | 38 +++++++++++++++ 8 files changed, 121 insertions(+), 51 deletions(-) delete mode 100644 public/loggedOut.php create mode 100644 src/Controller/LoggedOutController.php create mode 100644 tests/src/Controller/LogoutControllerTest.php diff --git a/composer.json b/composer.json index a2a72ec..a1be12b 100644 --- a/composer.json +++ b/composer.json @@ -40,8 +40,7 @@ "simplesamlphp/simplesamlphp": "^2.2", "simplesamlphp/xml-cas": "^v1.3", "simplesamlphp/xml-common": "^v1.17", - "simplesamlphp/xml-soap": "^v1.5", - "symfony/cache": "^6.0|^5.0|^4.3|^3.4" + "simplesamlphp/xml-soap": "^v1.5" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/public/loggedOut.php b/public/loggedOut.php deleted file mode 100644 index 20330c6..0000000 --- a/public/loggedOut.php +++ /dev/null @@ -1,36 +0,0 @@ -data['url'] = $_GET['url']; -} - -$t->send(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 2a656d7..3034db7 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -6,9 +6,10 @@ declare(strict_types=1); -use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; +use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -19,12 +20,16 @@ // New Routes $routes->add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) ->controller([Cas10Controller::class, 'validate']); - $routes->add(RoutesEnum::Validate->name, RoutesEnum::Logout->value) + $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value) ->controller([LogoutController::class, 'logout']); + $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) + ->controller([LoggedOutController::class, 'main']); // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) ->controller([Cas10Controller::class, 'validate']); - $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyLogout->value) + $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value) ->controller([LogoutController::class, 'logout']); + $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) + ->controller([LoggedOutController::class, 'main']); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index e14851f..ef66c66 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -12,4 +12,14 @@ services: exclude: - '../../src/Controller/Traits/*' public: true - autowire: true \ No newline at end of file + tags: ['controller.service_arguments'] + + # Explicit service definitions for CasServer Controllers + SimpleSAML\Module\casserver\Controller\Cas10Controller: + public: true + + SimpleSAML\Module\casserver\Controller\LogoutController: + public: true + + SimpleSAML\Module\casserver\Controller\LoggedOutController: + public: true \ No newline at end of file diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 397047a..4037b1a 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -11,14 +11,9 @@ use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\AsController; -/** - * Controller class for the casserver module. - * - * This class serves the different views available in the module. - * - * @package SimpleSAML\Module\casserver - */ +#[AsController] class Cas10Controller { use UrlTrait; diff --git a/src/Controller/LoggedOutController.php b/src/Controller/LoggedOutController.php new file mode 100644 index 0000000..b645a3f --- /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/LogoutController.php b/src/Controller/LogoutController.php index 1e06a88..08fb85a 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -7,12 +7,16 @@ use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +#[AsController] class LogoutController { use UrlTrait; @@ -57,11 +61,13 @@ public function __construct() /** * + * @param Request $request * @param string|null $url * * @return RedirectResponse|null */ public function logout( + Request $request, #[MapQueryParameter] ?string $url = null, ): RedirectResponse|null { if (!$this->casConfig->getOptionalValue('enable_logout', false)) { @@ -76,8 +82,13 @@ public function logout( } // Construct the logout redirect url - $logoutRedirectUrl = ($skipLogoutPage || $url === null) ? $url - : $url . '?' . http_build_query(['url' => $url]); + if ($skipLogoutPage) { + $logoutRedirectUrl = $url; + } else { + $loggedOutUrl = Module::getModuleURL('casserver/loggedOut.php'); + $logoutRedirectUrl = $url === null ? $loggedOutUrl + : $loggedOutUrl . '?' . http_build_query(['url' => $url]); + } // Delete the ticket from the session $session = $this->getSession(); diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php new file mode 100644 index 0000000..6526362 --- /dev/null +++ b/tests/src/Controller/LogoutControllerTest.php @@ -0,0 +1,38 @@ +controller = new LogoutController(); + } + + public static function requestParameters(): array + { + return [ + 'no redirect url' => [''], + 'with redirect url' => ['http://example.com/redirect'], + ]; + } + + #[DataProvider('requestParameters')] + public function testLogout(string $redirectUrl): void + { + $request = Request::create( + uri: 'https://localhost/casserver/logout', + parameters: ['url' => $redirectUrl], + ); + } +} \ No newline at end of file From 5b9393209d4d87d1bc8ca5148f31349c8af72022 Mon Sep 17 00:00:00 2001 From: Tim van Dijen Date: Wed, 20 Nov 2024 16:49:12 +0100 Subject: [PATCH 05/48] Fix quality issues --- composer.json | 9 ++++++--- src/Controller/Traits/UrlTrait.php | 1 + tests/src/Controller/LogoutControllerTest.php | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index a1be12b..4f193c1 100644 --- a/composer.json +++ b/composer.json @@ -35,12 +35,15 @@ "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", + "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/xml-cas": "^1.3", + "simplesamlphp/xml-common": "^1.17", + "simplesamlphp/xml-soap": "^1.5", + "symfony/http-foundation": "^6.4", + "symfony/http-kernel": "^6.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 88a7476..858edac 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; +use SimpleSAML\Configuration; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\TicketValidator; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 6526362..0292c31 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -35,4 +35,4 @@ public function testLogout(string $redirectUrl): void parameters: ['url' => $redirectUrl], ); } -} \ No newline at end of file +} From 938d88698c3c84364ba70b4505e1a1e947aac04c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 18:30:01 +0200 Subject: [PATCH 06/48] composer require checker for dev environments --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4f193c1..6defbaa 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,8 @@ "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.14" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", @@ -61,7 +62,8 @@ "scripts": { "validate": [ "vendor/bin/phpunit --no-coverage --testdox", - "vendor/bin/phpcs -p" + "vendor/bin/phpcs -p", + "vendor/bin/composer-require-checker check composer.json" ], "tests": [ "vendor/bin/phpunit --no-coverage" From acc2e0475c85b4d024c1148910ab802ccf2dcfe3 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 19:10:27 +0200 Subject: [PATCH 07/48] fix composer require checker version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6defbaa..88e37a4 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", "squizlabs/php_codesniffer": "^3.7", - "maglnet/composer-require-checker": "^4.14" + "maglnet/composer-require-checker": "4.7.1" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", From 7287057e370753277b9996c6efcce086b7c64c25 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 20:41:43 +0200 Subject: [PATCH 08/48] Add tests for LogoutController.php part1 --- composer.json | 4 +- src/Controller/LogoutController.php | 34 +++++--- tests/src/Controller/LogoutControllerTest.php | 85 ++++++++++++++++--- 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index 88e37a4..a3fc946 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", + "ext-pdo": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", @@ -56,9 +57,6 @@ "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", diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 08fb85a..2e07d7a 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\casserver\Controller; use SimpleSAML\Auth\Simple; +use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module; @@ -33,19 +34,28 @@ class LogoutController /** @var Simple */ protected Simple $authSource; + /** @var SspContainer */ + protected SspContainer $container; + // this could be any configured ticket store /** @var mixed */ protected mixed $ticketStore; + /** - * Controller constructor. - * - * It initializes the global configuration for the controllers implemented here. + * @param Configuration|null $casConfig + * @param Simple|null $source + * @param SspContainer|null $container * + * @throws \Exception */ - public function __construct() - { - $this->casConfig = Configuration::getConfig('module_casserver.php'); + public function __construct( + // Facilitate testing + Configuration $casConfig = null, + Simple $source = null, + SspContainer $container = null, + ) { + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); /* Instantiate ticket store */ @@ -56,7 +66,8 @@ public function __construct() $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; $this->ticketStore = new $ticketStoreClass($this->casConfig); - $this->authSource = new Simple($this->casConfig->getValue('authsource')); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->container = $container ?? new SspContainer(); } /** @@ -84,10 +95,11 @@ public function logout( // Construct the logout redirect url if ($skipLogoutPage) { $logoutRedirectUrl = $url; + $params = []; } else { - $loggedOutUrl = Module::getModuleURL('casserver/loggedOut.php'); - $logoutRedirectUrl = $url === null ? $loggedOutUrl - : $loggedOutUrl . '?' . http_build_query(['url' => $url]); + $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut.php'); + $params = $url === null ? [] + : ['url' => $url]; } // Delete the ticket from the session @@ -98,7 +110,7 @@ public function logout( // Redirect if (!$this->authSource->isAuthenticated()) { - $this->redirect($logoutRedirectUrl); + $this->container->redirect($logoutRedirectUrl, $params); } // Logout and redirect diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 0292c31..e73897f 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -4,35 +4,92 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use SimpleSAML\Auth\Simple; +use SimpleSAML\Compat\SspContainer; +use SimpleSAML\Configuration; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\HttpFoundation\Request; class LogoutControllerTest extends TestCase { - /** @var LogoutController */ - private $controller; + private array $moduleConfig; + + private Simple $authSimpleMock; + + private SspContainer $sspContainer; protected function setUp(): void { - $this->controller = new LogoutController(); + $this->authSimpleMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['logout', 'isAuthenticated']) + ->getMock(); + + $this->sspContainer = $this->getMockBuilder(SspContainer::class) + ->disableOriginalConstructor() + ->onlyMethods(['redirect']) + ->getMock(); + + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + } + + 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 requestParameters(): array + public function testLogoutNotAllowed(): void { - return [ - 'no redirect url' => [''], - 'with redirect url' => ['http://example.com/redirect'], - ]; + $this->moduleConfig['enable_logout'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Logout not allowed'); + + $controller = new LogoutController($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($config, $this->authSimpleMock); + $controller->logout(Request::create('/')); } - #[DataProvider('requestParameters')] - public function testLogout(string $redirectUrl): void + public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void { - $request = Request::create( - uri: 'https://localhost/casserver/logout', - parameters: ['url' => $redirectUrl], + $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); + + $this->sspContainer->expects($this->once())->method('redirect')->with( + $this->equalTo('http://localhost/module.php/casserver/loggedOut.php'), + [], ); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller->logout(Request::create('/')); } } From 85bc4766d2f283a225a54a1993442c8a7ba76b10 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 20:58:57 +0200 Subject: [PATCH 09/48] psalm --- composer.json | 7 +++++-- psalm-dev.xml | 4 ++++ psalm.xml | 6 ++++++ tests/src/Controller/LogoutControllerTest.php | 3 ++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index a3fc946..9f98896 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,8 @@ "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", "squizlabs/php_codesniffer": "^3.7", - "maglnet/composer-require-checker": "4.7.1" + "maglnet/composer-require-checker": "4.7.1", + "vimeo/psalm": "^5" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", @@ -61,7 +62,9 @@ "validate": [ "vendor/bin/phpunit --no-coverage --testdox", "vendor/bin/phpcs -p", - "vendor/bin/composer-require-checker check composer.json" + "vendor/bin/composer-require-checker check composer.json", + "vendor/bin/psalm -c psalm-dev.xml", + "vendor/bin/psalm -c psalm.xml" ], "tests": [ "vendor/bin/phpunit --no-coverage" diff --git a/psalm-dev.xml b/psalm-dev.xml index fc2fbd3..071de9a 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 a7a64d4..72284dc 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,12 @@ useDocblockTypes="true" errorLevel="2" reportMixedIssues="false" + resolveFromConfigFile="true" + autoloader="vendor/autoload.php" + findUnusedCode="false" + findUnusedBaselineEntry="true" + hideExternalErrors="true" + allowStringToStandInForClass="true" > diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index e73897f..1538c71 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -82,8 +82,9 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $config = Configuration::loadFromArray($this->moduleConfig); // Unauthenticated + /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - + /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo('http://localhost/module.php/casserver/loggedOut.php'), [], From 13db7a7491b989a9c739f6c36237608902717fa9 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 13:38:09 +0200 Subject: [PATCH 10/48] LogoutController.php tests part 2 --- src/Controller/LogoutController.php | 13 +- tests/src/Controller/LogoutControllerTest.php | 117 ++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 2e07d7a..6623a28 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -56,6 +56,9 @@ public function __construct( SspContainer $container = null, ) { $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->container = $container ?? new SspContainer(); + /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); /* Instantiate ticket store */ @@ -66,8 +69,6 @@ public function __construct( $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; $this->ticketStore = new $ticketStoreClass($this->casConfig); - $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); - $this->container = $container ?? new SspContainer(); } /** @@ -120,6 +121,14 @@ public function logout( return null; } + /** + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } + /** * @param string $message * diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 1538c71..b36263e 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -8,7 +8,9 @@ use SimpleSAML\Auth\Simple; use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LogoutController; +use SimpleSAML\Session; use Symfony\Component\HttpFoundation\Request; class LogoutControllerTest extends TestCase @@ -75,6 +77,33 @@ public function testLogoutNoRedirectUrlOnSkipLogout(): void $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 + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + /** @psalm-suppress UndefinedMethod */ + $this->sspContainer->expects($this->once())->method('redirect')->with( + $this->equalTo($urlParam), + [], + ); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + + $logoutUrl = Module::getModuleURL('casserver/logout.php'); + + $request = Request::create( + uri: $logoutUrl, + parameters: ['url' => $urlParam], + ); + $controller->logout($request, $urlParam); + } + public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void { $this->moduleConfig['enable_logout'] = true; @@ -93,4 +122,92 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); } + + 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.php'); + + // Unauthenticated + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + /** @psalm-suppress UndefinedMethod */ + $this->sspContainer->expects($this->once())->method('redirect')->with( + $this->equalTo($logoutUrl), + ['url' => $urlParam], + ); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $request = Request::create( + uri: $logoutUrl, + parameters: ['url' => $urlParam], + ); + $controller->logout($request, $urlParam); + } + + public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + // Unauthenticated + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('logout') + ->with('http://localhost/module.php/casserver/loggedOut.php'); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller->logout(Request::create('/')); + } + + 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([$config, $this->authSimpleMock, $this->sspContainer]) + ->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'])); + } } From 1c8b8a8c9ab709aa7236c3753616f22e2175585c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 16:51:41 +0200 Subject: [PATCH 11/48] add CAS10 validate.php tests --- src/Controller/Cas10Controller.php | 69 +++-- tests/src/Controller/Cas10ControllerTest.php | 293 +++++++++++++++++++ 2 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 tests/src/Controller/Cas10ControllerTest.php diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 4037b1a..caa6fca 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -34,14 +34,16 @@ class Cas10Controller protected mixed $ticketStore; /** - * Controller constructor. - * - * It initializes the global configuration for the controllers implemented here. + * @param Configuration|null $casConfig + * @param $ticketStore * + * @throws \Exception */ - public function __construct() - { - $this->casConfig = Configuration::getConfig('module_casserver.php'); + public function __construct( + Configuration $casConfig = null, + $ticketStore = null, + ) { + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); @@ -53,40 +55,39 @@ public function __construct() $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; /** @psalm-suppress InvalidStringClass */ - $this->ticketStore = new $ticketStoreClass($this->casConfig); + $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); } /** - * @param Request $request + * @param Request $request + * @param bool $renew + * @param string|null $ticket + * @param string|null $service * * @return Response */ - public function validate(Request $request): Response - { + public function validate( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + ): Response { + + $forceAuthn = $renew; // Check if any of the required query parameters are missing - if (!$request->query->has('service')) { - Logger::debug('casserver: Missing service parameter: [service]'); - return new Response( - $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST, - ); - } elseif (!$request->query->has('ticket')) { - Logger::debug('casserver: Missing service parameter: [ticket]'); + 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, ); } - // Check if we are required to force an authentication - $forceAuthn = $request->query->has('renew') && $request->query->get('renew'); - // Get the ticket - $ticket = $request->query->get('ticket'); - // Get the service - $service = $request->query->get('service'); - 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); @@ -112,12 +113,14 @@ public function validate(Request $request): Response } elseif ($this->ticketFactory->isExpired($serviceTicket)) { $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; $failed = true; - } elseif ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($service)) { + // The service we pass 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 && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { + } elseif ($forceAuthn) { + // If the forceAuthn/renew is true $message = 'Ticket was issued from single sign on session'; $failed = true; } @@ -139,6 +142,10 @@ public function validate(Request $request): Response 'casserver:validate: internal server error. Missing user name attribute: ' . var_export($usernameField, true), ); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST, + ); } // Successful validation @@ -147,4 +154,12 @@ public function validate(Request $request): Response Response::HTTP_OK, ); } + + /** + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } } diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php new file mode 100644 index 0000000..ca51723 --- /dev/null +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -0,0 +1,293 @@ +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' => [ + [], + ], + ]; + } + + #[DataProvider('queryParameterValues')] + public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + 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(); + } + }; + + $cas10Controller = new Cas10Controller($config, $ticketStore); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + 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($config); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + 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($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()); + } + + 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($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()); + } + + 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['attributes'] = []; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($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()); + } + + 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($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()); + } + + 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($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()); + } + + 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($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("yes\neduPersonPrincipalName@google.com\n", $response->getContent()); + } +} From 4e00a203d6e70ad55da4908867e1b6ae31c509ce Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 16:58:09 +0200 Subject: [PATCH 12/48] Add missing dependency --- src/Controller/Cas10Controller.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index caa6fca..63b3285 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; #[AsController] class Cas10Controller From 44a3b0ed01a71e26b74953e2efbbdbad099cea2d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 19:16:40 +0200 Subject: [PATCH 13/48] LoggedInController.php --- composer.json | 1 + public/loggedIn.php | 32 ------------------- routing/routes/routes.php | 5 +++ routing/services/services.yml | 3 ++ src/Controller/LoggedInController.php | 45 +++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 32 deletions(-) delete mode 100644 public/loggedIn.php create mode 100644 src/Controller/LoggedInController.php diff --git a/composer.json b/composer.json index 9f98896..bc5196b 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "ext-libxml": "*", "ext-SimpleXML": "*", "ext-pdo": "*", + "ext-session": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", diff --git a/public/loggedIn.php b/public/loggedIn.php deleted file mode 100644 index 295f482..0000000 --- a/public/loggedIn.php +++ /dev/null @@ -1,32 +0,0 @@ -send(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 3034db7..31a1480 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -24,6 +25,8 @@ ->controller([LogoutController::class, 'logout']); $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) ->controller([LoggedOutController::class, 'main']); + $routes->add(RoutesEnum::LoggedIn->name, RoutesEnum::LoggedIn->value) + ->controller([LoggedInController::class, 'main']); // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) @@ -32,4 +35,6 @@ ->controller([LogoutController::class, 'logout']); $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) ->controller([LoggedOutController::class, 'main']); + $routes->add(LegacyRoutesEnum::LegacyLoggedIn->name, LegacyRoutesEnum::LegacyLoggedIn->value) + ->controller([LoggedInController::class, 'main']); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index ef66c66..6b968fc 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -22,4 +22,7 @@ services: public: true SimpleSAML\Module\casserver\Controller\LoggedOutController: + public: true + + SimpleSAML\Module\casserver\Controller\LoggedInController: public: true \ No newline at end of file diff --git a/src/Controller/LoggedInController.php b/src/Controller/LoggedInController.php new file mode 100644 index 0000000..e4049f6 --- /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'); + } +} From 95af6871b514c504f53554874c1fb62bff323523 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 22 Nov 2024 12:04:16 +0200 Subject: [PATCH 14/48] Cas10 validate improvements --- src/Controller/Cas10Controller.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 63b3285..a7d7275 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -102,26 +102,26 @@ public function validate( $failed = false; $message = ''; - // No ticket - if ($serviceTicket === null) { + if (empty($serviceTicket)) { + // No ticket $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; $failed = true; - // This is not a service ticket } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { + // This is not a service ticket $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; $failed = true; - // the ticket has expired } 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 we pass to the query parameters does not match the one in the ticket. + // 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) { - // If the forceAuthn/renew is 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; } @@ -157,6 +157,8 @@ public function validate( } /** + * Used by the unit tests + * * @return mixed */ public function getTicketStore(): mixed From d9ab4000d9466daad9517e256997ebea22978c6b Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 25 Nov 2024 19:53:46 +0200 Subject: [PATCH 15/48] serviceValidate, proxyValidate, logoutController --- public/login.php | 2 +- public/proxyValidate.php | 33 --- public/serviceValidate.php | 33 --- public/utility/urlUtils.php | 22 ++ public/utility/validateTicket.php | 202 ------------- routing/routes/routes.php | 13 + routing/services/services.yml | 3 + src/Controller/Cas10Controller.php | 10 +- src/Controller/Cas20Controller.php | 268 ++++++++++++++++++ src/Controller/LoginController.php | 252 ++++++++++++++++ src/Controller/LogoutController.php | 5 + src/Http/XmlResponse.php | 17 ++ tests/src/Controller/Cas10ControllerTest.php | 21 +- tests/src/Controller/LogoutControllerTest.php | 18 +- 14 files changed, 612 insertions(+), 287 deletions(-) delete mode 100644 public/proxyValidate.php delete mode 100644 public/serviceValidate.php delete mode 100644 public/utility/validateTicket.php create mode 100644 src/Controller/Cas20Controller.php create mode 100644 src/Controller/LoginController.php create mode 100644 src/Http/XmlResponse.php diff --git a/public/login.php b/public/login.php index 5b9798a..126b770 100644 --- a/public/login.php +++ b/public/login.php @@ -226,7 +226,7 @@ $_GET[$ticketName] = $serviceTicket['id']; // We want to capture the output from echo used in validateTicket ob_start(); - require_once 'utility/validateTicket.php'; + echo casServiceValidate($serviceTicket['id'], $serviceUrl); $casResponse = ob_get_contents(); ob_end_clean(); echo '
' . htmlspecialchars($casResponse) . '
'; diff --git a/public/proxyValidate.php b/public/proxyValidate.php deleted file mode 100644 index 01ab183..0000000 --- a/public/proxyValidate.php +++ /dev/null @@ -1,33 +0,0 @@ -addURLParameters( + Module::getModuleURL('casserver/serviceValidate.php'), + compact('ticket', 'service'), + ); + + return $httpUtils->fetch($url); +} diff --git a/public/utility/validateTicket.php b/public/utility/validateTicket.php deleted file mode 100644 index 0d639d2..0000000 --- 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/routing/routes/routes.php b/routing/routes/routes.php index 31a1480..9488594 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\Cas20Controller; use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; @@ -21,6 +22,12 @@ // New Routes $routes->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::Logout->name, RoutesEnum::Logout->value) ->controller([LogoutController::class, 'logout']); $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) @@ -31,6 +38,12 @@ // 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::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value) ->controller([LogoutController::class, 'logout']); $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) diff --git a/routing/services/services.yml b/routing/services/services.yml index 6b968fc..94d335b 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -18,6 +18,9 @@ services: SimpleSAML\Module\casserver\Controller\Cas10Controller: public: true + SimpleSAML\Module\casserver\Controller\Cas20Controller: + public: true + SimpleSAML\Module\casserver\Controller\LogoutController: public: true diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index a7d7275..b270459 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -25,6 +25,9 @@ class Cas10Controller /** @var Configuration */ protected Configuration $casConfig; + /** @var Configuration */ + protected Configuration $sspConfig; + /** @var Cas10 */ protected Cas10 $cas10Protocol; @@ -35,15 +38,18 @@ class Cas10Controller protected mixed $ticketStore; /** + * @param Configuration|null $sspConfig * @param Configuration|null $casConfig - * @param $ticketStore + * @param null $ticketStore * * @throws \Exception */ public function __construct( + Configuration $sspConfig = null, Configuration $casConfig = null, $ticketStore = null, ) { + $this->sspConfig = $sspConfig ?? Configuration::getInstance(); $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ @@ -127,7 +133,7 @@ public function validate( } if ($failed) { - Logger::error('casserver:validate: ' . $message, true); + Logger::error('casserver:validate: ' . $message); return new Response( $this->cas10Protocol->getValidateFailureResponse(), Response::HTTP_BAD_REQUEST, diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php new file mode 100644 index 0000000..bbe879d --- /dev/null +++ b/src/Controller/Cas20Controller.php @@ -0,0 +1,268 @@ +sspConfig = $sspConfig ?? Configuration::getInstance(); + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $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 = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + /** @psalm-suppress InvalidStringClass */ + $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); + } + + /** + * @param Request $request + * @param bool $renew + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function serviceValidate( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $pgtUrl = null, + ): XmlResponse { + return $this->validate( + $request, + 'serviceValidate', + $renew, + $ticket, + $service, + $pgtUrl, + ); + } + + /** + * @param Request $request + * @param bool $renew + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function proxyValidate( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $pgtUrl = null, + ): XmlResponse { + return $this->validate( + $request, + 'proxyValidate', + $renew, + $ticket, + $service, + $pgtUrl, + ); + } + + /** + * @param Request $request + * @param string $method + * @param bool $renew + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function validate( + Request $request, + string $method, + bool $renew = false, + ?string $ticket = null, + ?string $service = null, + ?string $pgtUrl = null, + ): XmlResponse { + $forceAuthn = $renew; + $serviceUrl = $service ?? $_GET['TARGET'] ?? null; + + // Check if any of the required query parameters are missing + if ($serviceUrl === null || $ticket === null) { + $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; + $message = "casserver: Missing service parameter: [{$messagePostfix}]"; + Logger::debug($message); + + ob_start(); + echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); + $responseContent = ob_get_clean(); + + return new XmlResponse( + $responseContent, + 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) { + $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); + Logger::error($message); + + ob_start(); + echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); + $responseContent = ob_get_clean(); + + return new XmlResponse( + $responseContent, + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $failed = false; + $message = ''; + if (empty($serviceTicket)) { + // No ticket + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { + $message = 'Ticket ' . var_export($_GET['ticket'], true) . + ' is a proxy ticket. Use proxyValidate instead.'; + $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($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($finalMessage); + + ob_start(); + echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); + $responseContent = ob_get_clean(); + + return new XmlResponse( + $responseContent, + 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 { + $this->httpUtils->fetch( + $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], + ); + + $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); + + $this->ticketStore->addTicket($proxyGrantingTicket); + } catch (Exception $e) { + // Fall through + } + } + } + + ob_start(); + echo $this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']); + $successContent = ob_get_clean(); + + return new XmlResponse( + $successContent, + Response::HTTP_OK, + ); + } +} diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php new file mode 100644 index 0000000..1814615 --- /dev/null +++ b/src/Controller/LoginController.php @@ -0,0 +1,252 @@ +sspConfig = $sspConfig ?? Configuration::getInstance(); + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); + + $this->serviceValidator = new ServiceValidator($this->casConfig); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + // 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); + } + + /** + * + * @param Request $request + * @param bool $renew + * @param bool $gateway + * @param string|null $service + * @param string|null $scope + * @param string|null $language + * @param string|null $entityId + * + * @return RedirectResponse|null + * @throws \Exception + */ + public function login( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] bool $gateway = false, + #[MapQueryParameter] string $service = null, + #[MapQueryParameter] string $scope = null, + #[MapQueryParameter] string $language = null, + #[MapQueryParameter] string $entityId = null, + #[MapQueryParameter] string $debugMode = null, + ): RedirectResponse|null { + $this->handleServiceConfiguration($service); + $this->handleScope($scope); + $this->handleLanguage($language); + + if ($request->query->has(ProcessingChain::AUTHPARAM)) { + $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); + } + + // Get the ticket from the session + $session = Session::getSessionFromRequest(); + $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); + + // Construct the ticket name + $defaultTicketName = isset($service) ? 'ticket' : 'SAMLart'; + $ticketName = $this->casconfig->getOptionalValue('ticketName', $defaultTicketName); + + + $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + } + + public function handleDebugMode( + Request $request, + ?string $debugMode, + string $ticketName, + array $serviceTicket, + ): void { + // Check if the debugMode is supported + if (!\in_array($debugMode, ['true', 'samlValidate'], true)) { + return; + } + + if ($debugMode === 'true') { + // Service validate CAS20 + $this->httpUtils->redirectTrustedURL( + Module::getModuleURL('/cas/serviceValidate.php'), + [ ...$request->getQueryParams(), $ticketName => $serviceTicket['id'] ], + ); + } + + // samlValidate Mode + $samlValidate = new SamlValidateResponder(); + $samlResponse = $samlValidate->convertToSaml($serviceTicket); + $soap = $samlValidate->wrapInSoap($samlResponse); + echo '
' . htmlspecialchars((string)$soap) . '
'; + } + + /** + * @param array|null $sessionTicket + * + * @return string + */ + public function getReturnUrl(?array $sessionTicket): string + { + // Parse the query parameters and return them in an array + $query = parseQueryParameters($sessionTicket); + // Construct the ReturnTo URL + return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); + } + + /** + * @param string|null $service + * + * @return void + * @throws \Exception + */ + public function handleServiceConfiguration(?string $service): void + { + // todo: Check request objec the TARGET + $serviceUrl = $service ?? $_GET['TARGET'] ?? null; + if ($serviceUrl === null) { + return; + } + $serviceCasConfig = $this->serviceValidator->checkServiceURL(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 \Exception($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; + } + + Language::setLanguageCookie($language); + } + + /** + * @param string|null $scope + * + * @return void + * @throws \Exception + */ + 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($_GET['scope'], true); + Logger::debug('casserver:' . $message); + + throw new \Exception($message); + } + + // Set the idplist from the scopes + $this->idpList = $scopes[$scope]; + } +} diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 6623a28..2461ac5 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -28,6 +28,9 @@ class LogoutController /** @var Configuration */ protected Configuration $casConfig; + /** @var Configuration */ + protected Configuration $sspConfig; + /** @var TicketFactory */ protected TicketFactory $ticketFactory; @@ -50,11 +53,13 @@ class LogoutController * @throws \Exception */ public function __construct( + Configuration $sspConfig = null, // Facilitate testing Configuration $casConfig = null, Simple $source = null, SspContainer $container = null, ) { + $this->sspConfig = $sspConfig ?? Configuration::getInstance(); $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->container = $container ?? new SspContainer(); diff --git a/src/Http/XmlResponse.php b/src/Http/XmlResponse.php new file mode 100644 index 0000000..8480b65 --- /dev/null +++ b/src/Http/XmlResponse.php @@ -0,0 +1,17 @@ + 'text/xml; charset=ISO-8859-1', + ])); + } +} diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index ca51723..e398c64 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -23,8 +23,11 @@ class Cas10ControllerTest extends TestCase private string $sessionId; + private Configuration $sspConfig; + protected function setUp(): void { + $this->sspConfig = Configuration::getConfig('config.php'); $this->sessionId = session_create_id(); $this->moduleConfig = [ 'ticketstore' => [ @@ -95,7 +98,7 @@ public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(400, $response->getStatusCode()); @@ -122,7 +125,7 @@ public function getTicket(string $ticketId): ?array } }; - $cas10Controller = new Cas10Controller($config, $ticketStore); + $cas10Controller = new Cas10Controller($this->sspConfig, $config, $ticketStore); $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(500, $response->getStatusCode()); @@ -142,7 +145,7 @@ public function testReturnBadRequestOnTicketNotExist(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(400, $response->getStatusCode()); @@ -162,7 +165,7 @@ public function testReturnBadRequestOnTicketExpired(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -186,7 +189,7 @@ public function testReturnBadRequestOnTicketNotService(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -210,7 +213,7 @@ public function testReturnBadRequestOnTicketMissingUsernameField(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -234,7 +237,7 @@ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): voi parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -258,7 +261,7 @@ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -282,7 +285,7 @@ public function testSuccessfullValidation(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index b36263e..69ac65e 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -21,6 +21,8 @@ class LogoutControllerTest extends TestCase private SspContainer $sspContainer; + private Configuration $sspConfig; + protected function setUp(): void { $this->authSimpleMock = $this->getMockBuilder(Simple::class) @@ -39,6 +41,8 @@ protected function setUp(): void 'directory' => __DIR__ . '../../../../tests/ticketcache', ], ]; + + $this->sspConfig = Configuration::getConfig('config.php'); } public static function setUpBeforeClass(): void @@ -60,7 +64,7 @@ public function testLogoutNotAllowed(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Logout not allowed'); - $controller = new LogoutController($config, $this->authSimpleMock); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock); $controller->logout(Request::create('/')); } @@ -73,7 +77,7 @@ public function testLogoutNoRedirectUrlOnSkipLogout(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Required URL query parameter [url] not provided. (CAS Server)'); - $controller = new LogoutController($config, $this->authSimpleMock); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock); $controller->logout(Request::create('/')); } @@ -93,7 +97,7 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void [], ); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $logoutUrl = Module::getModuleURL('casserver/logout.php'); @@ -119,7 +123,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void [], ); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); } @@ -140,7 +144,7 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void ['url' => $urlParam], ); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $request = Request::create( uri: $logoutUrl, parameters: ['url' => $urlParam], @@ -161,7 +165,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('logout') ->with('http://localhost/module.php/casserver/loggedOut.php'); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); } @@ -172,7 +176,7 @@ public function testTicketIdGetsDeletedOnLogout(): void $config = Configuration::loadFromArray($this->moduleConfig); $controllerMock = $this->getMockBuilder(LogoutController::class) - ->setConstructorArgs([$config, $this->authSimpleMock, $this->sspContainer]) + ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer]) ->onlyMethods(['getSession']) ->getMock(); From bd3649f8c4f6a38b4cc7ed44df8596526a4befa6 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 26 Nov 2024 19:47:03 +0200 Subject: [PATCH 16/48] samlValidate.php controller and tests --- composer.json | 1 + public/samlValidate.php | 36 -- routing/routes/routes.php | 7 + routing/services/services.yml | 14 +- src/Controller/Cas10Controller.php | 28 +- src/Controller/Cas20Controller.php | 73 ++-- src/Controller/Cas30Controller.php | 134 +++++++ src/Controller/LoginController.php | 5 +- src/Controller/LogoutController.php | 12 +- ...892424614ae738153cf6fda6ea372f54489870b8eb | 1 + tests/resources/saml/samlRequest.xml | 8 + tests/src/Controller/Cas30ControllerTest.php | 361 ++++++++++++++++++ 12 files changed, 589 insertions(+), 91 deletions(-) delete mode 100644 public/samlValidate.php create mode 100644 src/Controller/Cas30Controller.php create mode 100644 tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb create mode 100644 tests/resources/saml/samlRequest.xml create mode 100644 tests/src/Controller/Cas30ControllerTest.php diff --git a/composer.json b/composer.json index bc5196b..7dd8fda 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "ext-SimpleXML": "*", "ext-pdo": "*", "ext-session": "*", + "ext-xml": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", diff --git a/public/samlValidate.php b/public/samlValidate.php deleted file mode 100644 index fdb5012..0000000 --- a/public/samlValidate.php +++ /dev/null @@ -1,36 +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/routing/routes/routes.php b/routing/routes/routes.php index 9488594..7d6cac6 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; use SimpleSAML\Module\casserver\Controller\Cas20Controller; +use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; @@ -28,6 +29,9 @@ $routes->add(RoutesEnum::ProxyValidate->name, RoutesEnum::ProxyValidate->value) ->controller([Cas20Controller::class, 'proxyValidate']) ->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) @@ -44,6 +48,9 @@ $routes->add(LegacyRoutesEnum::LegacyProxyValidate->name, LegacyRoutesEnum::LegacyProxyValidate->value) ->controller([Cas20Controller::class, 'proxyValidate']) ->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) diff --git a/routing/services/services.yml b/routing/services/services.yml index 94d335b..4af98b8 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -8,11 +8,12 @@ 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'] + resource: '../../src/Controller/*' + exclude: + - '../../src/Controller/Traits/*' + public: true + tags: [ 'controller.service_arguments' ] + # Explicit service definitions for CasServer Controllers SimpleSAML\Module\casserver\Controller\Cas10Controller: @@ -21,6 +22,9 @@ services: SimpleSAML\Module\casserver\Controller\Cas20Controller: public: true + SimpleSAML\Module\casserver\Controller\Cas30Controller: + public: true + SimpleSAML\Module\casserver\Controller\LogoutController: public: true diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index b270459..66ee55c 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -25,9 +25,6 @@ class Cas10Controller /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var Cas10 */ protected Cas10 $cas10Protocol; @@ -38,19 +35,22 @@ class Cas10Controller protected mixed $ticketStore; /** - * @param Configuration|null $sspConfig + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param null $ticketStore * * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, Configuration $casConfig = null, $ticketStore = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since + // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor + // argument in order to facilitate testin. + $this->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); @@ -67,21 +67,25 @@ public function __construct( /** * @param Request $request - * @param bool $renew - * @param string|null $ticket - * @param string|null $service + * @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] bool $renew = false, #[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 afterwards. if ($service === null || $ticket === null) { $messagePostfix = $service === null ? 'service' : 'ticket'; Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]"); diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index bbe879d..2f3ba1f 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -27,9 +27,6 @@ class Cas20Controller /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var Cas20 */ protected Cas20 $cas20Protocol; @@ -40,18 +37,22 @@ class Cas20Controller protected mixed $ticketStore; /** + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param $ticketStore * * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, Configuration $casConfig = null, $ticketStore = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since + // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor + // argument in order to facilitate testing + $this->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); @@ -62,65 +63,74 @@ public function __construct( ); $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; - /** @psalm-suppress InvalidStringClass */ $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); } /** * @param Request $request - * @param bool $renew - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl + * @param string $TARGET // todo: this should go away + * @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 = '', #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, #[MapQueryParameter] ?string $pgtUrl = null, ): XmlResponse { return $this->validate( - $request, - 'serviceValidate', - $renew, - $ticket, - $service, - $pgtUrl, + request: $request, + method: 'serviceValidate', + target: $TARGET, + renew: $renew, + ticket: $ticket, + service: $service, + pgtUrl: $pgtUrl, ); } /** * @param Request $request - * @param bool $renew - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl - * + * @param string $TARGET // todo: this should go away??? + * @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 = '', #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, #[MapQueryParameter] ?string $pgtUrl = null, ): XmlResponse { return $this->validate( - $request, - 'proxyValidate', - $renew, - $ticket, - $service, - $pgtUrl, + request: $request, + method: 'proxyValidate', + target: $TARGET, + renew: $renew, + ticket: $ticket, + service: $service, + pgtUrl: $pgtUrl, ); } /** * @param Request $request * @param string $method + * @param string $target * @param bool $renew * @param string|null $ticket * @param string|null $service @@ -131,13 +141,15 @@ public function proxyValidate( public function validate( Request $request, string $method, + string $target, bool $renew = false, ?string $ticket = null, ?string $service = null, ?string $pgtUrl = null, ): XmlResponse { $forceAuthn = $renew; - $serviceUrl = $service ?? $_GET['TARGET'] ?? null; + // todo: According to the protocol, there is no target??? Why are we using it? + $serviceUrl = $service ?? $target ?? null; // Check if any of the required query parameters are missing if ($serviceUrl === null || $ticket === null) { @@ -250,12 +262,13 @@ public function validate( $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); $this->ticketStore->addTicket($proxyGrantingTicket); - } catch (Exception $e) { + } catch (\Exception $e) { // Fall through } } } + // TODO: Replace with string casting ob_start(); echo $this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']); $successContent = ob_get_clean(); diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php new file mode 100644 index 0000000..d71ee46 --- /dev/null +++ b/src/Controller/Cas30Controller.php @@ -0,0 +1,134 @@ +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. + * + * @throws \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 { + // From SAML2\SOAP::receive() + $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 + + $ticketParser = xml_parser_create(); + xml_parser_set_option($ticketParser, XML_OPTION_CASE_FOLDING, 0); + xml_parser_set_option($ticketParser, XML_OPTION_SKIP_WHITE, 1); + xml_parse_into_struct($ticketParser, $postBody, $values, $tags); + xml_parser_free($ticketParser); + + // Check for the required saml attributes + $samlRequestAttributes = $values[ $tags['samlp:Request'][0] ]['attributes']; + if (!isset($samlRequestAttributes['RequestID'])) { + throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); + } elseif (!isset($samlRequestAttributes['IssueInstant'])) { + throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); + } + + if ( + !isset($tags['samlp:AssertionArtifact']) + || empty($values[$tags['samlp:AssertionArtifact'][0]]['value']) + ) { + throw new \RuntimeException('Missing ticketId in AssertionArtifact'); + } + + $ticketId = $values[$tags['samlp:AssertionArtifact'][0]]['value']; + 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/LoginController.php b/src/Controller/LoginController.php index 1814615..c486e85 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -10,6 +10,7 @@ use SimpleSAML\Locale\Language; use SimpleSAML\Logger; use SimpleSAML\Module; +use SimpleSAML\Module\casserver\Cas\AttributeExtractor; use SimpleSAML\Module\casserver\Cas\Factories\ProcessingChainFactory; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; @@ -175,7 +176,7 @@ public function handleDebugMode( public function getReturnUrl(?array $sessionTicket): string { // Parse the query parameters and return them in an array - $query = parseQueryParameters($sessionTicket); + $query = $this->parseQueryParameters($sessionTicket); // Construct the ReturnTo URL return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); } @@ -193,7 +194,7 @@ public function handleServiceConfiguration(?string $service): void if ($serviceUrl === null) { return; } - $serviceCasConfig = $this->serviceValidator->checkServiceURL(sanitize($serviceUrl)); + $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); diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 2461ac5..60b0990 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -28,9 +28,6 @@ class LogoutController /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var TicketFactory */ protected TicketFactory $ticketFactory; @@ -53,14 +50,17 @@ class LogoutController * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, // Facilitate testing Configuration $casConfig = null, Simple $source = null, SspContainer $container = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since + // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor + // argument in order to facilitate testin. + $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->container = $container ?? new SspContainer(); diff --git a/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb new file mode 100644 index 0000000..f04b856 --- /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 0000000..6de610a --- /dev/null +++ b/tests/resources/saml/samlRequest.xml @@ -0,0 +1,8 @@ + + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php new file mode 100644 index 0000000..99bf3ae --- /dev/null +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -0,0 +1,361 @@ +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, + // 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 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 function testSoapBodyMissingRequestIdAttribute(): 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, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing RequestID samlp:Request attribute.'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testSoapBodyMissingIssueInstantAttribute(): 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, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing IssueInstant samlp:Request attribute.'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testSoapBodyMissingTicketId(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + + + + +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, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing ticketId in AssertionArtifact'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + 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, + ); + + $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); + } + + 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, + ); + + $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); + } + + 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(), + ); + } +} From 63269c8e25fa692356c4733eec9a3bbc14cb129d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 26 Nov 2024 19:55:01 +0200 Subject: [PATCH 17/48] fix psalm errors --- tests/src/Controller/Cas10ControllerTest.php | 1 + tests/src/Controller/Cas30ControllerTest.php | 33 +++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index e398c64..1acc1c4 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -125,6 +125,7 @@ public function getTicket(string $ticketId): ?array } }; + /** @psalm-suppress InvalidArgument */ $cas10Controller = new Cas10Controller($this->sspConfig, $config, $ticketStore); $response = $cas10Controller->validate($request, ...$params); diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index 99bf3ae..8fed306 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -33,7 +33,7 @@ class Cas30ControllerTest extends TestCase private array $ticket; /** - * @throws Exception + * @throws \Exception */ protected function setUp(): void { @@ -80,6 +80,10 @@ protected function setUp(): void ]; } + /** + * @return void + * @throws \Exception + */ public function testNoSoapBody(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -106,6 +110,10 @@ public function testNoSoapBody(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSoapBodyMissingRequestIdAttribute(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -145,6 +153,10 @@ public function testSoapBodyMissingRequestIdAttribute(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSoapBodyMissingIssueInstantAttribute(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -184,6 +196,10 @@ public function testSoapBodyMissingIssueInstantAttribute(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSoapBodyMissingTicketId(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -224,6 +240,11 @@ public function testSoapBodyMissingTicketId(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + + /** + * @return void + * @throws \Exception + */ public function testCasValidateAndDeleteTicketThrowsException(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -252,6 +273,7 @@ public function testCasValidateAndDeleteTicketThrowsException(): void content: $samlRequest, ); + /** @psalm-suppress UndefinedMethod */ $this->ticketValidatorMock ->expects($this->once()) ->method('validateAndDeleteTicket') @@ -270,6 +292,10 @@ public function testCasValidateAndDeleteTicketThrowsException(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testUnableToLoadTicket(): void { $this->ticketStore->addTicket(['id' => $this->ticket['id']]); @@ -300,6 +326,7 @@ public function testUnableToLoadTicket(): void content: $samlRequest, ); + /** @psalm-suppress UndefinedMethod */ $this->ticketValidatorMock ->expects($this->once()) ->method('validateAndDeleteTicket') @@ -318,6 +345,10 @@ public function testUnableToLoadTicket(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSuccessfullValidation(): void { $this->ticketStore->addTicket($this->ticket); From e1b3012e2a8522c4ae98151282d39b4cb7a35518 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 27 Nov 2024 19:52:17 +0200 Subject: [PATCH 18/48] move proxy.php to Cas20Controller action --- public/proxy.php | 115 -------- routing/routes/routes.php | 6 + src/Cas/Protocol/Cas20.php | 2 +- src/Controller/Cas20Controller.php | 115 ++++++-- tests/src/Controller/Cas20ControllerTest.php | 283 +++++++++++++++++++ tests/src/Controller/Cas30ControllerTest.php | 1 - 6 files changed, 383 insertions(+), 139 deletions(-) delete mode 100644 public/proxy.php create mode 100644 tests/src/Controller/Cas20ControllerTest.php diff --git a/public/proxy.php b/public/proxy.php deleted file mode 100644 index ae4182f..0000000 --- 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/routing/routes/routes.php b/routing/routes/routes.php index 7d6cac6..a60b6ff 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -29,6 +29,9 @@ $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']); @@ -48,6 +51,9 @@ $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']); diff --git a/src/Cas/Protocol/Cas20.php b/src/Cas/Protocol/Cas20.php index 4c63d1c..2307739 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/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 2f3ba1f..f04c55e 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -68,7 +68,7 @@ public function __construct( /** * @param Request $request - * @param string $TARGET // todo: this should go away + * @param string $TARGET * @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. @@ -97,6 +97,94 @@ public function serviceValidate( ); } + /** + * /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 + */ + public function proxy( + Request $request, + #[MapQueryParameter] ?string $targetService = null, + #[MapQueryParameter] ?string $pgt = null, + ): XmlResponse { + $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []); + // Fail if + $message = match (true) { + // targetService pareameter 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 $TARGET // todo: this should go away??? @@ -157,12 +245,8 @@ public function validate( $message = "casserver: Missing service parameter: [{$messagePostfix}]"; Logger::debug($message); - ob_start(); - echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - $responseContent = ob_get_clean(); - return new XmlResponse( - $responseContent, + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), Response::HTTP_BAD_REQUEST, ); } @@ -178,12 +262,8 @@ public function validate( $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); Logger::error($message); - ob_start(); - echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - $responseContent = ob_get_clean(); - return new XmlResponse( - $responseContent, + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), Response::HTTP_INTERNAL_SERVER_ERROR, ); } @@ -222,12 +302,8 @@ public function validate( $finalMessage = 'casserver:validate: ' . $message; Logger::error($finalMessage); - ob_start(); - echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - $responseContent = ob_get_clean(); - return new XmlResponse( - $responseContent, + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), Response::HTTP_BAD_REQUEST, ); } @@ -268,13 +344,8 @@ public function validate( } } - // TODO: Replace with string casting - ob_start(); - echo $this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']); - $successContent = ob_get_clean(); - return new XmlResponse( - $successContent, + (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), Response::HTTP_OK, ); } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php new file mode 100644 index 0000000..5fc5a41 --- /dev/null +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -0,0 +1,283 @@ +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, + ]; + } + + 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)); + } +} diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index 8fed306..ba584a8 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -62,7 +62,6 @@ protected function setUp(): void $this->ticket = [ 'id' => 'ST-' . $this->sessionId, 'validBefore' => 9999999999, - // phpcs:ignore Generic.Files.LineLength.TooLong 'service' => 'https://myservice.com/abcd', 'forceAuthn' => false, 'userName' => 'username@google.com', From 3f3721adb1a39cd814e86a4aebfe9d1617c45088 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 Dec 2024 11:19:41 +0200 Subject: [PATCH 19/48] Removed cas.php.Added LoginController.php --- public/.keep | 0 public/cas.php | 51 ------ public/login.php | 240 --------------------------- public/utility/urlUtils.php | 125 -------------- routing/routes/routes.php | 5 + routing/services/services.yml | 3 + src/Controller/Cas20Controller.php | 139 +--------------- src/Controller/LoginController.php | 246 +++++++++++++++++++++++----- src/Controller/LogoutController.php | 2 +- src/Controller/Traits/UrlTrait.php | 184 ++++++++++++++++----- 10 files changed, 361 insertions(+), 634 deletions(-) create mode 100644 public/.keep delete mode 100644 public/cas.php delete mode 100644 public/login.php delete mode 100644 public/utility/urlUtils.php diff --git a/public/.keep b/public/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/cas.php b/public/cas.php deleted file mode 100644 index 6c9f602..0000000 --- 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/login.php b/public/login.php deleted file mode 100644 index 126b770..0000000 --- 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(); - echo casServiceValidate($serviceTicket['id'], $serviceUrl); - $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/utility/urlUtils.php b/public/utility/urlUtils.php deleted file mode 100644 index 97fbb8a..0000000 --- a/public/utility/urlUtils.php +++ /dev/null @@ -1,125 +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; -} - -/** - * Uses the cas service validate, this provides additional attributes - * - * @param string $ticket - * @param string $service - * - * @return array username and attributes - * @throws \SimpleSAML\Error\Exception - */ -function casServiceValidate(string $ticket, string $service): string -{ - $httpUtils = new Utils\HTTP(); - $url = $httpUtils->addURLParameters( - Module::getModuleURL('casserver/serviceValidate.php'), - compact('ticket', 'service'), - ); - - return $httpUtils->fetch($url); -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index a60b6ff..999a4a6 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; +use SimpleSAML\Module\casserver\Controller\LoginController; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -39,6 +40,8 @@ ->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']); @@ -61,6 +64,8 @@ ->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 index 4af98b8..7348ffa 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -32,4 +32,7 @@ services: 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/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index f04c55e..ef1891d 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -89,8 +89,8 @@ public function serviceValidate( return $this->validate( request: $request, method: 'serviceValidate', - target: $TARGET, renew: $renew, + target: $TARGET, ticket: $ticket, service: $service, pgtUrl: $pgtUrl, @@ -207,146 +207,11 @@ public function proxyValidate( return $this->validate( request: $request, method: 'proxyValidate', - target: $TARGET, renew: $renew, + target: $TARGET, ticket: $ticket, service: $service, pgtUrl: $pgtUrl, ); } - - /** - * @param Request $request - * @param string $method - * @param string $target - * @param bool $renew - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl - * - * @return XmlResponse - */ - public function validate( - Request $request, - string $method, - string $target, - bool $renew = false, - ?string $ticket = null, - ?string $service = null, - ?string $pgtUrl = null, - ): XmlResponse { - $forceAuthn = $renew; - // todo: According to the protocol, there is no target??? Why are we using it? - $serviceUrl = $service ?? $target ?? null; - - // Check if any of the required query parameters are missing - if ($serviceUrl === null || $ticket === null) { - $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; - $message = "casserver: Missing service parameter: [{$messagePostfix}]"; - Logger::debug($message); - - return new XmlResponse( - (string)$this->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 - // unserialization handlers. - $serviceTicket = $this->ticketStore->getTicket($ticket); - // Delete the ticket - $this->ticketStore->deleteTicket($ticket); - } catch (\Exception $e) { - $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); - Logger::error($message); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_INTERNAL_SERVER_ERROR, - ); - } - - $failed = false; - $message = ''; - if (empty($serviceTicket)) { - // No ticket - $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; - $failed = true; - } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . - ' is a proxy ticket. Use proxyValidate instead.'; - $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($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($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 { - $this->httpUtils->fetch( - $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], - ); - - $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); - - $this->ticketStore->addTicket($proxyGrantingTicket); - } catch (\Exception $e) { - // Fall through - } - } - } - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), - Response::HTTP_OK, - ); - } } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index c486e85..ca3d89c 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -7,19 +7,21 @@ use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; -use SimpleSAML\Locale\Language; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\AttributeExtractor; use SimpleSAML\Module\casserver\Cas\Factories\ProcessingChainFactory; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; +use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; +use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; @@ -34,9 +36,6 @@ class LoginController /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var TicketFactory */ protected TicketFactory $ticketFactory; @@ -46,6 +45,9 @@ class LoginController /** @var Utils\HTTP */ protected Utils\HTTP $httpUtils; + /** @var Cas20 */ + protected Cas20 $cas20Protocol; + // this could be any configured ticket store /** @var mixed */ protected mixed $ticketStore; @@ -56,28 +58,39 @@ class LoginController /** @var array */ protected array $idpList; - /** @var string */ - protected string $authProcId; + /** @var string|null */ + protected ?string $authProcId = null; + + protected array $postAuthUrlParameters = []; + + /** @var string[] */ + private const DEBUG_MODES = ['true', 'samlValidate']; /** @var AttributeExtractor */ protected AttributeExtractor $attributeExtractor; + /** @var SamlValidateResponder */ + private SamlValidateResponder $samlValidateResponder; + /** + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param SspContainer|null $httpUtils + * @param HTTP|null $httpUtils * * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, // Facilitate testing Configuration $casConfig = null, Simple $source = null, Utils\HTTP $httpUtils = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + + $this->cas20Protocol = new Cas20($this->casConfig); $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->httpUtils = $httpUtils ?? new Utils\HTTP(); @@ -94,9 +107,11 @@ public function __construct( // Ticket Store $this->ticketStore = new $ticketStoreClass($this->casConfig); // Processing Chain Factory - $processingChainFactory = new ProcessingChainFactory($this->casconfig); + $processingChainFactory = new ProcessingChainFactory($this->casConfig); // Attribute Extractor - $this->attributeExtractor = new AttributeExtractor($this->casconfig, $processingChainFactory); + $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory); + // Saml Validate Responsder + $this->samlValidateResponder = new SamlValidateResponder(); } /** @@ -105,11 +120,15 @@ public function __construct( * @param bool $renew * @param bool $gateway * @param string|null $service + * @param string|null $TARGET * @param string|null $scope * @param string|null $language * @param string|null $entityId + * @param string|null $debugMode + * @param string|null $method * - * @return RedirectResponse|null + * @return RedirectResponse|XmlResponse|null + * @throws NoState * @throws \Exception */ public function login( @@ -117,12 +136,26 @@ public function login( #[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, - ): RedirectResponse|null { - $this->handleServiceConfiguration($service); + #[MapQueryParameter] string $method = null, + ): RedirectResponse|XmlResponse|null { + $forceAuthn = $renew; + $serviceUrl = $service ?? $TARGET ?? null; + $redirect = !(isset($method) && $method === 'POST'); + + // 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; + + $this->handleServiceConfiguration($serviceUrl); $this->handleScope($scope); $this->handleLanguage($language); @@ -130,67 +163,185 @@ public function login( $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); } - // Get the ticket from the session - $session = Session::getSessionFromRequest(); - $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); + // 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 + * */ + $this->authSource->login($params); + } - // Construct the ticket name - $defaultTicketName = isset($service) ? 'ticket' : 'SAMLart'; - $ticketName = $this->casconfig->getOptionalValue('ticketName', $defaultTicketName); + // 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) { + $urlParameters = $this->httpUtils->addURLParameters( + Module::getModuleURL('casserver/loggedIn'), + $this->postAuthUrlParameters, + ); + $this->httpUtils->redirectTrustedURL($urlParameters); + } + + // 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)) { + return $this->handleDebugMode($request, $debugMode, $serviceTicket); + } - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + $ticketName = $this->calculateTicketName($service); + $this->postAuthUrlParameters[$ticketName] = $serviceTicket['id']; + + // GET + if ($redirect) { + $this->httpUtils->redirectTrustedURL( + $this->httpUtils->addURLParameters($serviceUrl, $this->postAuthUrlParameters), + ); + } + // POST + $this->httpUtils->submitPOSTData($serviceUrl, $this->postAuthUrlParameters); + return null; } + /** + * @param Request $request + * @param string|null $debugMode + * @param array $serviceTicket + * + * @return XmlResponse + */ public function handleDebugMode( Request $request, ?string $debugMode, - string $ticketName, array $serviceTicket, - ): void { + ): XmlResponse { // Check if the debugMode is supported - if (!\in_array($debugMode, ['true', 'samlValidate'], true)) { - return; + if (!\in_array($debugMode, self::DEBUG_MODES, true)) { + return new XmlResponse( + 'invalid debug mode', + Response::HTTP_BAD_REQUEST, + ); } if ($debugMode === 'true') { // Service validate CAS20 - $this->httpUtils->redirectTrustedURL( - Module::getModuleURL('/cas/serviceValidate.php'), - [ ...$request->getQueryParams(), $ticketName => $serviceTicket['id'] ], + return $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'), ); } // samlValidate Mode - $samlValidate = new SamlValidateResponder(); - $samlResponse = $samlValidate->convertToSaml($serviceTicket); - $soap = $samlValidate->wrapInSoap($samlResponse); - echo '
' . htmlspecialchars((string)$soap) . '
'; + $samlResponse = $this->samlValidateResponder->convertToSaml($serviceTicket); + return new XmlResponse( + (string)$this->samlValidateResponder->wrapInSoap($samlResponse), + Response::HTTP_OK, + ); + } + + /** + * @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(?array $sessionTicket): string + public function getReturnUrl(Request $request, ?array $sessionTicket): string { // Parse the query parameters and return them in an array - $query = $this->parseQueryParameters($sessionTicket); + $query = $this->parseQueryParameters($request, $sessionTicket); // Construct the ReturnTo URL return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); } /** - * @param string|null $service + * @param string|null $serviceUrl * * @return void * @throws \Exception */ - public function handleServiceConfiguration(?string $service): void + public function handleServiceConfiguration(?string $serviceUrl): void { - // todo: Check request objec the TARGET - $serviceUrl = $service ?? $_GET['TARGET'] ?? null; if ($serviceUrl === null) { return; } @@ -204,7 +355,7 @@ public function handleServiceConfiguration(?string $service): void } // Override the cas configuration to use for this service - $this->casconfig = $serviceCasConfig; + $this->casConfig = $serviceCasConfig; } /** @@ -219,7 +370,7 @@ public function handleLanguage(?string $language): void return; } - Language::setLanguageCookie($language); + $this->postAuthUrlParameters['language'] = $language; } /** @@ -236,7 +387,7 @@ public function handleScope(?string $scope): void } // Get the scopes from the configuration - $scopes = $this->casconfig->getOptionalValue('scopes', []); + $scopes = $this->casConfig->getOptionalValue('scopes', []); // Fail if (!isset($scopes[$scope])) { @@ -250,4 +401,15 @@ public function handleScope(?string $scope): void // Set the idplist from the scopes $this->idpList = $scopes[$scope]; } + + /** + * Get the Session + * + * @return Session|null + * @throws \Exception + */ + protected function getSession(): ?Session + { + return Session::getSessionFromRequest(); + } } diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 60b0990..d104d02 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -103,7 +103,7 @@ public function logout( $logoutRedirectUrl = $url; $params = []; } else { - $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut.php'); + $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut'); $params = $url === null ? [] : ['url' => $url]; } diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 858edac..7e965e5 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -5,9 +5,12 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; use SimpleSAML\Configuration; +use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\TicketValidator; +use SimpleSAML\Module\casserver\Http\XmlResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; trait UrlTrait { @@ -26,7 +29,6 @@ public function checkServiceURL(string $service, array $legal_service_urls): boo return $serviceValidator->checkServiceURL($service) !== null; } - /** * @param string $parameter * @return string @@ -36,71 +38,177 @@ public 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 * @param Request $request + * @param array|null $sessionTicket * * @return array */ - public function parseQueryParameters(?array $sessionTicket, Request $request): array + public function parseQueryParameters(Request $request, ?array $sessionTicket): array { - $forceAuthn = isset($_GET['renew']) && $_GET['renew']; + $forceAuthn = $this->getRequestParam($request, 'renew'); $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - $query = []; + $queryParameters = $request->query->all(); + $requestParameters = $request->request->all(); + + $query = array_merge($requestParameters, $queryParameters); if ($sessionRenewId && $forceAuthn) { $query['renewId'] = $sessionRenewId; } - if (isset($_REQUEST['service'])) { - $query['service'] = $_REQUEST['service']; + if (isset($query['language'])) { + $query['language'] = is_string($query['language']) ? $query['language'] : null; } - if (isset($_REQUEST['TARGET'])) { - $query['TARGET'] = $_REQUEST['TARGET']; - } + return $query; + } - if (isset($_REQUEST['method'])) { - $query['method'] = $_REQUEST['method']; - } + /** + * @param Request $request + * @param string $paramName + * + * @return string|int|array|null + */ + public function getRequestParam(Request $request, string $paramName): string|int|array|null + { + return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; + } - if (isset($_REQUEST['renew'])) { - $query['renew'] = $_REQUEST['renew']; + /** + * @param Request $request + * @param string $method + * @param bool $renew + * @param string|null $target + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function validate( + Request $request, + string $method, + bool $renew = false, + ?string $target = null, + ?string $ticket = null, + ?string $service = null, + ?string $pgtUrl = null, + ): XmlResponse { + $forceAuthn = $renew; + $serviceUrl = $service ?? $target ?? null; + + // Check if any of the required query parameters are missing + if ($serviceUrl === null || $ticket === null) { + $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; + $message = "casserver: Missing service parameter: [{$messagePostfix}]"; + Logger::debug($message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); } - if (isset($_REQUEST['gateway'])) { - $query['gateway'] = $_REQUEST['gateway']; + 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) { + $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); + Logger::error($message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_INTERNAL_SERVER_ERROR, + ); } - if (\array_key_exists('language', $_GET)) { - $query['language'] = \is_string($_GET['language']) ? $_GET['language'] : null; + $failed = false; + $message = ''; + if (empty($serviceTicket)) { + // No ticket + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { + $message = 'Ticket ' . var_export($_GET['ticket'], true) . + ' is a proxy ticket. Use proxyValidate instead.'; + $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($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 (isset($_REQUEST['debugMode'])) { - $query['debugMode'] = $_REQUEST['debugMode']; - } + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($finalMessage); - return $query; - } + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } - /** - * @param Request $request - * - * @return array - */ - public function getRequestParams(Request $request): array - { - $params = []; - if ($request->isMethod('GET')) { - $params = $request->query->all(); - } elseif ($request->isMethod('POST')) { - $params = $request->request->all(); + $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 { + $this->httpUtils->fetch( + $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], + ); + + $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); + + $this->ticketStore->addTicket($proxyGrantingTicket); + } catch (\Exception $e) { + // Fall through + } + } } - return $params; + return new XmlResponse( + (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), + Response::HTTP_OK, + ); } } From ef816db97a34dbb1f678eaced651cefe4cf67d94 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 Dec 2024 11:40:01 +0200 Subject: [PATCH 20/48] UrlTrait tests --- tests/src/Controller/LogoutControllerTest.php | 6 ++--- .../Controller/Traits/UrlTraitTest.php} | 22 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) rename tests/{public/UtilsTest.php => src/Controller/Traits/UrlTraitTest.php} (87%) diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 69ac65e..5d48ee4 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -119,7 +119,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo('http://localhost/module.php/casserver/loggedOut.php'), + $this->equalTo('http://localhost/module.php/casserver/loggedOut'), [], ); @@ -133,7 +133,7 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $this->moduleConfig['skip_logout_page'] = false; $config = Configuration::loadFromArray($this->moduleConfig); $urlParam = 'https://example.com/test'; - $logoutUrl = Module::getModuleURL('casserver/loggedOut.php'); + $logoutUrl = Module::getModuleURL('casserver/loggedOut'); // Unauthenticated /** @psalm-suppress UndefinedMethod */ @@ -163,7 +163,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('logout') - ->with('http://localhost/module.php/casserver/loggedOut.php'); + ->with('http://localhost/module.php/casserver/loggedOut'); $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); diff --git a/tests/public/UtilsTest.php b/tests/src/Controller/Traits/UrlTraitTest.php similarity index 87% rename from tests/public/UtilsTest.php rename to tests/src/Controller/Traits/UrlTraitTest.php index 138b94a..aa65ead 100644 --- a/tests/public/UtilsTest.php +++ b/tests/src/Controller/Traits/UrlTraitTest.php @@ -1,23 +1,17 @@ assertEquals($allowed, checkServiceURL(sanitize($service), $legalServices), "$service validated wrong"); + $this->assertEquals( + $allowed, + $this->checkServiceURL($this->sanitize($service), $legalServices), + "$service validated wrong", + ); } From 1072701e97c1787a36b3a97efc05fa0770862bf4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 Dec 2024 11:52:36 +0200 Subject: [PATCH 21/48] Add missing import --- src/Controller/Traits/UrlTrait.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 7e965e5..447854f 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; +use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\ServiceValidator; From 38b43e5010e897138de70696f7998dd3e205d3d3 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 2 Dec 2024 18:49:19 +0200 Subject: [PATCH 22/48] LoginController Tests --- phpunit.xml | 1 + psalm.xml | 1 - src/Controller/LoginController.php | 38 +- src/Controller/Traits/UrlTrait.php | 2 +- tests/public/LoginIntegrationTest.php | 527 ------------------ tests/src/Controller/LoginControllerTest.php | 321 +++++++++++ tests/src/Controller/LogoutControllerTest.php | 13 +- 7 files changed, 353 insertions(+), 550 deletions(-) delete mode 100644 tests/public/LoginIntegrationTest.php create mode 100644 tests/src/Controller/LoginControllerTest.php diff --git a/phpunit.xml b/phpunit.xml index 9163c38..60deba4 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.xml b/psalm.xml index 72284dc..666c015 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,7 +13,6 @@ > - diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index ca3d89c..795c76c 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -147,6 +147,11 @@ public function login( $serviceUrl = $service ?? $TARGET ?? null; $redirect = !(isset($method) && $method === 'POST'); + // Set initial configurations, or fail + $this->handleServiceConfiguration($serviceUrl); + $this->handleScope($scope); + $this->handleLanguage($language); + // Get the ticket from the session $session = $this->getSession(); $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); @@ -155,10 +160,6 @@ public function login( // if this parameter is true, single sign-on will be bypassed and authentication will be enforced $requestForceAuthenticate = $forceAuthn && $sessionRenewId !== $requestRenewId; - $this->handleServiceConfiguration($serviceUrl); - $this->handleScope($scope); - $this->handleLanguage($language); - if ($request->query->has(ProcessingChain::AUTHPARAM)) { $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); } @@ -193,12 +194,14 @@ public function login( * REDIRECT TO AUTHSOURCE LOGIN * */ $this->authSource->login($params); + // We should never get here.This is to facilitate testing. + return null; } // 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 + // 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); @@ -213,6 +216,8 @@ public function login( $this->postAuthUrlParameters, ); $this->httpUtils->redirectTrustedURL($urlParameters); + // We should never get here.This is to facilitate testing. + return null; } // Get the state. @@ -246,9 +251,12 @@ public function login( $this->httpUtils->redirectTrustedURL( $this->httpUtils->addURLParameters($serviceUrl, $this->postAuthUrlParameters), ); + // We should never get here.This is to facilitate testing. + return null; } // POST $this->httpUtils->submitPOSTData($serviceUrl, $this->postAuthUrlParameters); + // We should never get here.This is to facilitate testing. return null; } @@ -338,7 +346,7 @@ public function getReturnUrl(Request $request, ?array $sessionTicket): string * @param string|null $serviceUrl * * @return void - * @throws \Exception + * @throws \RuntimeException */ public function handleServiceConfiguration(?string $serviceUrl): void { @@ -351,7 +359,7 @@ public function handleServiceConfiguration(?string $serviceUrl): void var_export($serviceUrl, true); Logger::debug('casserver:' . $message); - throw new \Exception($message); + throw new \RuntimeException($message); } // Override the cas configuration to use for this service @@ -377,7 +385,7 @@ public function handleLanguage(?string $language): void * @param string|null $scope * * @return void - * @throws \Exception + * @throws \RuntimeException */ public function handleScope(?string $scope): void { @@ -392,10 +400,10 @@ public function handleScope(?string $scope): void // Fail if (!isset($scopes[$scope])) { $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' . - var_export($_GET['scope'], true); + var_export($scope, true); Logger::debug('casserver:' . $message); - throw new \Exception($message); + throw new \RuntimeException($message); } // Set the idplist from the scopes @@ -408,8 +416,16 @@ public function handleScope(?string $scope): void * @return Session|null * @throws \Exception */ - protected function getSession(): ?Session + public function getSession(): ?Session { return Session::getSessionFromRequest(); } + + /** + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } } diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 447854f..859d77d 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -50,7 +50,7 @@ public function sanitize(string $parameter): string public function parseQueryParameters(Request $request, ?array $sessionTicket): array { $forceAuthn = $this->getRequestParam($request, 'renew'); - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + $sessionRenewId = !empty($sessionTicket['renewId']) ? $sessionTicket['renewId'] : null; $queryParameters = $request->query->all(); $requestParameters = $request->request->all(); diff --git a/tests/public/LoginIntegrationTest.php b/tests/public/LoginIntegrationTest.php deleted file mode 100644 index a431e98..0000000 --- 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/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php new file mode 100644 index 0000000..5216815 --- /dev/null +++ b/tests/src/Controller/LoginControllerTest.php @@ -0,0 +1,321 @@ +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); + $this->authSimpleMock->expects($this->once())->method('login')->with($loginParameters); + $sessionId = session_create_id(); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); + + $controllerMock->login($loginRequest, ...$requestParameters); + } + + /** + * 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); + $this->httpUtils->expects($this->once())->method('redirectTrustedURL') + ->with('http://localhost/module.php/casserver/loggedIn?'); + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: [], + ); + + $controllerMock->login($loginRequest); + } + + 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); + $this->httpUtils->expects($this->once())->method('redirectTrustedURL') + ->withAnyParameters() + ->willReturnCallback(function ($url) use ($redirectURL) { + $this->assertStringStartsWith( + $redirectURL, + $url, + 'Ticket should be part of the redirect.', + ); + }); + $queryParameters = [$serviceParam => 'https://example.com/ssp/module.php/cas/linkback.php']; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $queryParameters, + ); + + /** @psalm-suppress InvalidArgument */ + $controllerMock->login($loginRequest, ...$queryParameters); + } +} diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 5d48ee4..54c31ea 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\Simple; use SimpleSAML\Compat\SspContainer; @@ -17,9 +18,9 @@ class LogoutControllerTest extends TestCase { private array $moduleConfig; - private Simple $authSimpleMock; + private Simple|MockObject $authSimpleMock; - private SspContainer $sspContainer; + private SspContainer|MockObject $sspContainer; private Configuration $sspConfig; @@ -89,9 +90,7 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void $urlParam = 'https://example.com/test'; // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo($urlParam), [], @@ -115,9 +114,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $config = Configuration::loadFromArray($this->moduleConfig); // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo('http://localhost/module.php/casserver/loggedOut'), [], @@ -136,9 +133,7 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $logoutUrl = Module::getModuleURL('casserver/loggedOut'); // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo($logoutUrl), ['url' => $urlParam], @@ -159,9 +154,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $config = Configuration::loadFromArray($this->moduleConfig); // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('logout') ->with('http://localhost/module.php/casserver/loggedOut'); From 31cbf310c5e55bbc5100166971a76a88cb94cb94 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 3 Dec 2024 19:54:33 +0200 Subject: [PATCH 23/48] Tests for Cas20 validate --- src/Controller/Cas20Controller.php | 14 +- src/Controller/Traits/UrlTrait.php | 18 +- tests/src/Controller/Cas20ControllerTest.php | 221 ++++++++++++++++++- tests/src/Controller/Traits/UrlTraitTest.php | 108 ++++++--- 4 files changed, 318 insertions(+), 43 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index ef1891d..0b6112b 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -80,7 +80,7 @@ public function __construct( */ public function serviceValidate( Request $request, - #[MapQueryParameter] string $TARGET = '', + #[MapQueryParameter] string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, @@ -198,7 +198,7 @@ public function proxy( */ public function proxyValidate( Request $request, - #[MapQueryParameter] string $TARGET = '', + #[MapQueryParameter] string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, @@ -214,4 +214,14 @@ public function proxyValidate( pgtUrl: $pgtUrl, ); } + + /** + * Used by the unit tests + * + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } } diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 859d77d..5408865 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -72,9 +72,9 @@ public function parseQueryParameters(Request $request, ?array $sessionTicket): a * @param Request $request * @param string $paramName * - * @return string|int|array|null + * @return mixed */ - public function getRequestParam(Request $request, string $paramName): string|int|array|null + public function getRequestParam(Request $request, string $paramName): mixed { return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; } @@ -122,7 +122,11 @@ public function validate( // Delete the ticket $this->ticketStore->deleteTicket($ticket); } catch (\Exception $e) { - $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); + $messagePostfix = ''; + if (!empty($e->getMessage())) { + $messagePostfix = ': ' . var_export($e->getMessage(), true); + } + $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; Logger::error($message); return new XmlResponse( @@ -135,19 +139,19 @@ public function validate( $message = ''; if (empty($serviceTicket)) { // No ticket - $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $message = 'Ticket ' . var_export($ticket, true) . ' not recognized'; $failed = true; } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . + $message = 'Ticket ' . var_export($ticket, true) . ' is a proxy ticket. Use proxyValidate instead.'; $failed = true; } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { // This is not a service ticket - $message = 'ticket: ' . var_export($ticket, true) . ' 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'; + $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. diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 5fc5a41..bc10bfc 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -64,7 +64,7 @@ protected function setUp(): void $this->ticket = [ 'id' => 'ST-' . $this->sessionId, - 'validBefore' => 9999999999, + 'validBefore' => 1731111111, 'service' => 'https://myservice.com/abcd', 'forceAuthn' => false, 'userName' => 'username@google.com', @@ -280,4 +280,223 @@ public function testProxyReturnsProxyTicket(): void $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 service parameter: [ticket]', + ], + 'Ticket is empty but TARGET is not' => [ + ['TARGET' => 'http://localhost'], + 'casserver: Missing service 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_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public static function validateOnDifferentQueryParameterCombinations(): array + { + $sessionId = session_create_id(); + return [ + 'Returns Bad Request on Ticket Not Recogniged/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 a proxy ticket. Use proxyValidate instead.", + '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 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'], + ); + } } diff --git a/tests/src/Controller/Traits/UrlTraitTest.php b/tests/src/Controller/Traits/UrlTraitTest.php index aa65ead..25b5dbb 100644 --- a/tests/src/Controller/Traits/UrlTraitTest.php +++ b/tests/src/Controller/Traits/UrlTraitTest.php @@ -7,44 +7,12 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; +use Symfony\Component\HttpFoundation\Request; class UrlTraitTest extends TestCase { use UrlTrait; - /** - * @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", - ); - } - - /** * @return array */ @@ -85,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)); + } } From 31618c57ecf16ddcffb46a56e418b8706332ab41 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 4 Dec 2024 12:47:08 +0200 Subject: [PATCH 24/48] add tests for Proxy Service Validate --- src/Controller/Cas20Controller.php | 6 +++ src/Controller/Traits/UrlTrait.php | 21 ++++++-- tests/src/Controller/Cas20ControllerTest.php | 52 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 0b6112b..c2c8b4b 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -24,6 +25,9 @@ class Cas20Controller /** @var Logger */ protected Logger $logger; + /** @var Utils\HTTP */ + protected Utils\HTTP $httpUtils; + /** @var Configuration */ protected Configuration $casConfig; @@ -47,6 +51,7 @@ public function __construct( private readonly Configuration $sspConfig, Configuration $casConfig = null, $ticketStore = null, + Utils\HTTP $httpUtils = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor @@ -64,6 +69,7 @@ public function __construct( $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); } /** diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 5408865..be3ef99 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -198,15 +198,28 @@ public function validate( ], ); try { - $this->httpUtils->fetch( + // 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) { - // Fall through + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse( + C::ERR_INVALID_SERVICE, + 'Proxy callback url is failing.', + ), + Response::HTTP_BAD_REQUEST, + ); } } } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index bc10bfc..049f5f4 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; @@ -14,6 +15,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Cas20Controller; use SimpleSAML\Session; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -33,6 +35,8 @@ class Cas20ControllerTest extends TestCase private TicketValidator $ticketValidatorMock; + private Utils\HTTP|MockObject $utilsHttpMock; + private array $ticket; /** @@ -62,6 +66,11 @@ protected function setUp(): void ->onlyMethods(['getSessionId']) ->getMock(); + $this->utilsHttpMock = $this->getMockBuilder(Utils\HTTP::class) + ->disableOriginalConstructor() + ->onlyMethods(['fetch']) + ->getMock(); + $this->ticket = [ 'id' => 'ST-' . $this->sessionId, 'validBefore' => 1731111111, @@ -499,4 +508,47 @@ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): voi $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'], + ); + } } From f3144727bbf131f0754ae5d0acc4cef5c7788f02 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 4 Dec 2024 13:39:21 +0200 Subject: [PATCH 25/48] More tests on Cas20Controller.php --- composer.json | 3 ++ tests/src/Controller/Cas20ControllerTest.php | 34 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/composer.json b/composer.json index 7dd8fda..bf44351 100644 --- a/composer.json +++ b/composer.json @@ -70,6 +70,9 @@ ], "tests": [ "vendor/bin/phpunit --no-coverage" + ], + "propose-fix": [ + "vendor/bin/phpcs --report=diff" ] } } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 049f5f4..3cebcc1 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -91,6 +91,40 @@ protected function setUp(): void ]; } + public function testProxyValidatePassesTheCorrectMethodToValidate(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $requestParameters = [ + 'renew' => false, + 'service' => 'https://myservice.com/abcd', + 'ticket' => 'ST-' . $this->sessionId, + ]; + + $request = Request::create( + uri: 'https://myservice.com/abcd', + parameters: $requestParameters, + ); + + $expectedArguments = [ + 'request' => $request, + 'method' => 'proxyValidate', + 'renew' => false, + 'target' => null, + 'ticket' => 'ST-' . $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->proxyValidate($request, ...$requestParameters); + } + public static function queryParameterValues(): array { return [ From 36c2e4e2a7870ef0d4304b67d0cbaf669b47b625 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 08:52:21 +0200 Subject: [PATCH 26/48] Use TicketStore abstract class type --- src/Controller/Cas10Controller.php | 9 +++++---- src/Controller/Cas20Controller.php | 10 ++++++---- src/Controller/LoginController.php | 8 ++++---- src/Controller/LogoutController.php | 6 +++--- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 66ee55c..a41b2f9 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -8,6 +8,7 @@ use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas10; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,20 +32,20 @@ class Cas10Controller /** @var TicketFactory */ protected TicketFactory $ticketFactory; - // this could be any configured ticket store - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** * @param Configuration $sspConfig * @param Configuration|null $casConfig - * @param null $ticketStore + * @param TicketStore|null $ticketStore * * @throws \Exception */ public function __construct( private readonly Configuration $sspConfig, Configuration $casConfig = null, - $ticketStore = null, + TicketStore $ticketStore = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index c2c8b4b..2095ac4 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -9,6 +9,7 @@ use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Utils; @@ -37,20 +38,21 @@ class Cas20Controller /** @var TicketFactory */ protected TicketFactory $ticketFactory; - // this could be any configured ticket store - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** * @param Configuration $sspConfig * @param Configuration|null $casConfig - * @param $ticketStore + * @param TicketStore|null $ticketStore + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ public function __construct( private readonly Configuration $sspConfig, Configuration $casConfig = null, - $ticketStore = null, + TicketStore $ticketStore = null, Utils\HTTP $httpUtils = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 795c76c..a329a46 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; use SimpleSAML\Module\casserver\Cas\ServiceValidator; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; @@ -48,9 +49,8 @@ class LoginController /** @var Cas20 */ protected Cas20 $cas20Protocol; - // this could be any configured ticket store - /** @var mixed */ - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** @var ServiceValidator */ protected ServiceValidator $serviceValidator; @@ -76,7 +76,7 @@ class LoginController * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param HTTP|null $httpUtils + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index d104d02..d8f89d0 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -10,6 +10,7 @@ use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -37,9 +38,8 @@ class LogoutController /** @var SspContainer */ protected SspContainer $container; - // this could be any configured ticket store - /** @var mixed */ - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** From 640c7eb150155f354a9a2745b077ee528f6cb39d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 09:05:53 +0200 Subject: [PATCH 27/48] Use Module::resolveClass --- src/Controller/Cas10Controller.php | 5 ++--- src/Controller/Cas20Controller.php | 4 ++-- src/Controller/LoginController.php | 3 +-- src/Controller/LogoutController.php | 3 +-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index a41b2f9..c3ddaca 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -6,6 +6,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas10; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; @@ -60,9 +61,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; - /** @psalm-suppress InvalidStringClass */ + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); } diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 2095ac4..3da28a3 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -7,6 +7,7 @@ use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; @@ -68,8 +69,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); $this->httpUtils = $httpUtils ?? new Utils\HTTP(); } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index a329a46..6d5f731 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -102,8 +102,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); // Ticket Store $this->ticketStore = new $ticketStoreClass($this->casConfig); // Processing Chain Factory diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index d8f89d0..825893e 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -71,8 +71,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); $this->ticketStore = new $ticketStoreClass($this->casConfig); } From 801c4fd2c1452eccede36cd049e6ca39a4af6b1c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 09:53:20 +0200 Subject: [PATCH 28/48] Use RunnableResponse instead of redirect --- src/Controller/LogoutController.php | 21 +++---- tests/src/Controller/LogoutControllerTest.php | 59 ++++++++++--------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 825893e..11b3ad2 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -5,15 +5,15 @@ namespace SimpleSAML\Module\casserver\Controller; use SimpleSAML\Auth\Simple; -use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Session; -use Symfony\Component\HttpFoundation\RedirectResponse; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; @@ -35,17 +35,18 @@ class LogoutController /** @var Simple */ protected Simple $authSource; - /** @var SspContainer */ - protected SspContainer $container; + /** @var Utils\HTTP */ + protected Utils\HTTP $httpUtils; /** @var TicketStore */ protected TicketStore $ticketStore; /** + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param SspContainer|null $container + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ @@ -54,7 +55,7 @@ public function __construct( // Facilitate testing Configuration $casConfig = null, Simple $source = null, - SspContainer $container = null, + Utils\HTTP $httpUtils = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor @@ -62,7 +63,7 @@ public function __construct( $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) ? Configuration::getConfig('module_casserver.php') : $casConfig; $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); - $this->container = $container ?? new SspContainer(); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); @@ -80,12 +81,12 @@ public function __construct( * @param Request $request * @param string|null $url * - * @return RedirectResponse|null + * @return RunnableResponse|null */ public function logout( Request $request, #[MapQueryParameter] ?string $url = null, - ): RedirectResponse|null { + ): RunnableResponse|null { if (!$this->casConfig->getOptionalValue('enable_logout', false)) { $this->handleExceptionThrown('Logout not allowed'); } @@ -115,7 +116,7 @@ public function logout( // Redirect if (!$this->authSource->isAuthenticated()) { - $this->container->redirect($logoutRedirectUrl, $params); + return new RunnableResponse([$this->httpUtils, 'redirectTrustedURL'], [$logoutRedirectUrl, $params]); } // Logout and redirect diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 54c31ea..6df16f6 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -7,11 +7,11 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\Simple; -use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LogoutController; use SimpleSAML\Session; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; class LogoutControllerTest extends TestCase @@ -20,7 +20,7 @@ class LogoutControllerTest extends TestCase private Simple|MockObject $authSimpleMock; - private SspContainer|MockObject $sspContainer; + private Utils\HTTP $httpUtils; private Configuration $sspConfig; @@ -31,11 +31,6 @@ protected function setUp(): void ->onlyMethods(['logout', 'isAuthenticated']) ->getMock(); - $this->sspContainer = $this->getMockBuilder(SspContainer::class) - ->disableOriginalConstructor() - ->onlyMethods(['redirect']) - ->getMock(); - $this->moduleConfig = [ 'ticketstore' => [ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production @@ -43,6 +38,7 @@ protected function setUp(): void ], ]; + $this->httpUtils = new Utils\HTTP(); $this->sspConfig = Configuration::getConfig('config.php'); } @@ -91,12 +87,8 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo($urlParam), - [], - ); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); $logoutUrl = Module::getModuleURL('casserver/logout.php'); @@ -104,7 +96,14 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void uri: $logoutUrl, parameters: ['url' => $urlParam], ); - $controller->logout($request, $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 @@ -115,13 +114,16 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo('http://localhost/module.php/casserver/loggedOut'), - [], - ); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); - $controller->logout(Request::create('/')); + $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 @@ -134,17 +136,20 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo($logoutUrl), - ['url' => $urlParam], - ); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); $request = Request::create( uri: $logoutUrl, parameters: ['url' => $urlParam], ); - $controller->logout($request, $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 @@ -158,7 +163,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('logout') ->with('http://localhost/module.php/casserver/loggedOut'); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); $controller->logout(Request::create('/')); } @@ -169,7 +174,7 @@ public function testTicketIdGetsDeletedOnLogout(): void $config = Configuration::loadFromArray($this->moduleConfig); $controllerMock = $this->getMockBuilder(LogoutController::class) - ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer]) + ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils]) ->onlyMethods(['getSession']) ->getMock(); From 18e3e7220da38ab38d816a69ff14d04b83f2d80b Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 10:29:59 +0200 Subject: [PATCH 29/48] Added phpdocs --- tests/src/Controller/Cas10ControllerTest.php | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index 1acc1c4..6eba228 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -88,6 +88,12 @@ public static function queryParameterValues(): array ]; } + /** + * @param array $params + * + * @return void + * @throws Exception + */ #[DataProvider('queryParameterValues')] public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void { @@ -105,6 +111,10 @@ public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturn500OnDeleteTicketThatThrows(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -133,6 +143,10 @@ public function getTicket(string $ticketId): ?array $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketNotExist(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -153,6 +167,10 @@ public function testReturnBadRequestOnTicketNotExist(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketExpired(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -175,6 +193,10 @@ public function testReturnBadRequestOnTicketExpired(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketNotService(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -199,6 +221,10 @@ public function testReturnBadRequestOnTicketNotService(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketMissingUsernameField(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -223,6 +249,10 @@ public function testReturnBadRequestOnTicketMissingUsernameField(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -247,6 +277,10 @@ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): voi $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -271,6 +305,10 @@ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testSuccessfullValidation(): void { $config = Configuration::loadFromArray($this->moduleConfig); From dfea964910485dea96800e02fec516a0b2e8910e Mon Sep 17 00:00:00 2001 From: Patrick Radtke Date: Wed, 4 Dec 2024 16:56:10 -0800 Subject: [PATCH 30/48] Fix run with docker instructions --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c26f5f..2538915 100644 --- a/README.md +++ b/README.md @@ -89,17 +89,16 @@ of the `casserver` module mounted in the container, along with some configuratio "live" in the container, allowing you to test and iterate different things. ```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. From 9cc0b5c539fcc55782fd4ac76a09840ad05b7f50 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 19 Dec 2024 15:54:24 +0200 Subject: [PATCH 31/48] xml response to UTF-8 --- src/Controller/Cas10Controller.php | 2 +- src/Http/XmlResponse.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index c3ddaca..7802503 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -85,7 +85,7 @@ public function validate( // 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 afterwards. + // values afterward. if ($service === null || $ticket === null) { $messagePostfix = $service === null ? 'service' : 'ticket'; Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]"); diff --git a/src/Http/XmlResponse.php b/src/Http/XmlResponse.php index 8480b65..23e00c0 100644 --- a/src/Http/XmlResponse.php +++ b/src/Http/XmlResponse.php @@ -11,7 +11,7 @@ class XmlResponse extends Response public function __construct(?string $content = '', int $status = 200, array $headers = []) { parent::__construct($content, $status, array_merge($headers, [ - 'Content-Type' => 'text/xml; charset=ISO-8859-1', + 'Content-Type' => 'text/xml; charset=UTF-8', ])); } } From 5ea16ac9315e80376c4643b5bafd042db8ee95ba Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 19 Dec 2024 21:52:28 +0200 Subject: [PATCH 32/48] Fix validation condition for CAS10 --- src/Cas/AttributeExtractor.php | 7 ++----- src/Controller/Cas10Controller.php | 6 +++--- tests/src/Controller/Cas10ControllerTest.php | 4 ++-- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Cas/AttributeExtractor.php b/src/Cas/AttributeExtractor.php index 275f38d..291574e 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/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 7802503..0de3e10 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -147,8 +147,8 @@ public function validate( // Get the username field $usernameField = $this->casConfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); - // Fail if the username field is not present in the attribute list - if (!\array_key_exists($usernameField, $serviceTicket['attributes'])) { + // Fail if the username is not present in the ticket + if (empty($serviceTicket['userName'])) { Logger::error( 'casserver:validate: internal server error. Missing user name attribute: ' . var_export($usernameField, true), @@ -161,7 +161,7 @@ public function validate( // Successful validation return new Response( - $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]), + $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['userName']), Response::HTTP_OK, ); } diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index 6eba228..0b37f18 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -233,7 +233,7 @@ public function testReturnBadRequestOnTicketMissingUsernameField(): void 'service' => 'https://myservice.com/abcd', ]; $this->ticket['validBefore'] = 9999999999; - $this->ticket['attributes'] = []; + $this->ticket['userName'] = ''; $request = Request::create( uri: 'http://localhost', @@ -330,6 +330,6 @@ public function testSuccessfullValidation(): void $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals("yes\neduPersonPrincipalName@google.com\n", $response->getContent()); + $this->assertEquals("yes\nusername@google.com\n", $response->getContent()); } } From a1780a80c2a2a0bd4cefaf3c3d61d41965a0c862 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 09:06:31 +0200 Subject: [PATCH 33/48] review fixes #1 --- src/Cas/ServiceValidator.php | 77 ++++++++++++++++++----------- src/Cas/Ticket/SQLTicketStore.php | 8 +-- src/Controller/Cas10Controller.php | 11 +---- src/Controller/Cas20Controller.php | 8 +-- src/Controller/LoginController.php | 18 +++---- src/Controller/LogoutController.php | 4 +- 6 files changed, 69 insertions(+), 57 deletions(-) diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index f6dd889..ed7c79e 100644 --- a/src/Cas/ServiceValidator.php +++ b/src/Cas/ServiceValidator.php @@ -29,53 +29,70 @@ 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 (\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) { + $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 5ab0d84..0603ed8 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/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 0de3e10..81c7f4c 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -144,15 +144,8 @@ public function validate( ); } - // Get the username field - $usernameField = $this->casConfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); - // Fail if the username is not present in the ticket if (empty($serviceTicket['userName'])) { - Logger::error( - 'casserver:validate: internal server error. Missing user name attribute: ' - . var_export($usernameField, true), - ); return new Response( $this->cas10Protocol->getValidateFailureResponse(), Response::HTTP_BAD_REQUEST, @@ -169,9 +162,9 @@ public function validate( /** * Used by the unit tests * - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 3da28a3..eb903f4 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -88,7 +88,7 @@ public function __construct( */ public function serviceValidate( Request $request, - #[MapQueryParameter] string $TARGET = null, + #[MapQueryParameter] ?string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, @@ -124,7 +124,7 @@ public function proxy( $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []); // Fail if $message = match (true) { - // targetService pareameter is not defined + // 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]', @@ -226,9 +226,9 @@ public function proxyValidate( /** * Used by the unit tests * - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 6d5f731..05bbda6 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -134,13 +134,13 @@ 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, + #[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, ): RedirectResponse|XmlResponse|null { $forceAuthn = $renew; $serviceUrl = $service ?? $TARGET ?? null; @@ -421,9 +421,9 @@ public function getSession(): ?Session } /** - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 11b3ad2..1ee868d 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -127,9 +127,9 @@ public function logout( } /** - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } From c56cac33f4765b1ce865bd2e966aad7d579f324e Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 10:01:28 +0200 Subject: [PATCH 34/48] Intantiate classes after we finalize the casconfig overrides. Refactor checkServiceUrl.Improve composer validate. --- composer.json | 6 ++-- src/Controller/Cas20Controller.php | 2 +- src/Controller/LoginController.php | 54 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/composer.json b/composer.json index bf44351..db8476b 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,6 @@ }, "require": { "php": "^8.1", - "ext-ctype": "*", "ext-dom": "*", "ext-filter": "*", "ext-libxml": "*", @@ -54,7 +53,8 @@ "psalm/plugin-phpunit": "^0.19.0", "squizlabs/php_codesniffer": "^3.7", "maglnet/composer-require-checker": "4.7.1", - "vimeo/psalm": "^5" + "vimeo/psalm": "^5", + "icanhazstring/composer-unused": "^0.8.11" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", @@ -66,7 +66,7 @@ "vendor/bin/phpcs -p", "vendor/bin/composer-require-checker check composer.json", "vendor/bin/psalm -c psalm-dev.xml", - "vendor/bin/psalm -c psalm.xml" + "vendor/bin/composer-unused" ], "tests": [ "vendor/bin/phpunit --no-coverage" diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index eb903f4..717accb 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -206,7 +206,7 @@ public function proxy( */ public function proxyValidate( Request $request, - #[MapQueryParameter] string $TARGET = null, + #[MapQueryParameter] ?string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 05bbda6..5e7f3ec 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -76,7 +76,7 @@ class LoginController * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param Utils\HTTP|null $httpUtils + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ @@ -89,28 +89,12 @@ public function __construct( ) { $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) ? Configuration::getConfig('module_casserver.php') : $casConfig; - - $this->cas20Protocol = new Cas20($this->casConfig); - $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); - $this->httpUtils = $httpUtils ?? new Utils\HTTP(); - - $this->serviceValidator = new ServiceValidator($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); // Saml Validate Responsder $this->samlValidateResponder = new SamlValidateResponder(); + // Service Validator needs the generic casserver configuration. We do not need + $this->serviceValidator = new ServiceValidator($this->casConfig); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); } /** @@ -148,6 +132,7 @@ public function login( // Set initial configurations, or fail $this->handleServiceConfiguration($serviceUrl); + $this->intantiateClassDependencies($this->authSource, $this->httpUtils); $this->handleScope($scope); $this->handleLanguage($language); @@ -427,4 +412,31 @@ public function getTicketStore(): TicketStore { return $this->ticketStore; } + + /** + * @param Simple|null $source + * @param Utils\HTTP|null $httpUtils + * + * @return void + * @throws \Exception + */ + private function intantiateClassDependencies(Simple $source = null, Utils\HTTP $httpUtils = null): 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); + } } From 8c9a76428d4a0d07d92105ee6890c8bb349d25ae Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 11:53:15 +0200 Subject: [PATCH 35/48] fix UrlTrait::validate to handle proxy tickets --- src/Controller/Cas20Controller.php | 27 ++-- src/Controller/Cas30Controller.php | 5 +- src/Controller/LoginController.php | 7 +- src/Controller/Traits/UrlTrait.php | 41 ++++-- tests/src/Controller/Cas20ControllerTest.php | 130 ++++++++++++++++++- 5 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 717accb..3946080 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -76,13 +76,13 @@ public function __construct( /** * @param Request $request - * @param string $TARGET - * @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 + * @param string|null $TARGET + * @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 */ @@ -195,13 +195,14 @@ public function proxy( /** * @param Request $request - * @param string $TARGET // todo: this should go away??? - * @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 $TARGET + * @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 + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback + * * @return XmlResponse */ public function proxyValidate( diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index d71ee46..874dc15 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -37,8 +37,9 @@ class Cas30Controller protected SamlValidateResponder $validateResponder; /** - * @param Configuration $sspConfig - * @param Configuration|null $casConfig + * @param Configuration $sspConfig + * @param Configuration|null $casConfig + * @param TicketValidator|null $ticketValidator * * @throws \Exception */ diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 5e7f3ec..58d0ac9 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -132,7 +132,7 @@ public function login( // Set initial configurations, or fail $this->handleServiceConfiguration($serviceUrl); - $this->intantiateClassDependencies($this->authSource, $this->httpUtils); + $this->instantiateClassDependencies(); $this->handleScope($scope); $this->handleLanguage($language); @@ -414,13 +414,10 @@ public function getTicketStore(): TicketStore } /** - * @param Simple|null $source - * @param Utils\HTTP|null $httpUtils - * * @return void * @throws \Exception */ - private function intantiateClassDependencies(Simple $source = null, Utils\HTTP $httpUtils = null): void + private function instantiateClassDependencies(): void { $this->cas20Protocol = new Cas20($this->casConfig); diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index be3ef99..761ab47 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -105,7 +105,7 @@ public function validate( // Check if any of the required query parameters are missing if ($serviceUrl === null || $ticket === null) { $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; - $message = "casserver: Missing service parameter: [{$messagePostfix}]"; + $message = "casserver: Missing {$messagePostfix} parameter: [{$messagePostfix}]"; Logger::debug($message); return new XmlResponse( @@ -117,10 +117,8 @@ public function validate( try { // Get the service ticket // `getTicket` uses the unserializable method and Objects may throw Throwables in their - // unserialization handlers. + // un-serialization handlers. $serviceTicket = $this->ticketStore->getTicket($ticket); - // Delete the ticket - $this->ticketStore->deleteTicket($ticket); } catch (\Exception $e) { $messagePostfix = ''; if (!empty($e->getMessage())) { @@ -137,19 +135,40 @@ public function validate( $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 === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { - $message = 'Ticket ' . var_export($ticket, true) . - ' is a proxy ticket. Use proxyValidate instead.'; + } 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 (!$this->ticketFactory->isServiceTicket($serviceTicket)) { - // This is not a service ticket - $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket'; + } 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; - } elseif ($this->ticketFactory->isExpired($serviceTicket)) { + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($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; diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 3cebcc1..046cfdb 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -38,6 +38,7 @@ class Cas20ControllerTest extends TestCase private Utils\HTTP|MockObject $utilsHttpMock; private array $ticket; + private array $proxyTicket; /** * @throws \Exception @@ -89,6 +90,25 @@ protected function setUp(): void ], '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 function testProxyValidatePassesTheCorrectMethodToValidate(): void @@ -333,11 +353,11 @@ public static function validateFailsForEmptyServiceTicket(): array ], 'Ticket is empty but Service is not' => [ ['service' => 'http://localhost'], - 'casserver: Missing service parameter: [ticket]', + 'casserver: Missing ticket parameter: [ticket]', ], 'Ticket is empty but TARGET is not' => [ ['TARGET' => 'http://localhost'], - 'casserver: Missing service parameter: [ticket]', + 'casserver: Missing ticket parameter: [ticket]', ], ]; } @@ -407,7 +427,7 @@ public static function validateOnDifferentQueryParameterCombinations(): array { $sessionId = session_create_id(); return [ - 'Returns Bad Request on Ticket Not Recogniged/Exists' => [ + 'Returns Bad Request on Ticket Not Recognised/Exists' => [ [ 'ticket' => $sessionId, 'service' => 'https://myservice.com/abcd', @@ -420,7 +440,7 @@ public static function validateOnDifferentQueryParameterCombinations(): array 'ticket' => 'PT-' . $sessionId, 'service' => 'https://myservice.com/abcd', ], - "Ticket 'PT-{$sessionId}' is a proxy ticket. Use proxyValidate instead.", + "Ticket 'PT-{$sessionId}' is not a service ticket.", 'PT-' . $sessionId, ], 'Returns Bad Request on Ticket Expired' => [ @@ -445,7 +465,7 @@ public static function validateOnDifferentQueryParameterCombinations(): array 'service' => 'https://myservice.com/abcd', 'renew' => true, ], - "Ticket was issued from single sign on session", + 'Ticket was issued from single sign on session', 'ST-' . $sessionId, 9999999999, ], @@ -510,6 +530,106 @@ public function testServiceValidate( } } + + 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); From 7a8e7bb7951de1513528ba0cc12381ddb24b56ef Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 12:04:54 +0200 Subject: [PATCH 36/48] fix UrlTrait::validate call test for the different methods. --- tests/src/Controller/Cas20ControllerTest.php | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 046cfdb..f7ff434 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -111,13 +111,28 @@ protected function setUp(): void ]; } - public function testProxyValidatePassesTheCorrectMethodToValidate(): void + 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' => 'ST-' . $this->sessionId, + 'ticket' => $prefix . $this->sessionId, ]; $request = Request::create( @@ -127,10 +142,10 @@ public function testProxyValidatePassesTheCorrectMethodToValidate(): void $expectedArguments = [ 'request' => $request, - 'method' => 'proxyValidate', + 'method' => $method, 'renew' => false, 'target' => null, - 'ticket' => 'ST-' . $this->sessionId, + 'ticket' => $prefix . $this->sessionId, 'service' => 'https://myservice.com/abcd', 'pgtUrl' => null, ]; @@ -142,7 +157,7 @@ public function testProxyValidatePassesTheCorrectMethodToValidate(): void $controllerMock->expects($this->once())->method('validate') ->with(...$expectedArguments); - $controllerMock->proxyValidate($request, ...$requestParameters); + $controllerMock->$method($request, ...$requestParameters); } public static function queryParameterValues(): array From fb6664862ff43cc6cd0818b5aa5629e026c3da5b Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 14:07:43 +0200 Subject: [PATCH 37/48] user RunnableResponse --- config/module_casserver.php.dist | 3 +- src/Controller/Cas20Controller.php | 4 +- src/Controller/LoginController.php | 43 +++++++++---------- src/Controller/LogoutController.php | 12 +++--- tests/src/Controller/LoginControllerTest.php | 32 +++++++------- tests/src/Controller/LogoutControllerTest.php | 15 +++++-- 6 files changed, 60 insertions(+), 49 deletions(-) diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 9cfb61c..4dcc3d7 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -26,10 +26,11 @@ $config = [ 'https://host2.domain:5678/path2/path3', // So is regex '|^https://.*\.domain.com/|', - // Some configuration options can be overridden + // The FOLLOWING configuration options can be overridden 'https://override.example.com' => [ 'attrname' => 'uid', 'attributes_to_transfer' => ['cn'], + //'authproc' => [] ], ], diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 3946080..b6564d8 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -76,7 +76,7 @@ public function __construct( /** * @param Request $request - * @param string|null $TARGET + * @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. @@ -195,7 +195,7 @@ public function proxy( /** * @param Request $request - * @param string|null $TARGET + * @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. diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 58d0ac9..0bd1ecc 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -7,6 +7,7 @@ use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\AttributeExtractor; @@ -20,7 +21,6 @@ use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; use SimpleSAML\Utils; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -103,16 +103,14 @@ public function __construct( * @param bool $renew * @param bool $gateway * @param string|null $service - * @param string|null $TARGET + * @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 RedirectResponse|XmlResponse|null - * @throws NoState - * @throws \Exception + * @return RunnableResponse */ public function login( Request $request, @@ -125,13 +123,15 @@ public function login( #[MapQueryParameter] ?string $entityId = null, #[MapQueryParameter] ?string $debugMode = null, #[MapQueryParameter] ?string $method = null, - ): RedirectResponse|XmlResponse|null { + ): RunnableResponse { $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); @@ -177,9 +177,10 @@ public function login( /* * REDIRECT TO AUTHSOURCE LOGIN * */ - $this->authSource->login($params); - // We should never get here.This is to facilitate testing. - return null; + return new RunnableResponse( + [$this->authSource, 'login'], + [$params], + ); } // We are Authenticated. @@ -195,13 +196,11 @@ public function login( * We are done. REDIRECT TO LOGGEDIN * */ if (!isset($serviceUrl) && $this->authProcId === null) { - $urlParameters = $this->httpUtils->addURLParameters( - Module::getModuleURL('casserver/loggedIn'), - $this->postAuthUrlParameters, + $loggedInUrl = Module::getModuleURL('casserver/loggedIn'); + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$loggedInUrl, $this->postAuthUrlParameters], ); - $this->httpUtils->redirectTrustedURL($urlParameters); - // We should never get here.This is to facilitate testing. - return null; } // Get the state. @@ -232,16 +231,16 @@ public function login( // GET if ($redirect) { - $this->httpUtils->redirectTrustedURL( - $this->httpUtils->addURLParameters($serviceUrl, $this->postAuthUrlParameters), + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$serviceUrl, $this->postAuthUrlParameters], ); - // We should never get here.This is to facilitate testing. - return null; } // POST - $this->httpUtils->submitPOSTData($serviceUrl, $this->postAuthUrlParameters); - // We should never get here.This is to facilitate testing. - return null; + return new RunnableResponse( + [$this->httpUtils, 'submitPOSTData'], + [$serviceUrl, $this->postAuthUrlParameters], + ); } /** diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 1ee868d..c0922a2 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -81,12 +81,12 @@ public function __construct( * @param Request $request * @param string|null $url * - * @return RunnableResponse|null + * @return RunnableResponse */ public function logout( Request $request, #[MapQueryParameter] ?string $url = null, - ): RunnableResponse|null { + ): RunnableResponse { if (!$this->casConfig->getOptionalValue('enable_logout', false)) { $this->handleExceptionThrown('Logout not allowed'); } @@ -120,10 +120,10 @@ public function logout( } // Logout and redirect - $this->authSource->logout($logoutRedirectUrl); - - // We should never get here - return null; + return new RunnableResponse( + [$this->authSource, 'logout'], + [$logoutRedirectUrl], + ); } /** diff --git a/tests/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php index 5216815..96a36e7 100644 --- a/tests/src/Controller/LoginControllerTest.php +++ b/tests/src/Controller/LoginControllerTest.php @@ -10,6 +10,7 @@ use SimpleSAML\Auth\Simple; use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LoginController; use SimpleSAML\Session; @@ -209,11 +210,13 @@ public function testAuthSourceLogin(array $requestParameters, array $loginParame ->getMock(); $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->authSimpleMock->expects($this->once())->method('login')->with($loginParameters); $sessionId = session_create_id(); $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); - $controllerMock->login($loginRequest, ...$requestParameters); + $response = $controllerMock->login($loginRequest, ...$requestParameters); + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('login', $callable[1] ?? ''); } /** @@ -233,14 +236,16 @@ public function testIsAuthenticatedRedirectsToLoggedIn(): void $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); - $this->httpUtils->expects($this->once())->method('redirectTrustedURL') - ->with('http://localhost/module.php/casserver/loggedIn?'); + $loginRequest = Request::create( uri: Module::getModuleURL('casserver/login'), parameters: [], ); - $controllerMock->login($loginRequest); + $response = $controllerMock->login($loginRequest); + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); } public static function validServiceUrlProvider(): array @@ -300,15 +305,6 @@ public function testValidServiceUrl(string $serviceParam, string $redirectURL, b $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); $this->authSimpleMock->expects($this->any())->method('isAuthenticated')->willReturn(true); - $this->httpUtils->expects($this->once())->method('redirectTrustedURL') - ->withAnyParameters() - ->willReturnCallback(function ($url) use ($redirectURL) { - $this->assertStringStartsWith( - $redirectURL, - $url, - 'Ticket should be part of the redirect.', - ); - }); $queryParameters = [$serviceParam => 'https://example.com/ssp/module.php/cas/linkback.php']; $loginRequest = Request::create( uri: Module::getModuleURL('casserver/login'), @@ -316,6 +312,12 @@ public function testValidServiceUrl(string $serviceParam, string $redirectURL, b ); /** @psalm-suppress InvalidArgument */ - $controllerMock->login($loginRequest, ...$queryParameters); + $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 index 6df16f6..60d6490 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LogoutController; use SimpleSAML\Session; @@ -160,11 +161,19 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); - $this->authSimpleMock->expects($this->once())->method('logout') - ->with('http://localhost/module.php/casserver/loggedOut'); $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); - $controller->logout(Request::create('/')); + $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 From 7215414d0649ef491e1579dbc16e6910c5062ab4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 17:57:31 +0200 Subject: [PATCH 38/48] preg_match better error handling --- src/Cas/ServiceValidator.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index ed7c79e..cb968ca 100644 --- a/src/Cas/ServiceValidator.php +++ b/src/Cas/ServiceValidator.php @@ -72,6 +72,9 @@ public function checkServiceURL(string $service): ?Configuration } $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()); From 4484886a83ff8e1f3c8f1d3a0d365ffd268b577d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 21:39:40 +0200 Subject: [PATCH 39/48] Use DOMDocumentFactory instead of ext-xml to parse the SOAP message --- composer.json | 6 +++--- src/Controller/Cas30Controller.php | 23 ++++++++++++----------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index db8476b..9253f1d 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "config": { "preferred-install": { "simplesamlphp/simplesamlphp": "source", - "*": "source" + "*": "dist" }, "allow-plugins": { "composer/package-versions-deprecated": true, @@ -36,11 +36,11 @@ "ext-SimpleXML": "*", "ext-pdo": "*", "ext-session": "*", - "ext-xml": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/simplesamlphp": "^2.2", + "simplesamlphp/saml2": "^4.6", + "simplesamlphp/simplesamlphp": "^2.3", "simplesamlphp/xml-cas": "^1.3", "simplesamlphp/xml-common": "^1.17", "simplesamlphp/xml-soap": "^1.5", diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index 874dc15..e0ba08e 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Module\casserver\Controller; +use DOMXPath; +use SAML2\DOMDocumentFactory; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; @@ -89,28 +91,27 @@ public function samlValidate( // - IssueInstant [REQUIRED] - timestamp of the request // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service - $ticketParser = xml_parser_create(); - xml_parser_set_option($ticketParser, XML_OPTION_CASE_FOLDING, 0); - xml_parser_set_option($ticketParser, XML_OPTION_SKIP_WHITE, 1); - xml_parse_into_struct($ticketParser, $postBody, $values, $tags); - xml_parser_free($ticketParser); + $documentBody = DOMDocumentFactory::fromString($postBody); + $xPath = new DOMXpath($documentBody); + $xPath->registerNamespace('soap-env', 'http://schemas.xmlsoap.org/soap/envelope/'); + $samlRequestAttributes = $xPath->query('/soap-env:Envelope/soap-env:Body/*'); // Check for the required saml attributes - $samlRequestAttributes = $values[ $tags['samlp:Request'][0] ]['attributes']; - if (!isset($samlRequestAttributes['RequestID'])) { + if (!$samlRequestAttributes->item(0)->hasAttribute('RequestID')) { throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); - } elseif (!isset($samlRequestAttributes['IssueInstant'])) { + } elseif (!$samlRequestAttributes->item(0)->hasAttribute('IssueInstant')) { throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); } + $assertionArtifactNode = $samlRequestAttributes->item(0)->getElementsByTagName('AssertionArtifact'); if ( - !isset($tags['samlp:AssertionArtifact']) - || empty($values[$tags['samlp:AssertionArtifact'][0]]['value']) + $assertionArtifactNode->count() === 0 + || empty($assertionArtifactNode->item(0)->nodeValue) ) { throw new \RuntimeException('Missing ticketId in AssertionArtifact'); } - $ticketId = $values[$tags['samlp:AssertionArtifact'][0]]['value']; + $ticketId = $assertionArtifactNode->item(0)->nodeValue; Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { From 38f97d55faf85aedf1ece08e74cc73bfc7fd019c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 3 Jan 2025 14:56:48 +0200 Subject: [PATCH 40/48] Refactor samlValidate, parsing postbody. --- composer.json | 1 - src/Controller/Cas30Controller.php | 42 ++++++++++++++++-------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/composer.json b/composer.json index 9253f1d..bb0f1fa 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,6 @@ "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/saml2": "^4.6", "simplesamlphp/simplesamlphp": "^2.3", "simplesamlphp/xml-cas": "^1.3", "simplesamlphp/xml-common": "^1.17", diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index e0ba08e..4575075 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -4,8 +4,6 @@ namespace SimpleSAML\Module\casserver\Controller; -use DOMXPath; -use SAML2\DOMDocumentFactory; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; @@ -13,6 +11,8 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\SOAP\XML\env_200106\Envelope; +use SimpleSAML\XML\DOMDocumentFactory; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -92,26 +92,28 @@ public function samlValidate( // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service $documentBody = DOMDocumentFactory::fromString($postBody); - $xPath = new DOMXpath($documentBody); - $xPath->registerNamespace('soap-env', 'http://schemas.xmlsoap.org/soap/envelope/'); - $samlRequestAttributes = $xPath->query('/soap-env:Envelope/soap-env:Body/*'); - - // Check for the required saml attributes - if (!$samlRequestAttributes->item(0)->hasAttribute('RequestID')) { - throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); - } elseif (!$samlRequestAttributes->item(0)->hasAttribute('IssueInstant')) { - throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); + $envelope = Envelope::fromXML($documentBody->documentElement); + foreach ($envelope->getBody()->getElements() as $element) { + $samlpRequestXMLElement = $element->getXML(); + // Check for the required saml attributes + if ($samlpRequestXMLElement->nodeName !== 'samlp:Request') { + throw new \RuntimeException('Missing samlp:Request node.'); + } elseif (!$samlpRequestXMLElement->hasAttribute('RequestID')) { + throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); + } elseif (!$samlpRequestXMLElement->hasAttribute('IssueInstant')) { + throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); + } + // Assertion Artifact Element + $assertionArtifactNode = $samlpRequestXMLElement->firstElementChild; + if ( + $assertionArtifactNode->nodeName !== 'samlp:AssertionArtifact' + || empty($assertionArtifactNode->nodeValue) + ) { + throw new \RuntimeException('Missing ticketId in AssertionArtifact'); + } } - $assertionArtifactNode = $samlRequestAttributes->item(0)->getElementsByTagName('AssertionArtifact'); - if ( - $assertionArtifactNode->count() === 0 - || empty($assertionArtifactNode->item(0)->nodeValue) - ) { - throw new \RuntimeException('Missing ticketId in AssertionArtifact'); - } - - $ticketId = $assertionArtifactNode->item(0)->nodeValue; + $ticketId = $assertionArtifactNode?->nodeValue ?? ''; Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { From de620701eb46233b55ca4a43a178a2b74173f9c4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sat, 4 Jan 2025 18:34:17 +0200 Subject: [PATCH 41/48] Improve saml validate --- composer.json | 3 ++- src/Controller/Cas30Controller.php | 22 +++++++------------- tests/src/Controller/Cas30ControllerTest.php | 9 ++++---- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index bb0f1fa..400bdac 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "simplesamlphp/xml-common": "^1.17", "simplesamlphp/xml-soap": "^1.5", "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.4" + "symfony/http-kernel": "^6.4", + "simplesamlphp/saml11": "^1.2" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index 4575075..9b4b1f5 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\SAML11\XML\samlp\Request as SamlRequest; use SimpleSAML\SOAP\XML\env_200106\Envelope; use SimpleSAML\XML\DOMDocumentFactory; use Symfony\Component\HttpFoundation\Request; @@ -94,26 +95,17 @@ public function samlValidate( $documentBody = DOMDocumentFactory::fromString($postBody); $envelope = Envelope::fromXML($documentBody->documentElement); foreach ($envelope->getBody()->getElements() as $element) { - $samlpRequestXMLElement = $element->getXML(); - // Check for the required saml attributes - if ($samlpRequestXMLElement->nodeName !== 'samlp:Request') { - throw new \RuntimeException('Missing samlp:Request node.'); - } elseif (!$samlpRequestXMLElement->hasAttribute('RequestID')) { - throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); - } elseif (!$samlpRequestXMLElement->hasAttribute('IssueInstant')) { - throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); - } + // Request Element + $samlpRequestParsed = SamlRequest::fromXML($element->getXML()); + // Assertion Artifact Element - $assertionArtifactNode = $samlpRequestXMLElement->firstElementChild; - if ( - $assertionArtifactNode->nodeName !== 'samlp:AssertionArtifact' - || empty($assertionArtifactNode->nodeValue) - ) { + $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0]; + if (empty($assertionArtifactParsed->getContent())) { throw new \RuntimeException('Missing ticketId in AssertionArtifact'); } } - $ticketId = $assertionArtifactNode?->nodeValue ?? ''; + $ticketId = $assertionArtifactParsed?->getContent() ?? ''; Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index ba584a8..1d4f96c 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Session; +use SimpleSAML\XML\Exception\MissingAttributeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -146,8 +147,8 @@ public function testSoapBodyMissingRequestIdAttribute(): void ); // Exception expected - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Missing RequestID samlp:Request attribute.'); + $this->expectException(MissingAttributeException::class); + $this->expectExceptionMessage("Missing 'RequestID' attribute on samlp:Request."); $cas30Controller->samlValidate($this->samlValidateRequest, $target); } @@ -189,8 +190,8 @@ public function testSoapBodyMissingIssueInstantAttribute(): void ); // Exception expected - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Missing IssueInstant samlp:Request attribute.'); + $this->expectException(MissingAttributeException::class); + $this->expectExceptionMessage("Missing 'IssueInstant' attribute on samlp:Request."); $cas30Controller->samlValidate($this->samlValidateRequest, $target); } From 3d4d2ef84d456720bff0dac7aad09ac8b732e6ac Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 5 Jan 2025 08:00:17 +0200 Subject: [PATCH 42/48] Improve samlValidate. Use saml11 1,2,4 --- src/Controller/Cas30Controller.php | 26 +++-- tests/src/Controller/Cas30ControllerTest.php | 109 ++++++------------- 2 files changed, 49 insertions(+), 86 deletions(-) diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index 9b4b1f5..b02ece7 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\SAML11\Exception\ProtocolViolationException; use SimpleSAML\SAML11\XML\samlp\Request as SamlRequest; use SimpleSAML\SOAP\XML\env_200106\Envelope; use SimpleSAML\XML\DOMDocumentFactory; @@ -71,7 +72,9 @@ public function __construct( * @param Request $request * @param string $TARGET URL encoded service identifier of the back-end service. * - * @throws \RuntimeException + * @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 */ @@ -94,18 +97,19 @@ public function samlValidate( $documentBody = DOMDocumentFactory::fromString($postBody); $envelope = Envelope::fromXML($documentBody->documentElement); - foreach ($envelope->getBody()->getElements() as $element) { - // Request Element - $samlpRequestParsed = SamlRequest::fromXML($element->getXML()); - - // Assertion Artifact Element - $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0]; - if (empty($assertionArtifactParsed->getContent())) { - throw new \RuntimeException('Missing ticketId in AssertionArtifact'); - } + + // 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.'); } - $ticketId = $assertionArtifactParsed?->getContent() ?? ''; + // 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 { diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index 1d4f96c..d1518aa 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module; @@ -11,7 +12,6 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Session; -use SimpleSAML\XML\Exception\MissingAttributeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -110,14 +110,11 @@ public function testNoSoapBody(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } - /** - * @return void - * @throws \Exception - */ - public function testSoapBodyMissingRequestIdAttribute(): void + public static function soapEnvelopes(): array { - $casconfig = Configuration::loadFromArray($this->moduleConfig); - $samlRequest = << [ + << @@ -129,38 +126,12 @@ public function testSoapBodyMissingRequestIdAttribute(): void -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, - ); - - $cas30Controller = new Cas30Controller( - $this->sspConfig, - $casconfig, - ); - - // Exception expected - $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage("Missing 'RequestID' attribute on samlp:Request."); - - $cas30Controller->samlValidate($this->samlValidateRequest, $target); - } - - /** - * @return void - * @throws \Exception - */ - public function testSoapBodyMissingIssueInstantAttribute(): void - { - $casconfig = Configuration::loadFromArray($this->moduleConfig); - $samlRequest = << [ + << @@ -172,38 +143,12 @@ public function testSoapBodyMissingIssueInstantAttribute(): void -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, - ); - - $cas30Controller = new Cas30Controller( - $this->sspConfig, - $casconfig, - ); - - // Exception expected - $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage("Missing 'IssueInstant' attribute on samlp:Request."); - - $cas30Controller->samlValidate($this->samlValidateRequest, $target); - } - - /** - * @return void - * @throws \Exception - */ - public function testSoapBodyMissingTicketId(): void - { - $casconfig = Configuration::loadFromArray($this->moduleConfig); - $samlRequest = << [ + << @@ -216,7 +161,21 @@ public function testSoapBodyMissingTicketId(): void -SOAP; +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' @@ -234,8 +193,8 @@ public function testSoapBodyMissingTicketId(): void ); // Exception expected - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Missing ticketId in AssertionArtifact'); + $this->expectException($exceptionClassName); + $this->expectExceptionMessage($exceptionMessage); $cas30Controller->samlValidate($this->samlValidateRequest, $target); } From b6591100b73293990474628dc09c91368a8d1054 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 14:40:38 +0200 Subject: [PATCH 43/48] Move Cas20 validate function to TicketValidatorTrait --- src/Controller/Cas20Controller.php | 2 + src/Controller/Cas30Controller.php | 1 - .../Traits/TicketValidatorTrait.php | 184 ++++++++++++++++++ src/Controller/Traits/UrlTrait.php | 174 ----------------- tests/src/Controller/Cas20ControllerTest.php | 2 +- 5 files changed, 187 insertions(+), 176 deletions(-) create mode 100644 src/Controller/Traits/TicketValidatorTrait.php diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index b6564d8..a0bbac4 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; +use SimpleSAML\Module\casserver\Controller\Traits\TicketValidatorTrait; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Utils; @@ -23,6 +24,7 @@ class Cas20Controller { use UrlTrait; + use TicketValidatorTrait; /** @var Logger */ protected Logger $logger; diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index b02ece7..6765e7a 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -82,7 +82,6 @@ public function samlValidate( Request $request, #[MapQueryParameter] string $TARGET, ): XmlResponse { - // From SAML2\SOAP::receive() $postBody = $request->getContent(); if (empty($postBody)) { throw new \RuntimeException('samlValidate expects a soap body.'); diff --git a/src/Controller/Traits/TicketValidatorTrait.php b/src/Controller/Traits/TicketValidatorTrait.php new file mode 100644 index 0000000..8bad39c --- /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($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($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($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 index 761ab47..217d5d8 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -4,14 +4,10 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; -use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; -use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\TicketValidator; -use SimpleSAML\Module\casserver\Http\XmlResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; trait UrlTrait { @@ -78,174 +74,4 @@ public function getRequestParam(Request $request, string $paramName): mixed { return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; } - - /** - * @param Request $request - * @param string $method - * @param bool $renew - * @param string|null $target - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl - * - * @return XmlResponse - */ - public function validate( - Request $request, - string $method, - bool $renew = false, - ?string $target = null, - ?string $ticket = null, - ?string $service = null, - ?string $pgtUrl = null, - ): XmlResponse { - $forceAuthn = $renew; - $serviceUrl = $service ?? $target ?? null; - - // Check if any of the required query parameters are missing - if ($serviceUrl === null || $ticket === null) { - $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; - $message = "casserver: Missing {$messagePostfix} parameter: [{$messagePostfix}]"; - Logger::debug($message); - - return new XmlResponse( - (string)$this->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($message); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $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($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($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/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index f7ff434..679c275 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -433,7 +433,7 @@ public function getTicket(string $ticketId): ?array $xml->registerXPathNamespace('cas', 'serviceResponse'); $this->assertEquals('serviceResponse', $xml->getName()); $this->assertEquals( - C::ERR_INVALID_SERVICE, + C::ERR_INTERNAL_ERROR, $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], ); } From d1478182b31564a3a05ba3716d127762785e180d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 15:23:42 +0200 Subject: [PATCH 44/48] Create an enum list of configuration options that are allowed to be overriden. --- src/Cas/ServiceValidator.php | 11 ++++++++++- src/Controller/LoginController.php | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index cb968ca..d3cb673 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 @@ -93,7 +94,15 @@ public function checkServiceURL(string $service): ?Configuration 'matchingUrl' => $legalUrl, 'serviceUrl' => $service, ]; - if ($configOverride) { + 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 Configuration::loadFromArray($serviceConfig); diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 0bd1ecc..d1a0ab3 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -91,7 +91,7 @@ public function __construct( ? Configuration::getConfig('module_casserver.php') : $casConfig; // Saml Validate Responsder $this->samlValidateResponder = new SamlValidateResponder(); - // Service Validator needs the generic casserver configuration. We do not need + // 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(); From 1a7e2dcfe6db08fc1e8c57124022839f5abf20f2 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 15:47:57 +0200 Subject: [PATCH 45/48] Add missing enum file --- composer.json | 2 +- config/module_casserver.php.dist | 5 +++-- src/Codebooks/OverrideConfigPropertiesEnum.php | 13 +++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 src/Codebooks/OverrideConfigPropertiesEnum.php diff --git a/composer.json b/composer.json index 400bdac..5e677e4 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ "validate": [ "vendor/bin/phpunit --no-coverage --testdox", "vendor/bin/phpcs -p", - "vendor/bin/composer-require-checker check composer.json", + "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" ], diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 4dcc3d7..6c9fd07 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -26,11 +26,12 @@ $config = [ 'https://host2.domain:5678/path2/path3', // So is regex '|^https://.*\.domain.com/|', - // The FOLLOWING configuration options can be overridden + // ONLY the FOLLOWING configuration options can be overridden 'https://override.example.com' => [ 'attrname' => 'uid', 'attributes_to_transfer' => ['cn'], - //'authproc' => [] + //'authproc' => [], + //'service_ticket_expire_time' => 5, ], ], diff --git a/src/Codebooks/OverrideConfigPropertiesEnum.php b/src/Codebooks/OverrideConfigPropertiesEnum.php new file mode 100644 index 0000000..f086811 --- /dev/null +++ b/src/Codebooks/OverrideConfigPropertiesEnum.php @@ -0,0 +1,13 @@ + Date: Thu, 9 Jan 2025 16:10:11 +0200 Subject: [PATCH 46/48] fix saml11 versioning in composer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5e677e4..132e7dd 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "simplesamlphp/xml-soap": "^1.5", "symfony/http-foundation": "^6.4", "symfony/http-kernel": "^6.4", - "simplesamlphp/saml11": "^1.2" + "simplesamlphp/saml11": "~1.2.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", From 8fb020d57a3c06f75ccde3b08e4e8d26ead8f7c7 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 16:25:55 +0200 Subject: [PATCH 47/48] minor fixes --- src/Controller/Cas20Controller.php | 8 +++++--- src/Controller/Traits/TicketValidatorTrait.php | 8 ++++---- src/Controller/Traits/UrlTrait.php | 9 +++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index a0bbac4..6b875ed 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -112,17 +112,19 @@ public function serviceValidate( * 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. + * @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) { diff --git a/src/Controller/Traits/TicketValidatorTrait.php b/src/Controller/Traits/TicketValidatorTrait.php index 8bad39c..f4d7709 100644 --- a/src/Controller/Traits/TicketValidatorTrait.php +++ b/src/Controller/Traits/TicketValidatorTrait.php @@ -49,7 +49,7 @@ public function validate( try { // Get the service ticket - // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // `getTicket` uses the unserializable method and Objects may throw "Throwables" in their // un-serialization handlers. $serviceTicket = $this->ticketStore->getTicket($ticket); } catch (\Exception $e) { @@ -58,7 +58,7 @@ public function validate( $messagePostfix = ': ' . var_export($e->getMessage(), true); } $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; - Logger::error($message); + Logger::error(__METHOD__ . '::' . $message); return new XmlResponse( (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $message), @@ -86,7 +86,7 @@ public function validate( if ($failed) { $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); + Logger::error(__METHOD__ . '::' . $finalMessage); return new XmlResponse( (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), @@ -119,7 +119,7 @@ public function validate( if ($failed) { $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); + Logger::error(__METHOD__ . '::' . $finalMessage); return new XmlResponse( (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 217d5d8..a2192f1 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -12,11 +12,12 @@ trait UrlTrait { /** - * @deprecated - * @see ServiceValidator - * @param string $service - * @param array $legal_service_urls + * @param string $service + * @param array $legal_service_urls + * * @return bool + * @throws \ErrorException + * @see ServiceValidator */ public function checkServiceURL(string $service, array $legal_service_urls): bool { From 416f2b1d69ac728d7c1b1cc3fe40a4553e7dd70a Mon Sep 17 00:00:00 2001 From: Tim van Dijen Date: Thu, 9 Jan 2025 18:28:57 +0100 Subject: [PATCH 48/48] Remove PDO as a hard dependency. It's optional when using SQLTicketStore --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 132e7dd..dbc25e0 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,6 @@ "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", - "ext-pdo": "*", "ext-session": "*", "simplesamlphp/assert": "^1.1",