Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
16 changes: 16 additions & 0 deletions src/Ldap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down
13 changes: 13 additions & 0 deletions src/LdapInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
46 changes: 46 additions & 0 deletions src/Models/Attributes/Password.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*
Expand Down
19 changes: 17 additions & 2 deletions src/Models/Concerns/HasPassword.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ trait HasPassword
* Set the password on the user.
*
* @throws ConnectionException
* @throws LdapRecordException
*/
public function setPasswordAttribute(array|string $password): void
{
Expand All @@ -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()
);
}
Expand Down Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions src/Models/OpenLDAP/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/Testing/LdapFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down
16 changes: 16 additions & 0 deletions tests/Unit/ConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions tests/Unit/Models/Attributes/PasswordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions tests/Unit/Models/OpenLDAP/UserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down
Loading