diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 7e690160..25e50d47 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -115,8 +115,11 @@ $config = [ 'service_ticket_expire_time' => 5, // how many seconds proxy granting tickets are valid for at most, defaults to 3600 'proxy_granting_ticket_expire_time' => 600, - //how many seconds proxy tickets are valid for, defaults to 5 + // how many seconds proxy tickets are valid for, defaults to 5 'proxy_ticket_expire_time' => 5, + // OPTIONAL, if `gateway=true` is requested and user has no session, invoke the authsource with `isPassive=true`. + // defaults to false + //'enable_passive_mode' => true, // If query param debugMode=true is sent to the login endpoint then print cas ticket xml. Default false 'debugMode' => true, diff --git a/docker/ssp/authsources.php b/docker/ssp/authsources.php index 134815f8..64dea51a 100644 --- a/docker/ssp/authsources.php +++ b/docker/ssp/authsources.php @@ -14,6 +14,7 @@ 'users' => [ 'student:studentpass' => [ 'uid' => ['student'], + 'cn' => ['Firsty Lasty'], 'eduPersonAffiliation' => ['member', 'student'], 'eduPersonNickname' => 'Sir_Nickname', 'displayName' => 'Some User', diff --git a/docker/ssp/module_casserver.php b/docker/ssp/module_casserver.php index 4f5867d5..290cb4b0 100644 --- a/docker/ssp/module_casserver.php +++ b/docker/ssp/module_casserver.php @@ -115,7 +115,7 @@ url query parameter to CAS logout mandatory for obvious reasons.*/ // how many seconds service tickets are valid for, defaults to 5 - 'service_ticket_expire_time' => 5, + 'service_ticket_expire_time' => 60, // how many seconds proxy granting tickets are valid for at most, defaults to 3600 'proxy_granting_ticket_expire_time' => 600, //how many seconds proxy tickets are valid for, defaults to 5 diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 91e3268c..a16562ec 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -164,34 +164,31 @@ public function login( // This will be used to come back from the AuthSource login or from the Processing Chain $returnToUrl = $this->getReturnUrl($request, $sessionTicket); - // Authenticate + // renew=true and gateway=true are incompatible → prefer interactive login (disable passive) + if ($gateway && $forceAuthn) { + $gateway = false; + } + + // Handle passive authentication if service url defined + // Protocol (gateway set): CAS MUST NOT prompt for credentials during this branch. + if ($serviceUrl && $gateway && !$this->authSource->isAuthenticated() && !$requestForceAuthenticate) { + return $this->handleUnauthenticatedGateway( + $serviceUrl, + $entityId, + $returnToUrl, + ); + } + + // Handle interactive authentication + // Protocol: Normal interactive authentication flow (applies when gateway is not in effect). + // Renew semantics: when renew=true, server MUST enforce re-authentication (no SSO reuse). if ( $requestForceAuthenticate || !$this->authSource->isAuthenticated() ) { - $params = [ - 'ForceAuthn' => $forceAuthn, - 'isPassive' => $gateway, - 'ReturnTo' => $returnToUrl, - ]; - - if (isset($entityId)) { - $params['saml:idp'] = $entityId; - } - - if (isset($this->idpList)) { - if (count($this->idpList) > 1) { - $params['saml:IDPList'] = $this->idpList; - } else { - $params['saml:idp'] = $this->idpList[0]; - } - } - - /* - * REDIRECT TO AUTHSOURCE LOGIN - * */ - return new RunnableResponse( - [$this->authSource, 'login'], - [$params], + return $this->handleInteractiveAuthenticate( + forceAuthn: $forceAuthn, + returnToUrl: $returnToUrl, + entityId: $entityId, ); } @@ -204,9 +201,8 @@ public function login( $this->ticketStore->addTicket($sessionTicket); } - /* - * We are done. REDIRECT TO LOGGEDIN - * */ + /* We are done. REDIRECT TO LOGGEDIN */ + if (!isset($serviceUrl) && $this->authProcId === null) { $loggedInUrl = Module::getModuleURL('casserver/loggedIn'); return new RunnableResponse( @@ -251,6 +247,7 @@ public function login( return $t; } + // User has SSO or non-interactive auth succeeded → redirect/POST to service WITH a ticket $ticketName = $this->calculateTicketName($service); $this->postAuthUrlParameters[$ticketName] = $serviceTicket['id']; @@ -464,4 +461,99 @@ private function instantiateClassDependencies(): void // Attribute Extractor $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory); } + + /** + * Trigger interactive authentication via the AuthSource. + * + * @param bool $forceAuthn + * @param string $returnToUrl + * @param string|null $entityId + * + * @return RunnableResponse + */ + private function handleInteractiveAuthenticate( + bool $forceAuthn, + string $returnToUrl, + ?string $entityId, + ): RunnableResponse { + return $this->handleAuthenticate( + forceAuthn: $forceAuthn, + gateway: false, + returnToUrl: $returnToUrl, + entityId: $entityId, + ); + } + + /** + * Handle the gateway flow when the user is NOT authenticated. + * Passive mode is only attempted if 'enable_passive_mode' is enabled in configuration. + * + * Returns: RunnableResponse|null + * - RunnableResponse for either a passive attempt or a redirect to service without ticket. + * - null to indicate: proceed with interactive login (non-passive). + */ + private function handleUnauthenticatedGateway( + string $serviceUrl, + ?string $entityId, + string $returnToUrl, + ): RunnableResponse { + $passiveAllowed = $this->casConfig->getOptionalBoolean('enable_passive_mode', false); + + // Passive mode is not enabled by configuration + // CAS MUST redirect to the service URL WITHOUT a ticket parameter. + if (!$passiveAllowed) { + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$serviceUrl, []], + ); + } + + // Passive mode enabled: attempt a passive (non-interactive) authentication. + return $this->handleAuthenticate( + forceAuthn: false, + gateway: true, + returnToUrl: $returnToUrl, + entityId: $entityId, + ); + } + + /** + * Handle authentication request by configuring parameters and triggering login via auth source. + * + * @param bool $forceAuthn Whether to force authentication regardless of existing session + * @param bool $gateway Whether authentication should be passive/non-interactive + * @param string $returnToUrl URL to return to after authentication + * @param string|null $entityId Optional specific IdP entity ID to use + * + * @return RunnableResponse Response containing the login redirect + */ + private function handleAuthenticate( + bool $forceAuthn, + bool $gateway, + string $returnToUrl, + ?string $entityId, + ): RunnableResponse { + $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]; + } + } + + return new RunnableResponse( + [$this->authSource, 'login'], + [$params], + ); + } } diff --git a/tests/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php index ba6b7a61..a9f6a60e 100644 --- a/tests/src/Controller/LoginControllerTest.php +++ b/tests/src/Controller/LoginControllerTest.php @@ -215,8 +215,15 @@ public function testAuthSourceLogin(array $requestParameters, array $loginParame $response = $controllerMock->login($loginRequest, ...$requestParameters); $this->assertInstanceOf(RunnableResponse::class, $response); + + // Assert we call into authSource->login $callable = (array)$response->getCallable(); $this->assertEquals('login', $callable[1] ?? ''); + + // Assert the interactive authenticate parameters (entityId/idpList logic) + $arguments = $response->getArguments(); + $actualLoginParams = $arguments[0] ?? []; + $this->assertEquals($loginParameters, $actualLoginParams); } /** @@ -311,6 +318,7 @@ public function testValidServiceUrl(string $serviceParam, string $redirectURL, b parameters: $queryParameters, ); + /** @psalm-suppress InvalidArgument */ $response = $controllerMock->login($loginRequest, ...$queryParameters); $this->assertInstanceOf(RunnableResponse::class, $response); $arguments = $response->getArguments(); @@ -319,4 +327,241 @@ public function testValidServiceUrl(string $serviceParam, string $redirectURL, b $callable = (array)$response->getCallable(); $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); } + + /** + * @return array + */ + public static function serviceUrlsProvider(): array + { + return [ + ['https://example.com/ssp/module.php/cas/linkback.php'], + ['https://example.com/ssp/module.php/cas/linkback.php?foo=1&bar=2'], + ]; + } + + /** + * When passive is disabled and a service is provided, CAS must redirect to the service without appending CAS params + * + * @dataProvider serviceUrlsProvider + */ + public function testGatewayPassiveDisabledRedirectsWithoutParams(string $serviceUrl): void + { + // enable_passive_mode disabled + $moduleConfig = $this->moduleConfig; + $moduleConfig['enable_passive_mode'] = false; + + // Ensure the exact service URL (including its query string, if any) is allowed + $moduleConfig['legal_service_urls'] = [$serviceUrl]; + + $casconfig = Configuration::loadFromArray($moduleConfig); + + $params = [ + 'service' => $serviceUrl, + 'gateway' => true, + ]; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $params, + ); + + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + // Unauthenticated so gateway path is exercised + $this->authSimpleMock->expects($this->atLeastOnce())->method('isAuthenticated')->willReturn(false); + + // Session used to build ReturnTo + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn(session_create_id()); + + // Execute + $response = $controllerMock->login($loginRequest, ...$params); + + // Validate redirect with original service URL and no CAS parameters appended + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); + + $arguments = $response->getArguments(); + $this->assertEquals($serviceUrl, $arguments[0]); + $this->assertSame([], $arguments[1] ?? []); + } + + public function testGatewayPassiveEnabledPerformsPassiveAttempt(): void + { + // enable_passive_mode enabled + $moduleConfig = $this->moduleConfig; + $moduleConfig['enable_passive_mode'] = true; + $casconfig = Configuration::loadFromArray($moduleConfig); + + $params = [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'gateway' => true, + ]; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $params, + ); + + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + // Unauthenticated so gateway path is exercised, first passive attempt + $this->authSimpleMock->expects($this->atLeastOnce())->method('isAuthenticated')->willReturn(false); + + // Session used to build ReturnTo + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn(session_create_id()); + + $response = $controllerMock->login($loginRequest, ...$params); + + $this->assertInstanceOf(RunnableResponse::class, $response); + + // Should attempt passive auth via authSource->login + $callable = (array)$response->getCallable(); + $this->assertEquals('login', $callable[1] ?? ''); + + // Verify isPassive=true, ForceAuthn=false + $arguments = $response->getArguments(); + $actualLoginParams = $arguments[0] ?? []; + $this->assertArrayHasKey('ForceAuthn', $actualLoginParams); + $this->assertFalse($actualLoginParams['ForceAuthn']); + $this->assertArrayHasKey('isPassive', $actualLoginParams); + $this->assertTrue($actualLoginParams['isPassive']); + $this->assertArrayHasKey('ReturnTo', $actualLoginParams); + $this->assertIsString($actualLoginParams['ReturnTo']); + } + + public function testGatewayNoServicePassiveDisabledFallsBackToInteractive(): void + { + // enable_passive_mode disabled + $moduleConfig = $this->moduleConfig; + $moduleConfig['enable_passive_mode'] = false; + $casconfig = Configuration::loadFromArray($moduleConfig); + + $params = [ + 'gateway' => true, // no 'service' provided + ]; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $params, + ); + + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $this->authSimpleMock->expects($this->atLeastOnce())->method('isAuthenticated')->willReturn(false); + + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn(session_create_id()); + + $response = $controllerMock->login($loginRequest, ...$params); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('login', $callable[1] ?? ''); + + // isPassive should be false because gateway is disabled when no service in this scenario + $arguments = $response->getArguments(); + $loginArgs = $arguments[0] ?? []; + $this->assertArrayHasKey('isPassive', $loginArgs); + $this->assertFalse($loginArgs['isPassive']); + } + + public function testRenewAndGatewayConflictDisablesPassive(): void + { + // enable_passive_mode enabled (doesn't matter because renew must disable passive) + $moduleConfig = $this->moduleConfig; + $moduleConfig['enable_passive_mode'] = true; + $casconfig = Configuration::loadFromArray($moduleConfig); + + $params = [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'gateway' => true, + 'renew' => true, + ]; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $params, + ); + + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $this->authSimpleMock->expects($this->atLeastOnce())->method('isAuthenticated')->willReturn(false); + + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn(session_create_id()); + + $response = $controllerMock->login($loginRequest, ...$params); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('login', $callable[1] ?? ''); + + // isPassive must be false when renew=true (gateway disabled) + $arguments = $response->getArguments(); + $loginArgs = $arguments[0] ?? []; + $this->assertArrayHasKey('isPassive', $loginArgs); + $this->assertFalse($loginArgs['isPassive']); + $this->assertArrayHasKey('ForceAuthn', $loginArgs); + $this->assertTrue($loginArgs['ForceAuthn']); + } + + public function testAuthenticatedPostSubmitsViaPostWithTicket(): void + { + $moduleConfig = $this->moduleConfig; + $casconfig = Configuration::loadFromArray($moduleConfig); + + $requestParams = [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'method' => 'POST', + ]; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $requestParams, + ); + + // Prepare controller with mocks + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $sessionId = session_create_id(); + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId); + + // Simulate authenticated state and required auth data + $this->authSimpleMock->expects($this->any())->method('isAuthenticated')->willReturn(true); + $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999); + $this->authSimpleMock->expects($this->once())->method('getAuthDataArray')->willReturn([ + 'Attributes' => [ + 'eduPersonPrincipalName' => ['testuser@example.com'], + 'Expire' => 9999999999, + ], + ]); + + /** @psalm-suppress InvalidArgument */ + $response = $controllerMock->login($loginRequest, ...$requestParams); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('submitPOSTData', $callable[1] ?? ''); + + $arguments = $response->getArguments(); + $this->assertEquals('https://example.com/ssp/module.php/cas/linkback.php', $arguments[0]); + $params = $arguments[1] ?? []; + // default ticket name for CAS is 'ticket' + $this->assertArrayHasKey('ticket', $params); + $this->assertStringStartsWith('ST-', $params['ticket']); + } }