From e1b3012e2a8522c4ae98151282d39b4cb7a35518 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 27 Nov 2024 19:52:17 +0200 Subject: [PATCH] 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',