diff --git a/lam/HISTORY b/lam/HISTORY index 56de6b0ea..10cb8ac68 100644 --- a/lam/HISTORY +++ b/lam/HISTORY @@ -1,6 +1,7 @@ December 2024 9.0 - Fixed bugs: -> Windows: show more than 1000 LDAP entries when paged results is activated in server profile + -> WebAuthn: support DNs larger than 64 bytes (358) 24.09.2024 8.9 diff --git a/lam/lib/webauthn.inc b/lam/lib/webauthn.inc index 6577457c3..a109a35da 100644 --- a/lam/lib/webauthn.inc +++ b/lam/lib/webauthn.inc @@ -87,12 +87,11 @@ class WebauthnManager { * Returns if the given DN is registered for webauthn. * * @param string $dn DN - * @return boolean is registered + * @return bool is registered */ - public function isRegistered($dn) { + public function isRegistered($dn): bool { $database = $this->getDatabase(); - $userEntity = $this->getUserEntity($dn); - $results = $database->findAllForUserEntity($userEntity); + $results = $database->findAllForUserDn($dn); return !empty($results); } @@ -109,7 +108,7 @@ class WebauthnManager { $userEntity = $this->getUserEntity($dn); $challenge = $this->createChallenge(); $credentialParameters = $this->getCredentialParameters(); - $excludedKeys = $this->getExcludedKeys($userEntity, $extraExcludedKeys); + $excludedKeys = $this->getExcludedKeys($dn, $extraExcludedKeys); $timeout = $this->getTimeout(); $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create()->setUserVerification(AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED); $registrationObject = new PublicKeyCredentialCreationOptions( @@ -184,13 +183,23 @@ class WebauthnManager { */ private function getUserEntity($dn) { return new PublicKeyCredentialUserEntity( - $dn, - $dn, + self::getUserIdFromDn($dn), + self::getUserIdFromDn($dn), extractRDNValue($dn), null ); } + /** + * Generates the user ID as hash of the user DN. + * + * @param string $dn user DN + * @return string user ID + */ + public static function getUserIdFromDn(string $dn) { + return hash('sha256', $dn); + } + /** * Returns the part that identifies the server and application. * @@ -228,14 +237,14 @@ class WebauthnManager { /** * Returns a list of all credential ids that are already registered. * - * @param PublicKeyCredentialUserEntity $user user data + * @param string $userDn user DN * @param array $extraExcludedKeys credentialIds that should be added to excluded keys * @return PublicKeyCredentialDescriptor[] credential ids */ - private function getExcludedKeys($user, $extraExcludedKeys = []) { + private function getExcludedKeys(string $userDn, $extraExcludedKeys = []) { $keys = []; $repository = $this->getDatabase(); - $credentialSources = $repository->findAllForUserEntity($user); + $credentialSources = $repository->findAllForUserDn($userDn); foreach ($credentialSources as $credentialSource) { $keys[] = new PublicKeyCredentialDescriptor(PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, $credentialSource->getPublicKeyCredentialId()); } @@ -357,8 +366,7 @@ class WebauthnManager { $timeout = $this->getTimeout(); $challenge = $this->createChallenge(); $database = $this->getDatabase(); - $userEntity = $this->getUserEntity($userDN); - $publicKeyCredentialSources = $database->findAllForUserEntity($userEntity); + $publicKeyCredentialSources = $database->findAllForUserDn($userDN); $userVerification = PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DISCOURAGED; $extensions = new AuthenticationExtensionsClientInputs(); $relyingParty = $this->createRpEntry($isSelfService); @@ -406,12 +414,19 @@ class WebauthnManager { $psrFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); $psr7Request = $psrFactory->createRequest($symfonyRequest); $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($_SESSION['webauthn_authentication']); + $credential = $database->findOneByCredentialId($publicKeyCredential->getRawId()); + if ($credential === null) { + throw new LAMException(null, 'Unable to find credential'); + } + $userId = $credential->getUserHandle(); + // old entries use DN as user ID + $userHandle = ($userId === $userDn) ? $userDn : self::getUserIdFromDn($userDn); $responseValidator->check( $publicKeyCredential->getRawId(), $publicKeyCredential->getResponse(), $publicKeyCredentialRequestOptions, $psr7Request, - $userDn + $userHandle ); return true; } @@ -469,11 +484,21 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede * @return PublicKeyCredentialSource[] credential sources */ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array { + return $this->findAllForUserDn($publicKeyCredentialUserEntity->getName()); + } + + /** + * Finds all credential entries for the given user. + * + * @param string $userDn user DN + * @return PublicKeyCredentialSource[] credential sources + */ + public function findAllForUserDn(string $userDn): array { $credentials = []; try { $pdo = $this->getPDO(); - $statement = $pdo->prepare('select * from ' . $this->getTableName() . ' where userId = :userid'); - $statement->execute([':userid' => $publicKeyCredentialUserEntity->getId()]); + $statement = $pdo->prepare('select * from ' . $this->getTableName() . ' where userDN = :userDN'); + $statement->execute([':userDN' => $userDn]); $results = $statement->fetchAll(); foreach ($results as $result) { $jsonArray = json_decode($result['credentialSource'], true); @@ -497,36 +522,43 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede $userId = $publicKeyCredentialSource->getUserHandle(); $currentTime = time(); $pdo = $this->getPDO(); - $statement = $pdo->prepare('select * from ' . $this->getTableName() . ' where userId = :userId and credentialId = :credentialId'); + if (isset($_SESSION['selfService_clientDN'])) { + $userDn = lamDecrypt($_SESSION['selfService_clientDN'], 'SelfService'); + } + else { + $userDn = $_SESSION['ldap']->getUserName(); + } + $statement = $pdo->prepare('select * from ' . $this->getTableName() . ' where userDN = :userDN and credentialId = :credentialId'); $statement->execute([ - ':userId' => $userId, + ':userDN' => $userDn, ':credentialId' => $credentialId ]); $results = $statement->fetchAll(); if (empty($results)) { - $statement = $pdo->prepare('insert into ' . $this->getTableName() . ' (userId, credentialId, credentialSource, registrationTime, lastUseTime) VALUES (?, ?, ?, ?, ?)'); + $statement = $pdo->prepare('insert into ' . $this->getTableName() . ' (userId, credentialId, credentialSource, registrationTime, lastUseTime, userDN) VALUES (?, ?, ?, ?, ?, ?)'); $statement->execute([ $userId, $credentialId, $json, $currentTime, - $currentTime + $currentTime, + $userDn ]); - logNewMessage(LOG_DEBUG, 'Stored new credential for ' . $userId); + logNewMessage(LOG_DEBUG, 'Stored new credential for ' . $userDn); } else { $statement = $pdo->prepare( 'update ' . $this->getTableName() . ' set credentialSource = :credentialSource, lastUseTime = :lastUseTime' . - ' WHERE userId = :userId AND credentialId = :credentialId' + ' WHERE userDN = :userDN AND credentialId = :credentialId' ); $statement->execute([ ':credentialSource' => $json, ':lastUseTime' => $currentTime, - ':userId' => $userId, + ':userDN' => $userDn, ':credentialId' => $credentialId ]); - logNewMessage(LOG_DEBUG, 'Stored updated credential for ' . $userId); + logNewMessage(LOG_DEBUG, 'Stored updated credential for ' . $userDn); } } @@ -557,7 +589,7 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede */ public function searchDevices(string $searchTerm) { $pdo = $this->getPDO(); - $statement = $pdo->prepare('select * from ' . $this->getTableName() . ' where userId like :searchTerm order by userId,registrationTime'); + $statement = $pdo->prepare('select * from ' . $this->getTableName() . ' where userDN like :searchTerm order by userId,registrationTime'); $statement->execute([ ':searchTerm' => $searchTerm ]); @@ -566,7 +598,7 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede foreach ($results as $result) { $name = !empty($result['name']) ? $result['name'] : ''; $devices[] = [ - 'dn' => $result['userId'], + 'dn' => $result['userDN'], 'credentialId' => $result['credentialId'], 'lastUseTime' => $result['lastUseTime'], 'registrationTime' => $result['registrationTime'], @@ -586,9 +618,9 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede public function deleteDevice(string $dn, string $credentialId) { logNewMessage(LOG_NOTICE, 'Delete webauthn device ' . $credentialId . ' of ' . $dn); $pdo = $this->getPDO(); - $statement = $pdo->prepare('delete from ' . $this->getTableName() . ' where userId = :userId and credentialId = :credentialId'); + $statement = $pdo->prepare('delete from ' . $this->getTableName() . ' where userDN = :userDN and credentialId = :credentialId'); $statement->execute([ - ':userId' => $dn, + ':userDN' => $dn, ':credentialId' => $credentialId ]); return $statement->rowCount() > 0; @@ -604,9 +636,9 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede */ public function updateDeviceName(string $dn, string $credentialId, $name) { $pdo = $this->getPDO(); - $statement = $pdo->prepare('update ' . $this->getTableName() . ' set name = :name where userId = :userId and credentialId = :credentialId'); + $statement = $pdo->prepare('update ' . $this->getTableName() . ' set name = :name where userDN = :userDN and credentialId = :credentialId'); $statement->execute([ - ':userId' => $dn, + ':userDN' => $dn, ':credentialId' => $credentialId, ':name' => $name ]); @@ -636,6 +668,7 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede $this->createInitialSchema($pdo); } $this->addNameColumn($pdo); + $this->addUserDnColumn($pdo); } /** @@ -676,6 +709,29 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede } } + /** + * Adds the user DN column if not existing. + * + * @param PDO $pdo PDO + */ + protected function addUserDnColumn(PDO $pdo): void { + try { + $statement = $pdo->query("select * from pragma_table_info('" . $this->getTableName() . "') where name = 'userDN';"); + $results = $statement->fetchAll(); + if (empty($results)) { + $sql = 'alter table ' . $this->getTableName() . ' add column userDN VARCHAR(255);'; + logNewMessage(LOG_DEBUG, $sql); + $pdo->exec($sql); + $sql = 'update ' . $this->getTableName() . ' set userDn = userId;'; + logNewMessage(LOG_DEBUG, $sql); + $pdo->exec($sql); + } + } + catch (PDOException $e) { + logNewMessage(LOG_ERR, 'Unable to add userDN column to table: ' . $e->getMessage()); + } + } + /** * Exports all entries. * @@ -695,6 +751,7 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede 'registrationTime' => $dbRow['registrationTime'], 'lastUseTime' => $dbRow['lastUseTime'], 'name' => $dbRow['name'], + 'userDN' => $dbRow['userDN'], ]; } return $data; @@ -713,14 +770,15 @@ abstract class PublicKeyCredentialSourceRepositoryBase implements PublicKeyCrede return; } foreach ($data as $dbRow) { - $statement = $pdo->prepare('insert into ' . $this->getTableName() . ' (userId, credentialId, credentialSource, registrationTime, lastUseTime, name) VALUES (?, ?, ?, ?, ?, ?)'); + $statement = $pdo->prepare('insert into ' . $this->getTableName() . ' (userId, credentialId, credentialSource, registrationTime, lastUseTime, name, userDN) VALUES (?, ?, ?, ?, ?, ?, ?)'); $statement->execute([ $dbRow['userId'], $dbRow['credentialId'], $dbRow['credentialSource'], $dbRow['registrationTime'], $dbRow['lastUseTime'], - $dbRow['name'] + $dbRow['name'], + $dbRow['userDN'], ]); } } @@ -835,6 +893,27 @@ class PublicKeyCredentialSourceRepositoryMySql extends PublicKeyCredentialSource // added via initial schema } + /** + * {@inheritDoc} + */ + protected function addUserDnColumn(PDO $pdo): void { + try { + $statement = $pdo->query("show columns from " . $this->getTableName() . " like 'userDN';"); + $results = $statement->fetchAll(); + if (empty($results)) { + $sql = 'alter table ' . $this->getTableName() . ' add column userDN VARCHAR(255);'; + logNewMessage(LOG_DEBUG, $sql); + $pdo->exec($sql); + $sql = 'update ' . $this->getTableName() . ' set userDn = userId;'; + logNewMessage(LOG_DEBUG, $sql); + $pdo->exec($sql); + } + } + catch (PDOException $e) { + logNewMessage(LOG_ERR, 'Unable to add userDN column to table: ' . $e->getMessage()); + } + } + } diff --git a/lam/tests/lib/PublicKeyCredentialSourceRepositorySQLiteTest.php b/lam/tests/lib/PublicKeyCredentialSourceRepositorySQLiteTest.php index e935dc5c9..7b4cbb09b 100644 --- a/lam/tests/lib/PublicKeyCredentialSourceRepositorySQLiteTest.php +++ b/lam/tests/lib/PublicKeyCredentialSourceRepositorySQLiteTest.php @@ -1,6 +1,7 @@ database = new PublicKeyCredentialSourceRepositorySQLiteTestDb(); + $_SESSION['ldap'] = new Ldap(null); + $_SESSION['ldap']->encrypt_login('cn=user1', 'password'); } /** @@ -59,10 +62,8 @@ public function test_findOneByCredentialId_emptyDb() { /** * Empty DB test */ - public function test_findAllForUserEntity_emptyDb() { - $entity = new PublicKeyCredentialUserEntity("cn=test,dc=example", "cn=test,dc=example", "test", null); - - $result = $this->database->findAllForUserEntity($entity); + public function test_findAllForDn_emptyDb() { + $result = $this->database->findAllForUserDn("cn=test,dc=example"); $this->assertEmpty($result); } @@ -78,7 +79,7 @@ public function test_saveCredentialSource() { new CertificateTrustPath(['x5c' => 'test']), \Symfony\Component\Uid\Uuid::fromString('00000000-0000-0000-0000-000000000000'), "p1", - "uh1", + WebauthnManager::getUserIdFromDn("cn=user1"), 1); $this->database->saveCredentialSource($source1); $source2 = new PublicKeyCredentialSource( @@ -89,9 +90,10 @@ public function test_saveCredentialSource() { new CertificateTrustPath(['x5c' => 'test']), \Symfony\Component\Uid\Uuid::fromString('00000000-0000-0000-0000-000000000000'), "p2", - "uh1", + WebauthnManager::getUserIdFromDn("cn=user1"), 1); $this->database->saveCredentialSource($source2); + $_SESSION['ldap']->encrypt_login('cn=user2', 'password'); $source3 = new PublicKeyCredentialSource( "id3", PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, @@ -100,7 +102,7 @@ public function test_saveCredentialSource() { new CertificateTrustPath(['x5c' => 'test']), \Symfony\Component\Uid\Uuid::fromString('00000000-0000-0000-0000-000000000000'), "p3", - "uh2", + WebauthnManager::getUserIdFromDn("cn=user2"), 1); $this->database->saveCredentialSource($source3); @@ -108,10 +110,10 @@ public function test_saveCredentialSource() { $this->assertNotNull($this->database->findOneByCredentialId("id2")); $this->assertNotNull($this->database->findOneByCredentialId("id3")); $this->assertEquals(2, sizeof( - $this->database->findAllForUserEntity(new PublicKeyCredentialUserEntity("uh1", "uh1", "uh1", null)) + $this->database->findAllForUserDn("cn=user1") )); $this->assertEquals(1, sizeof( - $this->database->findAllForUserEntity(new PublicKeyCredentialUserEntity("uh2", "uh2", "uh2", null)) + $this->database->findAllForUserDn("cn=user2") )); } @@ -125,7 +127,7 @@ public function test_hasRegisteredCredentials() { new CertificateTrustPath(['x5c' => 'test']), \Symfony\Component\Uid\Uuid::fromString('00000000-0000-0000-0000-000000000000'), "p1", - "uh1", + WebauthnManager::getUserIdFromDn("cn=user1"), 1); $this->database->saveCredentialSource($source1); $this->assertTrue($this->database->hasRegisteredCredentials()); @@ -140,12 +142,12 @@ public function test_searchDevices() { new CertificateTrustPath(['x5c' => 'test']), \Symfony\Component\Uid\Uuid::fromString('00000000-0000-0000-0000-000000000000'), "p1", - "uh1", + WebauthnManager::getUserIdFromDn("cn=user1"), 1); $this->database->saveCredentialSource($source1); - $this->assertNotEmpty($this->database->searchDevices('uh1')); - $this->assertNotEmpty($this->database->searchDevices('%h1%')); - $this->assertEmpty($this->database->searchDevices('uh2')); + $this->assertNotEmpty($this->database->searchDevices('cn=user1')); + $this->assertNotEmpty($this->database->searchDevices('%user1%')); + $this->assertEmpty($this->database->searchDevices('cn=user2')); } public function test_deleteDevice() { @@ -157,11 +159,11 @@ public function test_deleteDevice() { new CertificateTrustPath(['x5c' => 'test']), \Symfony\Component\Uid\Uuid::fromString('00000000-0000-0000-0000-000000000000'), "p1", - "uh1", + WebauthnManager::getUserIdFromDn("cn=user1"), 1); $this->database->saveCredentialSource($source1); - $this->assertTrue($this->database->deleteDevice('uh1', base64_encode('id1'))); - $this->assertFalse($this->database->deleteDevice('uh1', base64_encode('id2'))); + $this->assertTrue($this->database->deleteDevice('cn=user1', base64_encode('id1'))); + $this->assertFalse($this->database->deleteDevice('cn=user1', base64_encode('id2'))); } } diff --git a/lam/tests/lib/WebauthnManagerTest.php b/lam/tests/lib/WebauthnManagerTest.php index 5b5390dcb..583a68687 100644 --- a/lam/tests/lib/WebauthnManagerTest.php +++ b/lam/tests/lib/WebauthnManagerTest.php @@ -59,7 +59,7 @@ class WebauthnManagerTest extends TestCase { protected function setup(): void { $this->database = $this ->getMockBuilder(PublicKeyCredentialSourceRepositorySQLite::class) - ->onlyMethods(['getPdoUrl', 'findOneByCredentialId', 'findAllForUserEntity']) + ->onlyMethods(['getPdoUrl', 'findOneByCredentialId', 'findAllForUserDn']) ->getMock(); $file = tmpfile(); $filePath = stream_get_meta_data($file)['uri']; @@ -85,7 +85,7 @@ protected function setup(): void { } public function test_getAuthenticationObject() { - $this->database->method('findAllForUserEntity')->willReturn([]); + $this->database->method('findAllForUserDn')->willReturn([]); $authenticationObj = $this->manager->getAuthenticationObject('uid=test,o=test', false); $this->assertEquals(32, strlen($authenticationObj->getChallenge())); @@ -99,14 +99,14 @@ public function test_getRegistrationObject() { } public function test_isRegistered_notRegistered() { - $this->database->method('findAllForUserEntity')->willReturn([]); + $this->database->method('findAllForUserDn')->willReturn([]); $isRegistered = $this->manager->isRegistered('uid=test,o=test'); $this->assertFalse($isRegistered); } public function test_isRegistered_registered() { - $this->database->method('findAllForUserEntity')->willReturn([new PublicKeyCredentialSource( + $this->database->method('findAllForUserDn')->willReturn([new PublicKeyCredentialSource( "id1", PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, [],