From fbc1432a30b1ac76fe25027276c38f872677c149 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 7 Jan 2026 20:42:01 +0200 Subject: [PATCH] Upgrade ldap module to version 2.5. Updated documentation to match the new configuration. Added tests. Fixed the ldap integration code. --- composer.json | 2 +- docs/cas.md | 104 ++++++++++++++++++----- src/Auth/Source/CAS.php | 58 +++++++------ tests/config/authsources.php | 15 ++++ tests/src/Controller/CASTest.php | 125 ++++++++++++++++++++++++++++ tools/composer-require-checker.json | 6 +- 6 files changed, 260 insertions(+), 50 deletions(-) diff --git a/composer.json b/composer.json index f4ea349..af37cb4 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "simplesamlphp/assert": "^1.9", "simplesamlphp/composer-module-installer": "^1.5", "simplesamlphp/simplesamlphp": "^2.5@dev", - "simplesamlphp/simplesamlphp-module-ldap": "^1.2", + "simplesamlphp/simplesamlphp-module-ldap": "^v2.5.0", "simplesamlphp/xml-cas-module-slate": "^1.2", "simplesamlphp/xml-cas": "^2.3", "simplesamlphp/xml-common": "^2.5", diff --git a/docs/cas.md b/docs/cas.md index fdb280f..0b19966 100644 --- a/docs/cas.md +++ b/docs/cas.md @@ -1,11 +1,13 @@ # Using the CAS authentication source with SimpleSAMLphp -This is completely based on the original cas authentication, -the only difference is this is authentication module and not a script. +This is completely based on the original CAS authentication; +the only difference is this is an authentication module, not a script. ## Setting up the CAS authentication module -Adding an authentication source +### Adding an authentication source + +In new deployments using ldap v2.5+, configure LDAP as a separate authsource in the ldap module and reference it by id from CAS. Example authsource.php: @@ -13,18 +15,70 @@ Example authsource.php: 'example-cas' => [ 'cas:CAS', 'cas' => [ - 'login' => 'https://cas.example.com/login', - 'validate' => 'https://cas.example.com/validate', - 'logout' => 'https://cas.example.com/logout' + 'login' => 'https://cas.example.com/login', + 'validate' => 'https://cas.example.com/validate', // CAS v2 + 'logout' => 'https://cas.example.com/logout', + ], + 'ldap' => [ + 'authsource' => 'ldap-backend', + ], +], + +// LDAP authsource (dnpattern mode) +'ldap-backend' => [ + 'ldap:Ldap', + + // REQUIRED in v2.5: one or more LDAP URLs + 'connection_string' => 'ldaps://ldap.example.com', + + // Optional extras + 'encryption' => 'ssl', + 'version' => 3, + 'options' => [ + 'network_timeout' => 3, + 'referrals' => false, + ], + + // Dnpattern mode (no search) + 'dnpattern' => 'uid=%username%,cn=people,dc=example,dc=com', + 'search.enable' => false, + + // 'attributes' => ['uid', 'cn', 'mail'], +] +``` + +OR: + +```php +'example-cas' => [ + 'cas:CAS', + 'cas' => [ + 'login' => 'https://cas.example.com/login', + 'serviceValidate' => 'https://cas.example.com/serviceValidate', // CAS v3 + 'logout' => 'https://cas.example.com/logout', ], 'ldap' => [ - 'servers' => 'ldaps://ldaps.example.be:636/', - 'enable_tls' => true, - 'searchbase' => 'ou=people,dc=org,dc=com', - 'searchattributes' => 'uid', - 'attributes' => ['uid','cn'], - 'priv_user_dn' => 'cn=simplesamlphp,ou=applications,dc=org,dc=com', - 'priv_user_pw' => 'password', + 'authsource' => 'ldap-backend', + ], +], + +// LDAP authsource (search mode) +'ldap-backend' => [ + 'ldap:Ldap', + 'connection_string' => 'ldaps://ldap1.example.com ldaps://ldap2.example.com', + 'search' => [ + 'username' => 'cn=simplesamlphp,ou=apps,dc=example,dc=com', + 'password' => 'secret', + 'base' => ['ou=people,dc=example,dc=com'], + 'filter' => '(uid=%username%)', + 'scope' => 'sub', + ], + 'attributes' => ['*'], + 'attributes.binary' => ['jpegPhoto'], + 'timeout' => 3, + 'options' => [ + 'network_timeout' => 3, + 'referrals' => false, ], ], ``` @@ -39,7 +93,7 @@ To get them, call `serviceValidate`, either directly: ```php 'cas' => [ - 'serviceValidate' => 'https://cas.example.com/serviceValidate', + 'serviceValidate' => 'https://cas.example.com/serviceValidate', // CAS v3 ] ``` @@ -62,18 +116,18 @@ You can opt in to Slate support: 'serviceValidate' => 'https://cas.example.com/p3/serviceValidate', // Enable Slate support (optional) 'slate.enabled' => true, - + // Optional XPath-based attribute mappings 'attributes' => [ // Standard CAS attributes - 'uid' => 'cas:user', - 'mail' => 'cas:attributes/cas:mail', - + 'uid' => 'cas:user', + 'mail' => 'cas:attributes/cas:mail', + // Slate namespaced attributes inside cas:attributes 'slate_person' => 'cas:attributes/slate:person', 'slate_round' => 'cas:attributes/slate:round', 'slate_ref' => 'cas:attributes/slate:ref', - + // Some deployments also place vendor elements at the top level 'slate_person_top' => '/cas:serviceResponse/cas:authenticationSuccess/slate:person', ], @@ -105,10 +159,10 @@ for each value: ```php 'cas' => [ 'attributes' => [ - 'uid' => 'cas:user', - 'sn' => 'cas:attributes/cas:sn', + 'uid' => 'cas:user', + 'sn' => 'cas:attributes/cas:sn', 'givenName' => 'cas:attributes/cas:firstname', - 'mail' => 'cas:attributes/cas:mail', + 'mail' => 'cas:attributes/cas:mail', ], ], ``` @@ -131,3 +185,9 @@ set `ldap` to `null`: 'ldap' => null, ] ``` + +### Troubleshooting + +- Mismatch between validate (v2) and serviceValidate (v3): ensure you use the correct endpoint for your CAS server. +- Attribute mappings: verify XPath keys match your CAS response (case‑sensitive). +- LDAP connection issues: confirm connection_string, credentials, and base DN; consider increasing `network_timeout` while testing. diff --git a/src/Auth/Source/CAS.php b/src/Auth/Source/CAS.php index 68d1900..57e4673 100644 --- a/src/Auth/Source/CAS.php +++ b/src/Auth/Source/CAS.php @@ -15,7 +15,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module; -use SimpleSAML\Module\ldap\Auth\Ldap; +use SimpleSAML\Module\ldap\Auth\Source\Ldap; use SimpleSAML\Slate\XML\AuthenticationSuccess as SlateAuthnSuccess; use SimpleSAML\Slate\XML\ServiceResponse as SlateServiceResponse; use SimpleSAML\Utils; @@ -28,7 +28,6 @@ use function preg_split; use function strcmp; use function strval; -use function var_export; /** * Authenticate using CAS. @@ -240,13 +239,13 @@ private function casServiceValidate(string $ticket, string $service): array // array is empty or not set then an empty array will be returned. $attributesFromQueryConfiguration = $this->parseQueryAttributes($dom); if (!empty($attributesFromQueryConfiguration)) { - // Overwrite attributes from parseAuthenticationSuccess with configured - // XPath-based attributes, instead of combining them. + // Overwrite attributes from parseAuthenticationSuccess with configured + // XPath-based attributes, instead of combining them. foreach ($attributesFromQueryConfiguration as $name => $values) { - // Ensure a clean, unique list of string values + // Ensure a clean, unique list of string values $values = array_values(array_unique(array_map('strval', $values))); - // Configuration wins: replace any existing attribute with the same name + // Configuration wins: replace any existing attribute with the same name $attributes[$name] = $values; } } @@ -281,35 +280,46 @@ protected function casValidation(string $ticket, string $service): array /** * Called by linkback, to finish validate/ finish logging in. + * * @param array $state */ + public function finalStep(array &$state): void { $ticket = $state['cas:ticket']; $stateId = Auth\State::saveState($state, self::STAGE_INIT); $service = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]); - list($username, $casAttributes) = $this->casValidation($ticket, $service); + + [$username, $casAttributes] = $this->casValidation($ticket, $service); + $ldapAttributes = []; - $config = Configuration::loadFromArray( - $this->ldapConfig, - 'Authentication source ' . var_export($this->authId, true), - ); - if (!empty($this->ldapConfig['servers'])) { - $ldap = new Ldap( - $config->getString('servers'), - $config->getOptionalBoolean('enable_tls', false), - $config->getOptionalBoolean('debug', false), - $config->getOptionalInteger('timeout', 0), - $config->getOptionalInteger('port', 389), - $config->getOptionalBoolean('referrals', true), - ); - - $ldapAttributes = $ldap->validate($this->ldapConfig, $username); - if ($ldapAttributes === false) { - throw new Exception("Failed to authenticate against LDAP-server."); + // Expect $this->ldapConfig to contain an 'authsource' key when LDAP is desired + $backendId = $this->ldapConfig['authsource'] ?? null; + + if ($backendId !== null) { + /** @var \SimpleSAML\Auth\Source|null $source */ + $source = Auth\Source::getById($backendId); + if ($source === null) { + throw new Exception('Could not find authentication source with id ' . $backendId); + } + + // Ensure we only call getAttributes() on an LDAP authsource that supports it + if (!$source instanceof Ldap) { + throw new Exception(sprintf( + "Configured ldap.authsource '%s' is not an LDAP authsource.", + $backendId, + )); + } + + try { + $ldapAttributes = $source->getAttributes($username); + } catch (Exception $e) { + Logger::debug('CAS - ldap lookup failed: ' . $e->getMessage()); + $ldapAttributes = []; } } + $attributes = array_merge_recursive($casAttributes, $ldapAttributes); $state['Attributes'] = $attributes; } diff --git a/tests/config/authsources.php b/tests/config/authsources.php index 3866ca2..b858d32 100644 --- a/tests/config/authsources.php +++ b/tests/config/authsources.php @@ -79,4 +79,19 @@ ], 'ldap' => [], ], + // LDAP backend used by CAS ldap tests + 'ldap-backend' => [ + 'ldap:Ldap', + 'connection_string' => 'ldap://ldap.invalid.example.test', // invalid host to force failure later + 'search' => [ + 'base' => ['dc=example,dc=com'], + 'filter' => '(uid=%username%)', + 'scope' => 'sub', + ], + // Optional: + // 'attributes' => ['*'], + // 'attributes.binary' => [], + // 'timeout' => 3, + // 'options' => ['network_timeout' => 3, 'referrals' => false], + ], ]; diff --git a/tests/src/Controller/CASTest.php b/tests/src/Controller/CASTest.php index c5eb80e..2a8d975 100644 --- a/tests/src/Controller/CASTest.php +++ b/tests/src/Controller/CASTest.php @@ -21,6 +21,8 @@ use SimpleSAML\XML\DOMDocumentFactory; use SimpleSAML\XMLSchema\Type\Interface\ValueTypeInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; /** * Set of tests for the controllers in the "cas" module. @@ -53,6 +55,7 @@ protected function setUp(): void 'module.enable' => [ 'cas' => true, 'core' => true, + 'ldap' => true, ], ], '[ARRAY]', @@ -529,4 +532,126 @@ public function testCasserverAutoMapAttributesMatchBetweenModelAndXPath(): void ); } } + + + /** + * finalStep() should throw if ldap.authsource points to a non‑existent authsource. + */ + public function testFinalStepThrowsWhenLdapAuthsourceNotFound(): void + { + $config = [ + 'cas' => [ + 'login' => 'https://example.org/login', + 'serviceValidate' => 'https://example.org/serviceValidate', + 'logout' => 'https://example.org/logout', + ], + 'ldap' => [ + 'authsource' => 'missing-backend', + ], + ]; + + // Override casValidation to avoid real HTTP calls + $cas = new class (['AuthId' => 'unit-cas'], $config) extends CAS { + protected function casValidation(string $ticket, string $service): array + { + return ['user123', ['fromCas' => ['value']]]; + } + }; + + $state = ['cas:ticket' => 'ST-1-abc']; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Could not find authentication source with id missing-backend'); + + $cas->finalStep($state); + } + + + /** + * finalStep() should throw if ldap.authsource exists but is not an LDAP authsource. + * + * Here we re‑use the "something" authsource from the tests' authsources.php, + * which is configured as a cas:CAS authsource, not ldap:LDAP. + */ + public function testFinalStepThrowsWhenLdapAuthsourceIsNotLdap(): void + { + $config = [ + 'cas' => [ + 'login' => 'https://example.org/login', + 'serviceValidate' => 'https://example.org/serviceValidate', + 'logout' => 'https://example.org/logout', + ], + 'ldap' => [ + 'authsource' => 'something', + ], + ]; + + $cas = new class (['AuthId' => 'unit-cas'], $config) extends CAS { + protected function casValidation(string $ticket, string $service): array + { + return ['user123', ['fromCas' => ['value']]]; + } + }; + + $state = ['cas:ticket' => 'ST-1-abc']; + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "Configured ldap.authsource 'something' is not an LDAP authsource.", + ); + + $cas->finalStep($state); + } + + + /** + * Test that CAS finalStep() handles LDAP errors gracefully. + * When LDAP lookup fails, the method should: + * - Not throw an exception + * - Only use attributes from CAS validation + * - Set the username from CAS in the state + */ + public function testFinalStepSwallowsLdapErrorException(): void + { + $config = [ + 'cas' => [ + 'login' => 'https://example.org/login', + 'validate' => 'https://example.org/validate', // CAS 1.0, no serviceValidate + // no 'serviceValidate' here on purpose + 'logout' => 'https://example.org/logout', + ], + 'ldap' => [ + 'authsource' => 'ldap-backend', + ], + ]; + + $cas = new CAS(['AuthId' => 'unit-cas'], $config); + + // Mock HttpClient: casValidate() expects "yes\n\n" + $httpClient = $this->createMock(HttpClientInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $httpClient + ->method('request') + ->willReturn($response); + + $response + ->method('getContent') + ->willReturn("yes\nuser123\n"); + + // Inject mocked client + $ref = new \ReflectionClass($cas); + $initHttpClient = $ref->getMethod('initHttpClient'); + $initHttpClient->setAccessible(true); + $initHttpClient->invoke($cas, $httpClient); + + $state = ['cas:ticket' => 'ST-1-xyz']; + + // Should not throw; LDAP error will be caught + $cas->finalStep($state); + + // Attributes should come from CAS only; LDAP failure resulted in $ldapAttributes = [] + $this->assertArrayHasKey('Attributes', $state); + $this->assertSame([], $state['Attributes']); + } } diff --git a/tools/composer-require-checker.json b/tools/composer-require-checker.json index e0b6af2..271ab92 100644 --- a/tools/composer-require-checker.json +++ b/tools/composer-require-checker.json @@ -1,5 +1,5 @@ { - "symbol-whitelist": [ - "SimpleSAML\\Module\\ldap\\Auth\\Ldap" - ] + "symbol-whitelist": [ + "SimpleSAML\\Module\\ldap\\Auth\\Source\\Ldap" + ] }