diff --git a/src/Connection.php b/src/Connection.php index de111689..e354d2e8 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -326,6 +326,18 @@ public function isolate(Closure $operation): mixed } } + /** + * Change a user's password using the RFC 3062 Password Modify extended operation. + * + * @throws LdapRecordException + */ + public function changePassword(string $dn, string $oldPassword, string $newPassword): bool|string + { + return $this->run( + fn (LdapInterface $ldap) => $ldap->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..8d62063c 100644 --- a/src/Models/Attributes/Password.php +++ b/src/Models/Attributes/Password.php @@ -102,6 +102,40 @@ public static function md5(string $password): string return '{MD5}'.static::makeHash($password, 'md5'); } + /** + * Make an argon2i password. + * + * OpenLDAP's argon2 module registers the single scheme {ARGON2} — + * the variant is carried by the PHC string that follows it. + * + * @throws LdapRecordException + */ + public static function argon2i(string $password): string + { + if (! defined('PASSWORD_ARGON2I')) { + throw new LdapRecordException('Argon2i hashing is not supported by this PHP build.'); + } + + return '{ARGON2}'.password_hash($password, PASSWORD_ARGON2I); + } + + /** + * Make an argon2id password. + * + * OpenLDAP's argon2 module registers the single scheme {ARGON2} — + * the variant is carried by the PHC string that follows it. + * + * @throws LdapRecordException + */ + public static function argon2id(string $password): string + { + if (! defined('PASSWORD_ARGON2ID')) { + throw new LdapRecordException('Argon2id hashing is not supported by this PHP build.'); + } + + return '{ARGON2}'.password_hash($password, PASSWORD_ARGON2ID); + } + /** * Make a non-salted NThash password. */ @@ -228,6 +262,18 @@ public static function getSalt(string $encryptedPassword): string throw new LdapRecordException('Could not extract salt from encrypted password.'); } + /** + * Determine if passwords hashed with the given method can only be + * changed using the password modify extended operation (RFC 3062). + * + * These hashes embed a random salt that cannot be extracted, so the + * stored hash cannot be reproduced for a REMOVE/ADD batch modification. + */ + public static function hashMethodRequiresExop(string $method): bool + { + return in_array(strtolower($method), ['argon2', 'argon2i', 'argon2id'], true); + } + /** * Determine if the hash method requires a salt to be given. * diff --git a/src/Models/Concerns/HasPassword.php b/src/Models/Concerns/HasPassword.php index 8eb01ef9..9984cfa7 100644 --- a/src/Models/Concerns/HasPassword.php +++ b/src/Models/Concerns/HasPassword.php @@ -14,6 +14,7 @@ trait HasPassword * Set the password on the user. * * @throws ConnectionException + * @throws LdapRecordException */ public function setPasswordAttribute(array|string $password): void { @@ -29,9 +30,17 @@ 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; + + if (Password::hashMethodRequiresExop($method)) { + throw new LdapRecordException( + 'Argon2 passwords cannot be changed through attribute assignment. Use the changePassword method instead.' + ); + } + $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() ); } @@ -210,6 +219,12 @@ public function determinePasswordHashMethod(): ?string return null; } + // The {ARGON2} scheme carries its variant inside the PHC + // string following the prefix, not in the scheme name. + if (strcasecmp($method, 'argon2') === 0) { + return str_contains($password, '$argon2i$') ? 'argon2i' : 'argon2id'; + } + if (! $hashAndAlgo = Password::getHashMethodAndAlgo($password)) { return $method; } diff --git a/src/Models/OpenLDAP/User.php b/src/Models/OpenLDAP/User.php index beaba9e4..af6f865e 100644 --- a/src/Models/OpenLDAP/User.php +++ b/src/Models/OpenLDAP/User.php @@ -3,6 +3,7 @@ namespace LdapRecord\Models\OpenLDAP; use Illuminate\Contracts\Auth\Authenticatable; +use LdapRecord\LdapRecordException; use LdapRecord\Models\Concerns\CanAuthenticate; use LdapRecord\Models\Concerns\HasPassword; use LdapRecord\Models\Relations\HasMany; @@ -32,6 +33,26 @@ class User extends Entry implements Authenticatable 'inetorgperson', ]; + /** + * Change the user's password. + * + * @throws LdapRecordException + */ + public function changePassword(string $oldPassword, string $newPassword): void + { + $this->assertSecureConnection(); + + if (! $this->exists || ! $this->getDn()) { + throw new LdapRecordException( + 'A password change requires an existing model with a distinguished name.' + ); + } + + $this->getConnection()->changePassword( + $this->getDn(), $oldPassword, $newPassword + ); + } + /** * Get the unique identifier for the user. */ 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/ConnectionTest.php b/tests/Unit/ConnectionTest.php index a73a905b..ca250b70 100644 --- a/tests/Unit/ConnectionTest.php +++ b/tests/Unit/ConnectionTest.php @@ -308,6 +308,22 @@ public function test_ldap_operations_can_be_executed_with_connections() $this->assertTrue($returned); } + public function test_connections_can_change_passwords() + { + $ldap = (new LdapFake)->expect([ + LdapFake::operation('bind')->once()->andReturnResponse(), + LdapFake::operation('exopPasswd')->once() + ->with('cn=jdoe,dc=local,dc=com', 'secret', 'new-secret') + ->andReturnTrue(), + ]); + + $connection = new Connection([], $ldap); + + $this->assertTrue( + $connection->changePassword('cn=jdoe,dc=local,dc=com', 'secret', 'new-secret') + ); + } + public function test_ran_ldap_operations_are_retried_when_connection_is_lost() { $ldap = (new LdapFake) diff --git a/tests/Unit/Models/Attributes/PasswordTest.php b/tests/Unit/Models/Attributes/PasswordTest.php index 67a2c45c..d3727935 100644 --- a/tests/Unit/Models/Attributes/PasswordTest.php +++ b/tests/Unit/Models/Attributes/PasswordTest.php @@ -92,6 +92,32 @@ public function test_sha512crypt() $this->assertEquals($password, Password::sha512crypt('password', Password::getSalt($password))); } + public function test_argon2i() + { + $password = Password::argon2i('password'); + + $this->assertStringStartsWith('{ARGON2}$argon2i$', $password); + $this->assertNotEquals($password, Password::argon2i('password')); + } + + public function test_argon2id() + { + $password = Password::argon2id('password'); + + $this->assertStringStartsWith('{ARGON2}$argon2id$', $password); + $this->assertNotEquals($password, Password::argon2id('password')); + } + + public function test_hash_method_requires_exop() + { + $this->assertTrue(Password::hashMethodRequiresExop('argon2')); + $this->assertTrue(Password::hashMethodRequiresExop('argon2i')); + $this->assertTrue(Password::hashMethodRequiresExop('ARGON2ID')); + + $this->assertFalse(Password::hashMethodRequiresExop('ssha')); + $this->assertFalse(Password::hashMethodRequiresExop('md5')); + } + // 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..30a23b36 100644 --- a/tests/Unit/Models/OpenLDAP/UserTest.php +++ b/tests/Unit/Models/OpenLDAP/UserTest.php @@ -3,9 +3,13 @@ namespace LdapRecord\Tests\Unit\Models\OpenLDAP; use LdapRecord\Connection; +use LdapRecord\ConnectionException; 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 +79,82 @@ 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_through_attribute_assignment_throws_exception() + { + $this->expectException(LdapRecordException::class); + $this->expectExceptionMessage( + 'Argon2 passwords cannot be changed through attribute assignment. Use the changePassword method instead.' + ); + + $user = (new OpenLDAPUserTestStub)->setRawAttributes([ + 'dn' => ['cn=jdoe,dc=local,dc=com'], + 'userpassword' => [ + Password::argon2id('secret'), + ], + ]); + + $user->password = ['secret', 'new-secret']; + } + + 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->assertCount(1, $modifications); + $this->assertEquals(LDAP_MODIFY_BATCH_REPLACE, $modifications[0]['modtype']); + $this->assertEquals('ARGON2', Password::getHashMethod($modifications[0]['values'][0])); + $this->assertStringContainsString('$argon2id$', $modifications[0]['values'][0]); + } + + public function test_changing_password_performs_password_modify_extended_operation() + { + $ldap = DirectoryFake::setup()->getLdapConnection(); + + $ldap->expect( + 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'], + ]); + + $user->changePassword('secret', 'new-secret'); + + $this->assertEmpty($user->getModifications()); + } + + public function test_changing_password_requires_a_secure_connection() + { + $user = (new User)->setRawAttributes([ + 'dn' => ['cn=jdoe,dc=local,dc=com'], + ]); + + $this->expectException(ConnectionException::class); + + $user->changePassword('secret', 'new-secret'); + } + + public function test_changing_password_requires_an_existing_model() + { + $this->expectException(LdapRecordException::class); + $this->expectExceptionMessage( + 'A password change requires an existing model with a distinguished name.' + ); + + (new OpenLDAPUserTestStub)->changePassword('secret', 'new-secret'); + } + public function test_correct_auth_identifier_is_returned() { $entryUuid = 'foo';