Skip to content
Open
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
29 changes: 29 additions & 0 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
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
16 changes: 16 additions & 0 deletions src/Models/Attributes/Password.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
72 changes: 70 additions & 2 deletions src/Models/Concerns/HasPassword.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace LdapRecord\Models\Concerns;

use Closure;
use LdapRecord\ConnectionException;
use LdapRecord\LdapRecordException;
use LdapRecord\Models\Attributes\Password;
Expand All @@ -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.
*
Expand All @@ -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()
);
}
Expand Down Expand Up @@ -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.
*/
Expand Down
12 changes: 12 additions & 0 deletions src/Models/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -955,13 +955,25 @@ 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 = [];

$this->in = null;
}

/**
* Perform any operations deferred until the model is saved.
*
* @throws LdapRecordException
*/
protected function performDeferredOperations(): void {}

/**
* Inserts the model into the directory.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Testing/ConnectionFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
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/Models/Attributes/PasswordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
95 changes: 95 additions & 0 deletions tests/Unit/Models/OpenLDAP/UserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down