diff --git a/src/Connection.php b/src/Connection.php index de111689..ccdb6783 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 09350e75..31ac1adb 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 80b6d6ee..499aa8dc 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/Attributes/Password.php b/src/Models/Attributes/Password.php index 71f4d06e..f38ae95b 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 8eb01ef9..7955cee6 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,9 +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)) { + [$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() ); } @@ -119,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 24d42f80..3e95d085 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 2c76a06d..73e81043 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 a1608e1a..3d2dc6e6 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/Attributes/PasswordTest.php b/tests/Unit/Models/Attributes/PasswordTest.php index 67a2c45c..7340b90c 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 e03a4d11..fa05176d 100644 --- a/tests/Unit/Models/OpenLDAP/UserTest.php +++ b/tests/Unit/Models/OpenLDAP/UserTest.php @@ -4,8 +4,11 @@ use LdapRecord\Connection; use LdapRecord\Container; +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 @@ -75,6 +78,98 @@ 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_defers_a_pending_change_instead_of_queuing_modifications() + { + $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() { $entryUuid = 'foo';