From 207cb8c084af44f8f34ee42a7b38c5548239c254 Mon Sep 17 00:00:00 2001 From: Bastien Wermeille Date: Tue, 9 Jun 2026 15:02:19 +0200 Subject: [PATCH 1/2] Add support for argon2 --- src/Models/Attributes/Password.php | 16 ++++++++++++++++ src/Models/Concerns/HasPassword.php | 6 ++++++ tests/Unit/Models/Attributes/PasswordTest.php | 16 ++++++++++++++++ tests/Unit/Models/OpenLDAP/UserTest.php | 15 +++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/src/Models/Attributes/Password.php b/src/Models/Attributes/Password.php index 71f4d06ec..f38ae95b2 100644 --- a/src/Models/Attributes/Password.php +++ b/src/Models/Attributes/Password.php @@ -102,6 +102,22 @@ public static function md5(string $password): string return '{MD5}'.static::makeHash($password, 'md5'); } + /** + * Make an argon2i password. + */ + public static function argon2i(string $password): string + { + return '{ARGON2I}'.password_hash($password, PASSWORD_ARGON2I); + } + + /** + * Make an argon2id password. + */ + public static function argon2id(string $password): string + { + return '{ARGON2ID}'.password_hash($password, PASSWORD_ARGON2ID); + } + /** * Make a non-salted NThash password. */ diff --git a/src/Models/Concerns/HasPassword.php b/src/Models/Concerns/HasPassword.php index 8eb01ef9f..6b55ce2d9 100644 --- a/src/Models/Concerns/HasPassword.php +++ b/src/Models/Concerns/HasPassword.php @@ -29,6 +29,12 @@ public function setPasswordAttribute(array|string $password): void // If the password given is an array, we can assume we // are changing the password for the current user. if (is_array($password)) { + if (in_array(strtolower($method), ['argon2i', 'argon2id'])) { + throw new LdapRecordException( + "Argon2 passwords cannot be changed using this method. Use the LDAP Password Modify extended operation instead." + ); + } + $this->setChangedPassword( $this->getHashedPassword($method, $password[0], $this->getPasswordSalt($method)), $this->getHashedPassword($method, $password[1]), diff --git a/tests/Unit/Models/Attributes/PasswordTest.php b/tests/Unit/Models/Attributes/PasswordTest.php index 67a2c45c8..7340b90c5 100644 --- a/tests/Unit/Models/Attributes/PasswordTest.php +++ b/tests/Unit/Models/Attributes/PasswordTest.php @@ -92,6 +92,22 @@ public function test_sha512crypt() $this->assertEquals($password, Password::sha512crypt('password', Password::getSalt($password))); } + public function test_argon2i() + { + $password = Password::argon2i('password'); + + $this->assertStringStartsWith('{ARGON2I}$argon2i$', $password); + $this->assertNotEquals($password, Password::argon2i('password')); + } + + public function test_argon2id() + { + $password = Password::argon2id('password'); + + $this->assertStringStartsWith('{ARGON2ID}$argon2id$', $password); + $this->assertNotEquals($password, Password::argon2id('password')); + } + // Unsalted Hash Tests. // public function test_sha() diff --git a/tests/Unit/Models/OpenLDAP/UserTest.php b/tests/Unit/Models/OpenLDAP/UserTest.php index e03a4d11f..3e0b5541b 100644 --- a/tests/Unit/Models/OpenLDAP/UserTest.php +++ b/tests/Unit/Models/OpenLDAP/UserTest.php @@ -4,6 +4,7 @@ use LdapRecord\Connection; use LdapRecord\Container; +use LdapRecord\LdapRecordException; use LdapRecord\Models\Attributes\Password; use LdapRecord\Models\OpenLDAP\User; use LdapRecord\Tests\TestCase; @@ -75,6 +76,20 @@ public function test_algo_and_salt_is_automatically_detected_when_changing_a_use $this->assertEquals(Password::CRYPT_SALT_TYPE_SHA512, $newAlgo); } + public function test_changing_argon2_password_throws_exception() + { + $this->expectException(LdapRecordException::class); + $this->expectExceptionMessage('Argon2 passwords cannot be changed using this method.'); + + $user = (new OpenLDAPUserTestStub)->setRawAttributes([ + 'userpassword' => [ + Password::argon2id('secret'), + ], + ]); + + $user->password = ['secret', 'new-secret']; + } + public function test_correct_auth_identifier_is_returned() { $entryUuid = 'foo'; From 8e64e261c2581a635bec0fa0839d1cef41f5a6cf Mon Sep 17 00:00:00 2001 From: Bastien Wermeille Date: Sun, 21 Jun 2026 14:09:57 +0200 Subject: [PATCH 2/2] Implement password change requiring exop --- src/Connection.php | 29 +++++++++ src/Ldap.php | 16 +++++ src/LdapInterface.php | 13 ++++ src/Models/Concerns/HasPassword.php | 72 +++++++++++++++++++-- src/Models/Model.php | 12 ++++ src/Testing/ConnectionFake.php | 12 ++++ src/Testing/LdapFake.php | 8 +++ tests/Unit/Models/OpenLDAP/UserTest.php | 86 ++++++++++++++++++++++++- 8 files changed, 240 insertions(+), 8 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index de1116891..ccdb67830 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -326,6 +326,35 @@ public function isolate(Closure $operation): mixed } } + /** + * Change a user's password using the RFC 3062 Password Modify extended operation. + * + * The change is performed on an isolated connection bound as the user itself, + * so the directory verifies the current password (a self-service change) + * without altering the bind state of the primary connection. + * + * @throws LdapRecordException + */ + public function changePassword(string $dn, string $oldPassword, string $newPassword): bool|string + { + return $this->isolate(function (Connection $connection) use ($dn, $oldPassword, $newPassword) { + $connection->initialize(); + + // Binding as the user with their current password proves knowledge + // of it. A failed bind (e.g. an incorrect current password) leaves + // the change unapplied and surfaces as an exception below. + $response = $connection->getLdapConnection()->bind($dn, $oldPassword); + + if (! $response->successful()) { + throw new LdapRecordException( + 'Unable to change password. The current password is incorrect or the directory rejected the bind.' + ); + } + + return $connection->getLdapConnection()->exopPasswd($dn, $oldPassword, $newPassword); + }); + } + /** * Attempt to get an exception for the cause of failure. */ diff --git a/src/Ldap.php b/src/Ldap.php index 09350e759..31ac1adbe 100644 --- a/src/Ldap.php +++ b/src/Ldap.php @@ -368,6 +368,22 @@ public function modifyBatch(string $dn, array $values): bool }); } + /** + * {@inheritdoc} + */ + public function exopPasswd(string $user = '', string $oldPassword = '', string $newPassword = '', ?array &$controls = null): bool|string + { + if (! function_exists('ldap_exop_passwd')) { + throw new LdapRecordException( + 'The function [ldap_exop_passwd] is unavailable. Ensure your PHP LDAP extension supports extended operations.' + ); + } + + return $this->executeFailableOperation(function () use ($user, $oldPassword, $newPassword, &$controls) { + return ldap_exop_passwd($this->connection, $user, $oldPassword, $newPassword, $controls); + }); + } + /** * {@inheritdoc} */ diff --git a/src/LdapInterface.php b/src/LdapInterface.php index 80b6d6eed..499aa8dcd 100644 --- a/src/LdapInterface.php +++ b/src/LdapInterface.php @@ -564,6 +564,19 @@ public function modify(string $dn, array $entry): bool; */ public function modifyBatch(string $dn, array $values): bool; + /** + * Modify a password using the RFC 3062 Password Modify extended operation. + * + * Returns the server-generated password when no new password is supplied, + * true on success when a new password is given, or false on failure. + * + * @see https://www.php.net/manual/en/function.ldap-exop-passwd.php + * @see https://www.rfc-editor.org/rfc/rfc3062 + * + * @throws LdapRecordException + */ + public function exopPasswd(string $user = '', string $oldPassword = '', string $newPassword = '', ?array &$controls = null): bool|string; + /** * Add attribute values to current attributes. * diff --git a/src/Models/Concerns/HasPassword.php b/src/Models/Concerns/HasPassword.php index 6b55ce2d9..7955cee65 100644 --- a/src/Models/Concerns/HasPassword.php +++ b/src/Models/Concerns/HasPassword.php @@ -2,6 +2,7 @@ namespace LdapRecord\Models\Concerns; +use Closure; use LdapRecord\ConnectionException; use LdapRecord\LdapRecordException; use LdapRecord\Models\Attributes\Password; @@ -10,6 +11,11 @@ /** @mixin Model */ trait HasPassword { + /** + * A password change deferred until the model is saved. + */ + protected ?Closure $pendingPasswordChange = null; + /** * Set the password on the user. * @@ -29,15 +35,23 @@ public function setPasswordAttribute(array|string $password): void // If the password given is an array, we can assume we // are changing the password for the current user. if (is_array($password)) { - if (in_array(strtolower($method), ['argon2i', 'argon2id'])) { - throw new LdapRecordException( - "Argon2 passwords cannot be changed using this method. Use the LDAP Password Modify extended operation instead." + [$oldPassword, $newPassword] = $password; + + // Argon2 hashes embed a random salt, so the currently stored hash + // cannot be reproduced to emit a REMOVE/ADD batch modification. + // Instead we defer a self-service RFC 3062 Password Modify + // extended operation until the model is saved. + if ($this->passwordChangeRequiresExop($method)) { + $this->pendingPasswordChange = fn () => $this->getConnection()->changePassword( + $this->getDn(), $oldPassword, $newPassword ); + + return; } $this->setChangedPassword( - $this->getHashedPassword($method, $password[0], $this->getPasswordSalt($method)), - $this->getHashedPassword($method, $password[1]), + $this->getHashedPassword($method, $oldPassword, $this->getPasswordSalt($method)), + $this->getHashedPassword($method, $newPassword), $this->getPasswordAttributeName() ); } @@ -125,6 +139,54 @@ protected function setChangedPassword(string $oldPassword, string $newPassword, ); } + /** + * Determine if changing a password hashed with the given method requires + * an extended operation rather than a batch modification. + */ + protected function passwordChangeRequiresExop(string $method): bool + { + return match (strtolower($method)) { + 'argon2i', 'argon2id' => true, + default => false, + }; + } + + /** + * Determine if the model has a password change deferred until save. + */ + public function hasPendingPasswordChange(): bool + { + return ! is_null($this->pendingPasswordChange); + } + + /** + * Flush any password change deferred until save. + * + * @throws LdapRecordException + */ + public function flushPendingPasswordChange(): void + { + if (! $change = $this->pendingPasswordChange) { + return; + } + + // Clear the pending change before executing it so a failed operation + // cannot be re-applied if the save is retried. + $this->pendingPasswordChange = null; + + $change(); + } + + /** + * Perform any operations deferred until the model is saved. + * + * @throws LdapRecordException + */ + protected function performDeferredOperations(): void + { + $this->flushPendingPasswordChange(); + } + /** * Set the password on the model. */ diff --git a/src/Models/Model.php b/src/Models/Model.php index 24d42f808..3e95d0855 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -955,6 +955,11 @@ public function save(array $attributes = []): void $this->exists ? $this->performUpdate() : $this->performInsert(); + // Some changes (such as argon2 password changes) cannot be expressed as + // batch modifications and are deferred until the model is saved. These + // run unconditionally here, even when no batch modifications exist. + $this->performDeferredOperations(); + $this->dispatch('saved'); $this->modifications = []; @@ -962,6 +967,13 @@ public function save(array $attributes = []): void $this->in = null; } + /** + * Perform any operations deferred until the model is saved. + * + * @throws LdapRecordException + */ + protected function performDeferredOperations(): void {} + /** * Inserts the model into the directory. * diff --git a/src/Testing/ConnectionFake.php b/src/Testing/ConnectionFake.php index 2c76a06d5..73e810434 100644 --- a/src/Testing/ConnectionFake.php +++ b/src/Testing/ConnectionFake.php @@ -33,6 +33,18 @@ public static function make(array $config = [], string $ldap = LdapFake::class): return $connection; } + /** + * Clone the connection, reusing the same fake LDAP connection. + * + * Sharing the underlying fake allows operations performed on an isolated + * connection (e.g. self-service password changes) to be asserted through + * the original fake's expectations. + */ + public function replicate(): static + { + return new static($this->configuration, $this->ldap); + } + /** * Set the user to authenticate as. */ diff --git a/src/Testing/LdapFake.php b/src/Testing/LdapFake.php index a1608e1ac..3d2dc6e69 100644 --- a/src/Testing/LdapFake.php +++ b/src/Testing/LdapFake.php @@ -458,6 +458,14 @@ public function modifyBatch(string $dn, array $values): bool return $this->resolveExpectation(__FUNCTION__, func_get_args()); } + /** + * {@inheritdoc} + */ + public function exopPasswd(string $user = '', string $oldPassword = '', string $newPassword = '', ?array &$controls = null): bool|string + { + return $this->resolveExpectation(__FUNCTION__, func_get_args()); + } + /** * {@inheritdoc} */ diff --git a/tests/Unit/Models/OpenLDAP/UserTest.php b/tests/Unit/Models/OpenLDAP/UserTest.php index 3e0b5541b..fa05176d5 100644 --- a/tests/Unit/Models/OpenLDAP/UserTest.php +++ b/tests/Unit/Models/OpenLDAP/UserTest.php @@ -7,6 +7,8 @@ use LdapRecord\LdapRecordException; use LdapRecord\Models\Attributes\Password; use LdapRecord\Models\OpenLDAP\User; +use LdapRecord\Testing\DirectoryFake; +use LdapRecord\Testing\LdapFake; use LdapRecord\Tests\TestCase; class UserTest extends TestCase @@ -76,18 +78,96 @@ public function test_algo_and_salt_is_automatically_detected_when_changing_a_use $this->assertEquals(Password::CRYPT_SALT_TYPE_SHA512, $newAlgo); } - public function test_changing_argon2_password_throws_exception() + public function test_changing_argon2_password_defers_a_pending_change_instead_of_queuing_modifications() { - $this->expectException(LdapRecordException::class); - $this->expectExceptionMessage('Argon2 passwords cannot be changed using this method.'); + $user = (new OpenLDAPUserTestStub)->setRawAttributes([ + 'dn' => ['cn=jdoe,dc=local,dc=com'], + 'userpassword' => [ + Password::argon2id('secret'), + ], + ]); + + $user->password = ['secret', 'new-secret']; + + // The change is performed via an extended operation on save, so no + // batch modifications are queued for it. + $this->assertEmpty($user->getModifications()); + $this->assertTrue($user->hasPendingPasswordChange()); + } + + public function test_resetting_argon2_password_still_queues_a_single_replace_modification() + { + $user = (new OpenLDAPUserTestStub)->setRawAttributes([ + 'dn' => ['cn=jdoe,dc=local,dc=com'], + 'userpassword' => [ + Password::argon2id('secret'), + ], + ]); + + $user->password = 'new-secret'; + + $modifications = $user->getModifications(); + + $this->assertFalse($user->hasPendingPasswordChange()); + $this->assertCount(1, $modifications); + $this->assertEquals(LDAP_MODIFY_BATCH_REPLACE, $modifications[0]['modtype']); + $this->assertEquals('ARGON2ID', Password::getHashMethod($modifications[0]['values'][0])); + } + + public function test_changing_argon2_password_performs_a_self_service_exop_on_save() + { + $ldap = DirectoryFake::setup()->getLdapConnection(); + + $ldap->expect([ + LdapFake::operation('bind')->once() + ->with('cn=jdoe,dc=local,dc=com', 'secret') + ->andReturnResponse(), + + LdapFake::operation('exopPasswd')->once() + ->with('cn=jdoe,dc=local,dc=com', 'secret', 'new-secret') + ->andReturnTrue(), + ]); $user = (new OpenLDAPUserTestStub)->setRawAttributes([ + 'dn' => ['cn=jdoe,dc=local,dc=com'], 'userpassword' => [ Password::argon2id('secret'), ], ]); $user->password = ['secret', 'new-secret']; + + $user->save(); + + $this->assertFalse($user->hasPendingPasswordChange()); + + // Guards against the change being silently skipped because no batch + // modifications exist (the early return in Model::performUpdate). + $ldap->assertMinimumExpectationCounts(); + } + + public function test_changing_argon2_password_throws_when_current_password_is_rejected() + { + $ldap = DirectoryFake::setup()->getLdapConnection(); + + $ldap->expect([ + LdapFake::operation('bind')->once() + ->with('cn=jdoe,dc=local,dc=com', 'wrong-secret') + ->andReturnResponse(49), + ]); + + $user = (new OpenLDAPUserTestStub)->setRawAttributes([ + 'dn' => ['cn=jdoe,dc=local,dc=com'], + 'userpassword' => [ + Password::argon2id('secret'), + ], + ]); + + $user->password = ['wrong-secret', 'new-secret']; + + $this->expectException(LdapRecordException::class); + + $user->save(); } public function test_correct_auth_identifier_is_returned()