Skip to content

Commit aaadf9c

Browse files
committed
Add exopPassword change suport for argon2
1 parent 207cb8c commit aaadf9c

6 files changed

Lines changed: 119 additions & 20 deletions

File tree

src/Ldap.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,16 @@ public function saslBind(?string $dn = null, ?string $password = null, array $op
313313
});
314314
}
315315

316+
/**
317+
* {@inheritdoc}
318+
*/
319+
public function exopPasswd(string $dn, string $oldPassword, string $newPassword): bool
320+
{
321+
return $this->executeFailableOperation(function () use ($dn, $oldPassword, $newPassword) {
322+
return (bool) ldap_exop_passwd($this->connection, $dn, $oldPassword, $newPassword);
323+
});
324+
}
325+
316326
/**
317327
* {@inheritdoc}
318328
*/

src/LdapInterface.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,15 @@ public function bind(?string $dn = null, ?string $password = null, ?array $contr
519519
*/
520520
public function saslBind(?string $dn = null, ?string $password = null, array $options = []): bool;
521521

522+
/**
523+
* Change a password using the LDAP Password Modify extended operation (RFC 3062).
524+
*
525+
* @see https://www.php.net/manual/en/function.ldap-exop-passwd.php
526+
*
527+
* @throws LdapRecordException
528+
*/
529+
public function exopPasswd(string $dn, string $oldPassword, string $newPassword): bool;
530+
522531
/**
523532
* Adds an entry to the current connection.
524533
*

src/Models/Attributes/Password.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ public static function getSalt(string $encryptedPassword): string
244244
throw new LdapRecordException('Could not extract salt from encrypted password.');
245245
}
246246

247+
/**
248+
* Determine if the hash method requires the LDAP Password Modify extended operation (RFC 3062).
249+
*/
250+
public static function hashMethodRequiresExop(string $method): bool
251+
{
252+
return in_array(strtolower($method), ['argon2i', 'argon2id']);
253+
}
254+
247255
/**
248256
* Determine if the hash method requires a salt to be given.
249257
*

src/Models/Concerns/HasPassword.php

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,43 @@
1010
/** @mixin Model */
1111
trait HasPassword
1212
{
13+
/**
14+
* Pending argon2 password change to be executed on save via RFC 3062 extended operation.
15+
*
16+
* @var array{0: string, 1: string}|null
17+
*/
18+
protected ?array $pendingPasswordExopChange = null;
19+
20+
/**
21+
* Execute any pending argon2 password change via exop, then process remaining modifications.
22+
*/
23+
protected function performUpdate(): void
24+
{
25+
if ($this->pendingPasswordExopChange === null) {
26+
parent::performUpdate();
27+
28+
return;
29+
}
30+
31+
[$oldPassword, $newPassword] = $this->pendingPasswordExopChange;
32+
33+
$this->pendingPasswordExopChange = null;
34+
35+
$modifications = $this->getModifications();
36+
37+
$this->dispatch('updating');
38+
39+
$this->setChangedPasswordViaExop($oldPassword, $newPassword);
40+
41+
if (count($modifications)) {
42+
$this->newQuery()->update($this->dn, $modifications);
43+
}
44+
45+
$this->dispatch('updated');
46+
$this->syncChanges();
47+
$this->syncOriginal();
48+
}
49+
1350
/**
1451
* Set the password on the user.
1552
*
@@ -29,17 +66,14 @@ public function setPasswordAttribute(array|string $password): void
2966
// If the password given is an array, we can assume we
3067
// are changing the password for the current user.
3168
if (is_array($password)) {
32-
if (in_array(strtolower($method), ['argon2i', 'argon2id'])) {
33-
throw new LdapRecordException(
34-
"Argon2 passwords cannot be changed using this method. Use the LDAP Password Modify extended operation instead."
35-
);
36-
}
37-
38-
$this->setChangedPassword(
39-
$this->getHashedPassword($method, $password[0], $this->getPasswordSalt($method)),
40-
$this->getHashedPassword($method, $password[1]),
41-
$this->getPasswordAttributeName()
42-
);
69+
match (true) {
70+
Password::hashMethodRequiresExop($method) => $this->pendingPasswordExopChange = [$password[0], $password[1]],
71+
default => $this->setChangedPassword(
72+
$this->getHashedPassword($method, $password[0], $this->getPasswordSalt($method)),
73+
$this->getHashedPassword($method, $password[1]),
74+
$this->getPasswordAttributeName()
75+
),
76+
};
4377
}
4478
// Otherwise, we will assume the password is being
4579
// reset, overwriting the one currently in place.
@@ -125,6 +159,24 @@ protected function setChangedPassword(string $oldPassword, string $newPassword,
125159
);
126160
}
127161

162+
/**
163+
* Change the password using the LDAP Password Modify extended operation (RFC 3062).
164+
*
165+
* @throws LdapRecordException
166+
*/
167+
protected function setChangedPasswordViaExop(string $oldPassword, string $newPassword): void
168+
{
169+
if (! $this->exists || ($dn = $this->getDn()) === null) {
170+
throw new LdapRecordException(
171+
'Cannot change password on a model that does not exist in the directory.'
172+
);
173+
}
174+
175+
$this->getConnection()
176+
->getLdapConnection()
177+
->exopPasswd($dn, $oldPassword, $newPassword);
178+
}
179+
128180
/**
129181
* Set the password on the model.
130182
*/

src/Testing/LdapFake.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,14 @@ public function parseResult(mixed $result, int &$errorCode = 0, ?string &$dn = n
418418
: new LdapResultResponse;
419419
}
420420

421+
/**
422+
* {@inheritdoc}
423+
*/
424+
public function exopPasswd(string $dn, string $oldPassword, string $newPassword): bool
425+
{
426+
return $this->resolveExpectation(__FUNCTION__, func_get_args());
427+
}
428+
421429
/**
422430
* {@inheritdoc}
423431
*/

tests/Unit/Models/OpenLDAP/UserTest.php

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
use LdapRecord\Connection;
66
use LdapRecord\Container;
7-
use LdapRecord\LdapRecordException;
87
use LdapRecord\Models\Attributes\Password;
98
use LdapRecord\Models\OpenLDAP\User;
9+
use LdapRecord\Testing\DirectoryFake;
10+
use LdapRecord\Testing\LdapFake;
1011
use LdapRecord\Tests\TestCase;
1112

1213
class UserTest extends TestCase
@@ -76,18 +77,29 @@ public function test_algo_and_salt_is_automatically_detected_when_changing_a_use
7677
$this->assertEquals(Password::CRYPT_SALT_TYPE_SHA512, $newAlgo);
7778
}
7879

79-
public function test_changing_argon2_password_throws_exception()
80+
public function test_changing_argon2_password_uses_exop()
8081
{
81-
$this->expectException(LdapRecordException::class);
82-
$this->expectExceptionMessage('Argon2 passwords cannot be changed using this method.');
83-
84-
$user = (new OpenLDAPUserTestStub)->setRawAttributes([
85-
'userpassword' => [
86-
Password::argon2id('secret'),
87-
],
82+
DirectoryFake::setup()
83+
->getLdapConnection()
84+
->expect([
85+
LdapFake::operation('isUsingTLS')->once()->andReturnTrue(),
86+
LdapFake::operation('exopPasswd')->once()->with(
87+
fn ($dn) => $dn === 'cn=john,dc=local,dc=com',
88+
fn ($old) => $old === 'secret',
89+
fn ($new) => $new === 'new-secret',
90+
)->andReturnTrue(),
91+
]);
92+
93+
$user = new User;
94+
95+
$user->setRawAttributes([
96+
'userpassword' => [Password::argon2id('secret')],
97+
'dn' => ['cn=john,dc=local,dc=com'],
8898
]);
8999

90100
$user->password = ['secret', 'new-secret'];
101+
102+
$user->save();
91103
}
92104

93105
public function test_correct_auth_identifier_is_returned()

0 commit comments

Comments
 (0)