From a166ae5a03d80c60980ac0214cf05bf74d7c946e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 2 Dec 2025 00:59:19 +0000 Subject: [PATCH 1/3] Fix: error setting user password Fixes Update Password Bug Fixes #10878 --- app/controllers/api/users.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 8ff7c12cae..d9fbd2b44b 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -117,6 +117,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor $hashedPassword = null; $isHashed = !$hash instanceof Plaintext; + if (!empty($password)) { if (!$isHashed) { // Password was never hashed, hash it with the default hash $defaultHash = new ProofsPassword(); @@ -125,6 +126,11 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor } else { $hashedPassword = $password; } + } else { + // when password is not provided, plaintext was set as the + $defaultProof = new ProofsPassword(); + $hash = $defaultProof->getHash(); + $isHashed = !$hash instanceof Plaintext; } $user = new Document([ @@ -160,7 +166,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor 'emailIsFree' => $emailCanonical?->isFree(), ]); - if (!$isHashed) { + if (!$isHashed && !empty($password)) { $hooks->trigger('passwordValidator', [$dbForProject, $project, $plaintextPassword, &$user, true]); } @@ -2627,7 +2633,8 @@ App::post('/v1/users/:userId/jwts') $session = \count($sessions) > 0 ? $sessions[\count($sessions) - 1] : new Document(); } else { // Find by ID - foreach ($sessions as $loopSession) { /** @var Utopia\Database\Document $loopSession */ + foreach ($sessions as $loopSession) { + /** @var Utopia\Database\Document $loopSession */ if ($loopSession->getId() == $sessionId) { $session = $loopSession; break; From 1df5b71e3249a5ede13be9d26f01587e17ee948d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 2 Dec 2025 00:59:39 +0000 Subject: [PATCH 2/3] Simplify --- app/controllers/api/users.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index d9fbd2b44b..85c26ab762 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -118,18 +118,17 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor $isHashed = !$hash instanceof Plaintext; + $defaultHash = new ProofsPassword(); if (!empty($password)) { if (!$isHashed) { // Password was never hashed, hash it with the default hash - $defaultHash = new ProofsPassword(); $hashedPassword = $defaultHash->hash($password); $hash = $defaultHash->getHash(); } else { $hashedPassword = $password; } } else { - // when password is not provided, plaintext was set as the - $defaultProof = new ProofsPassword(); - $hash = $defaultProof->getHash(); + // when password is not provided, plaintext was set as the default hash causing the issue + $hash = $defaultHash->getHash(); $isHashed = !$hash instanceof Plaintext; } @@ -250,7 +249,7 @@ App::post('/v1/users') ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('email', null, new Nullable(new EmailValidator()), 'User email.', true) ->param('phone', null, new Nullable(new Phone()), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) - ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary']) + ->param('password', '', fn($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary']) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('project') @@ -1331,7 +1330,7 @@ App::patch('/v1/users/:userId/password') ] )) ->param('userId', '', new UID(), 'User ID.') - ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, enabled: $project->getAttribute('auths', [])['passwordDictionary'] ?? false, allowEmpty: true), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) + ->param('password', '', fn($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, enabled: $project->getAttribute('auths', [])['passwordDictionary'] ?? false, allowEmpty: true), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) ->inject('response') ->inject('project') ->inject('dbForProject') From e114d497898e49d9449f61f7420e53a646ac4b8d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 2 Dec 2025 01:04:51 +0000 Subject: [PATCH 3/3] Fix: add test --- app/controllers/api/users.php | 4 +-- .../Services/Users/UsersConsoleClientTest.php | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 85c26ab762..ac8938273f 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -249,7 +249,7 @@ App::post('/v1/users') ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('email', null, new Nullable(new EmailValidator()), 'User email.', true) ->param('phone', null, new Nullable(new Phone()), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) - ->param('password', '', fn($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary']) + ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary']) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('project') @@ -1330,7 +1330,7 @@ App::patch('/v1/users/:userId/password') ] )) ->param('userId', '', new UID(), 'User ID.') - ->param('password', '', fn($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, enabled: $project->getAttribute('auths', [])['passwordDictionary'] ?? false, allowEmpty: true), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) + ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, enabled: $project->getAttribute('auths', [])['passwordDictionary'] ?? false, allowEmpty: true), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) ->inject('response') ->inject('project') ->inject('dbForProject') diff --git a/tests/e2e/Services/Users/UsersConsoleClientTest.php b/tests/e2e/Services/Users/UsersConsoleClientTest.php index 967104f5db..24e0f6868b 100644 --- a/tests/e2e/Services/Users/UsersConsoleClientTest.php +++ b/tests/e2e/Services/Users/UsersConsoleClientTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\Database\Helpers\ID; class UsersConsoleClientTest extends Scope { @@ -45,4 +46,39 @@ class UsersConsoleClientTest extends Scope $this->assertIsArray($response['body']['users']); $this->assertIsArray($response['body']['sessions']); } + + public function testCreateUserWithoutPasswordThenSetPassword() + { + // Create a user with email but without password + $userId = ID::unique(); + $email = $userId . '@example.com'; + + $response = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'userId' => $userId, + 'email' => $email, + // no password provided + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['$id']); + $this->assertEquals($email, $response['body']['email']); + $this->assertEmpty($response['body']['password']); + + // Now set the password for that user (console-side) + $newPassword = 'NewPass123!'; + + $set = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'password' => $newPassword, + ]); + + $this->assertEquals(200, $set['headers']['status-code']); + $this->assertEquals($userId, $set['body']['$id']); + $this->assertNotEmpty($set['body']['password']); + } }