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
22 changes: 22 additions & 0 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,28 @@ 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) {
// Connecting as the user proves knowledge of their current
// password, and upgrades the connection with StartTLS when
// configured, before any credentials are transmitted. A
// failed bind throws, leaving the change unapplied.
$connection->connect($dn, $oldPassword);

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
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
117 changes: 115 additions & 2 deletions src/Models/Concerns/HasPassword.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@
/** @mixin Model */
trait HasPassword
{
/**
* The [old, new] password pair of a change deferred until the model is saved.
*/
protected ?array $pendingPasswordChange = null;

/**
* Set the password on the user.
*
* @throws ConnectionException
* @throws LdapRecordException
*/
public function setPasswordAttribute(array|string $password): void
{
$this->assertSecureConnection();

// Setting a password always supersedes a previously deferred change.
$this->pendingPasswordChange = null;

// Here we will attempt to determine the password hash method in use
// by parsing the users hashed password (if it as available). If a
// method is determined, we will override the default here.
Expand All @@ -29,9 +38,27 @@ 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)) {
if (! $this->exists || ! $this->getDn()) {
throw new LdapRecordException(
'A password change requires an existing model with a distinguished name.'
);
}

$this->pendingPasswordChange = [$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 +146,86 @@ 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.
*
* Overriding this to return true routes any password change through the
* RFC 3062 extended operation, letting the server hash the password.
*/
protected function passwordChangeRequiresExop(string $method): bool
{
return Password::hashMethodRequiresExop($method);
}

/**
* 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;
}

[$oldPassword, $newPassword] = $change;

$this->getConnection()->changePassword(
$this->getDn(), $oldPassword, $newPassword
);

// Cleared only once the operation has succeeded, so
// a failed change is re-attempted on a retried save.
$this->pendingPasswordChange = null;
}

/**
* Determine if the model has operations deferred until save.
*/
protected function hasDeferredOperations(): bool
{
return $this->hasPendingPasswordChange();
}

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

/**
* Discard attribute changes and reset the attributes to their original state.
*/
public function discardChanges(): static
{
$this->pendingPasswordChange = null;

return parent::discardChanges();
}

/**
* Set the model's attributes with raw LDAP result values.
*/
public function setRawAttributes(array $attributes = []): static
{
$this->pendingPasswordChange = null;

return parent::setRawAttributes($attributes);
}

/**
* Set the password on the model.
*/
Expand Down Expand Up @@ -210,6 +317,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
30 changes: 28 additions & 2 deletions src/Models/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,21 @@ public function save(array $attributes = []): void
$this->in = null;
}

/**
* Determine if the model has operations deferred until save.
*/
protected function hasDeferredOperations(): bool
{
return false;
}

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

/**
* Inserts the model into the directory.
*
Expand Down Expand Up @@ -1016,13 +1031,24 @@ protected function performInsert(): void
*/
protected function performUpdate(): void
{
if (! count($modifications = $this->getModifications())) {
$modifications = $this->getModifications();

// Deferred operations (such as argon2 password changes) cannot be
// expressed as batch modifications, but still constitute an update.
if (! count($modifications) && ! $this->hasDeferredOperations()) {
return;
}

$this->dispatch('updating');

$this->newQuery()->update($this->dn, $modifications);
// Deferred operations run first — they verify the user's current
// credentials, so they are more likely to be rejected than the
// batch modifications, and failing early avoids partial updates.
$this->performDeferredOperations();

if (count($modifications)) {
$this->newQuery()->update($this->dn, $modifications);
}

$this->dispatch('updated');

Expand Down
34 changes: 34 additions & 0 deletions src/Testing/ConnectionFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class ConnectionFake extends Connection
*/
protected bool $connected = false;

/**
* Whether the fake is a replica sharing another connection's fake LDAP connection.
*/
protected bool $replicated = false;

/**
* Make a new fake LDAP connection instance.
*/
Expand All @@ -33,6 +38,35 @@ 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
{
$replica = new static($this->configuration, $this->ldap);

$replica->replicated = true;

return $replica;
}

/**
* {@inheritdoc}
*
* Disconnecting a replica must not reset the state of
* the fake LDAP connection it shares with its parent.
*/
public function disconnect(): void
{
if (! $this->replicated) {
parent::disconnect();
}
}

/**
* 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
Loading