From fde2f278e8a764fa556835cfef3e95161816f8dc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sat, 14 Jun 2025 18:07:42 +0530 Subject: [PATCH 01/10] added invalidating sessions for the project users --- app/controllers/api/projects.php | 41 ++++++++++++++++++- app/controllers/api/users.php | 11 +++++ .../update-auth-on-password-change.md | 1 + .../Utopia/Response/Model/Project.php | 7 ++++ tests/e2e/Scopes/ProjectCustom.php | 13 ++++++ .../Projects/ProjectsConsoleClientTest.php | 25 +++++++++++ tests/e2e/Services/Users/UsersBase.php | 13 +++++- 7 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 docs/references/databases/update-auth-on-password-change.md diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 51cbc097f5..cbdc1c5e05 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -94,13 +94,14 @@ App::post('/v1/projects') ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) + ->param('onPasswordChange', false, new Boolean(), 'For invalding sessions', true) ->inject('request') ->inject('response') ->inject('dbForPlatform') ->inject('cache') ->inject('pools') ->inject('hooks') - ->action(function (string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) { + ->action(function (string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, $onPasswordChange, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) { $team = $dbForPlatform->getDocument('teams', $teamId); @@ -127,6 +128,7 @@ App::post('/v1/projects') 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, + 'onPasswordChange' => $onPasswordChange ]; foreach ($auth as $method) { @@ -2499,3 +2501,40 @@ App::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); }); + +App::patch('/v1/projects/:projectId/auth/password-change') +->desc('Update on password change of the project') +->groups(['api', 'projects']) +->label('scope', 'projects.write') +->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateOnPasswordChange', + description: '/docs/references/projects/update-auth-on-password-change.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] +)) +->param('projectId', '', new UID(), 'Project unique ID.') +->param('onPasswordChange', false, new Boolean(), 'For invalidating project session') +->inject('response') +->inject('dbForPlatform') +->action(function (string $projectId, bool $onPasswordChange, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['onPasswordChange'] = $onPasswordChange; + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); +}); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index d7def69464..d9dd951c09 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1351,6 +1351,17 @@ App::patch('/v1/users/:userId/password') $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $sessions = $user->getAttribute('sessions', []); + $onPasswordChange = $project->getAttribute('auths', [])['onPasswordChange']; + if ($onPasswordChange) { + foreach ($sessions as $session) { + /** @var Document $session */ + $dbForProject->deleteDocument('sessions', $session->getId()); + } + } + + $dbForProject->purgeCachedDocument('users', $user->getId()); + $queueForEvents->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); diff --git a/docs/references/databases/update-auth-on-password-change.md b/docs/references/databases/update-auth-on-password-change.md new file mode 100644 index 0000000000..1daf0a69df --- /dev/null +++ b/docs/references/databases/update-auth-on-password-change.md @@ -0,0 +1 @@ +On password change. Should be an optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index efd002654e..c9f900933f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -271,6 +271,12 @@ class Project extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('onPasswordChange', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'For invalidating all sessions', + 'default' => false, + 'example' => self::TYPE_BOOLEAN, + ]) ; $services = Config::getParam('services', []); @@ -376,6 +382,7 @@ class Project extends Model $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('onPasswordChange', $authValues['onPasswordChange'] ?? false); foreach ($auth as $index => $method) { $key = $method['key']; diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a354696f53..0225805054 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -200,4 +200,17 @@ trait ProjectCustom return $key['body']['secret']; } + public function updateProjectOnPasswordChangeProperty(bool $value) + { + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . self::$project['$id'] . '/auth/password-change', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]), [ + 'onPasswordChange' => $value, + ]); + + return $response['headers']['status-code']; + } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index aea1971be7..d77e330e6a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -951,6 +951,31 @@ class ProjectsConsoleClientTest extends Scope return ['projectId' => $projectId]; } + /** @depends testCreateProject */ + public function testUpdateProjectOnPasswordChange($data): array + { + $id = $data['projectId']; + + // Check defaults + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['onPasswordChange']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-change', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'onPasswordChange' => true, + ]); + $this->assertTrue($response['body']['onPasswordChange']); + + return $data; + } + /** * @depends testCreateProject */ diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 00e999672f..391f31da41 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -1117,7 +1117,7 @@ trait UsersBase ]); $this->assertEquals(401, $session['headers']['status-code']); - + $this->updateProjectOnPasswordChangeProperty(true); $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/password', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1129,6 +1129,15 @@ trait UsersBase $this->assertNotEmpty($user['body']['$id']); $this->assertNotEmpty($user['body']['password']); + $sessions = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($sessions['headers']['status-code'], 200); + $this->assertIsArray($sessions['body']); + $this->assertEmpty($sessions['body']['sessions']); + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1138,7 +1147,7 @@ trait UsersBase ]); $this->assertEquals($session['headers']['status-code'], 201); - + $this->updateProjectOnPasswordChangeProperty(false); return $data; } From 74614e8d58c9244c5f50ed33d977e1b4f5fad05b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sat, 14 Jun 2025 18:09:20 +0530 Subject: [PATCH 02/10] updated description of on password change --- app/controllers/api/projects.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index cbdc1c5e05..c46dc4f603 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -94,7 +94,7 @@ App::post('/v1/projects') ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) - ->param('onPasswordChange', false, new Boolean(), 'For invalding sessions', true) + ->param('onPasswordChange', false, new Boolean(), 'Auth option to allow invalidating existing sessions', true) ->inject('request') ->inject('response') ->inject('dbForPlatform') @@ -2520,7 +2520,7 @@ App::patch('/v1/projects/:projectId/auth/password-change') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') -->param('onPasswordChange', false, new Boolean(), 'For invalidating project session') +->param('onPasswordChange', false, new Boolean(), 'Auth option to allow invalidating existing sessions') ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, bool $onPasswordChange, Response $response, Database $dbForPlatform) { From 774291b5d8dc0742ab7470f9de5605f27fa031c8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sat, 14 Jun 2025 18:15:41 +0530 Subject: [PATCH 03/10] added onPasswordChange property to the console project --- app/config/console.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/config/console.php b/app/config/console.php index 1de3a99370..5c43aa22f6 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -39,7 +39,8 @@ $console = [ 'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds - 'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled' + 'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled', + 'onPasswordChange' => true ], 'authWhitelistEmails' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [], 'authWhitelistIPs' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null)) : [], From 7a0ba95231560f80c610767b1dbeb63d0a104088 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 16 Jun 2025 23:05:52 +0530 Subject: [PATCH 04/10] updated onPasswordChange to invalidate session --- app/config/console.php | 2 +- app/controllers/api/projects.php | 19 +++++++++---------- app/controllers/api/users.php | 4 ++-- .../databases/session-invalidation.md | 1 + .../update-auth-on-password-change.md | 1 - .../Utopia/Response/Model/Project.php | 4 ++-- tests/e2e/Scopes/ProjectCustom.php | 6 +++--- .../Projects/ProjectsConsoleClientTest.php | 10 +++++----- tests/e2e/Services/Users/UsersBase.php | 4 ++-- 9 files changed, 25 insertions(+), 26 deletions(-) create mode 100644 docs/references/databases/session-invalidation.md delete mode 100644 docs/references/databases/update-auth-on-password-change.md diff --git a/app/config/console.php b/app/config/console.php index 5c43aa22f6..aa7de13ba0 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -40,7 +40,7 @@ $console = [ 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds 'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled', - 'onPasswordChange' => true + 'invalidateSessions' => true ], 'authWhitelistEmails' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [], 'authWhitelistIPs' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null)) : [], diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index c46dc4f603..116c32fd58 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -94,14 +94,13 @@ App::post('/v1/projects') ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) - ->param('onPasswordChange', false, new Boolean(), 'Auth option to allow invalidating existing sessions', true) ->inject('request') ->inject('response') ->inject('dbForPlatform') ->inject('cache') ->inject('pools') ->inject('hooks') - ->action(function (string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, $onPasswordChange, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) { + ->action(function (string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) { $team = $dbForPlatform->getDocument('teams', $teamId); @@ -128,7 +127,7 @@ App::post('/v1/projects') 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, - 'onPasswordChange' => $onPasswordChange + 'invalidateSessions' => true ]; foreach ($auth as $method) { @@ -2502,15 +2501,15 @@ App::delete('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -App::patch('/v1/projects/:projectId/auth/password-change') -->desc('Update on password change of the project') +App::patch('/v1/projects/:projectId/auth/session-invalidation') +->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) ->label('scope', 'projects.write') ->label('sdk', new Method( namespace: 'projects', group: 'auth', - name: 'updateOnPasswordChange', - description: '/docs/references/projects/update-auth-on-password-change.md', + name: 'updateInvalidateSessions', + description: '/docs/references/projects/session-invalidation.md', auth: [AuthType::ADMIN], responses: [ new SDKResponse( @@ -2520,10 +2519,10 @@ App::patch('/v1/projects/:projectId/auth/password-change') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') -->param('onPasswordChange', false, new Boolean(), 'Auth option to allow invalidating existing sessions') +->param('invalidateSessions', false, new Boolean(), 'Auth option to allow invalidating existing sessions') ->inject('response') ->inject('dbForPlatform') -->action(function (string $projectId, bool $onPasswordChange, Response $response, Database $dbForPlatform) { +->action(function (string $projectId, bool $invalidateSessions, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -2532,7 +2531,7 @@ App::patch('/v1/projects/:projectId/auth/password-change') } $auths = $project->getAttribute('auths', []); - $auths['onPasswordChange'] = $onPasswordChange; + $auths['invalidateSessions'] = $invalidateSessions; $dbForPlatform->updateDocument('projects', $project->getId(), $project ->setAttribute('auths', $auths)); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index d9dd951c09..0b580170e1 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1352,8 +1352,8 @@ App::patch('/v1/users/:userId/password') $user = $dbForProject->updateDocument('users', $user->getId(), $user); $sessions = $user->getAttribute('sessions', []); - $onPasswordChange = $project->getAttribute('auths', [])['onPasswordChange']; - if ($onPasswordChange) { + $invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false; + if ($invalidate) { foreach ($sessions as $session) { /** @var Document $session */ $dbForProject->deleteDocument('sessions', $session->getId()); diff --git a/docs/references/databases/session-invalidation.md b/docs/references/databases/session-invalidation.md new file mode 100644 index 0000000000..cbaf378624 --- /dev/null +++ b/docs/references/databases/session-invalidation.md @@ -0,0 +1 @@ +Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/docs/references/databases/update-auth-on-password-change.md b/docs/references/databases/update-auth-on-password-change.md deleted file mode 100644 index 1daf0a69df..0000000000 --- a/docs/references/databases/update-auth-on-password-change.md +++ /dev/null @@ -1 +0,0 @@ -On password change. Should be an optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index c9f900933f..a944c4e6e1 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -271,7 +271,7 @@ class Project extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) - ->addRule('onPasswordChange', [ + ->addRule('invalidateSessions', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'For invalidating all sessions', 'default' => false, @@ -382,7 +382,7 @@ class Project extends Model $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); - $document->setAttribute('onPasswordChange', $authValues['onPasswordChange'] ?? false); + $document->setAttribute('invalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $index => $method) { $key = $method['key']; diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 0225805054..5b079a6482 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -200,15 +200,15 @@ trait ProjectCustom return $key['body']['secret']; } - public function updateProjectOnPasswordChangeProperty(bool $value) + public function updateProjectinvalidateSessionsProperty(bool $value) { - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . self::$project['$id'] . '/auth/password-change', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . self::$project['$id'] . '/auth/session-invalidation', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], 'x-appwrite-project' => 'console', ]), [ - 'onPasswordChange' => $value, + 'invalidateSessions' => $value, ]); return $response['headers']['status-code']; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index d77e330e6a..5b7043805a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -952,7 +952,7 @@ class ProjectsConsoleClientTest extends Scope } /** @depends testCreateProject */ - public function testUpdateProjectOnPasswordChange($data): array + public function testUpdateProjectInvalidateSessions($data): array { $id = $data['projectId']; @@ -963,15 +963,15 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertFalse($response['body']['onPasswordChange']); + $this->assertFalse($response['body']['invalidateSessions']); - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-change', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'onPasswordChange' => true, + 'invalidateSessions' => true, ]); - $this->assertTrue($response['body']['onPasswordChange']); + $this->assertTrue($response['body']['invalidateSessions']); return $data; } diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 391f31da41..0aa5784930 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -1117,7 +1117,7 @@ trait UsersBase ]); $this->assertEquals(401, $session['headers']['status-code']); - $this->updateProjectOnPasswordChangeProperty(true); + $this->updateProjectinvalidateSessionsProperty(true); $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/password', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1147,7 +1147,7 @@ trait UsersBase ]); $this->assertEquals($session['headers']['status-code'], 201); - $this->updateProjectOnPasswordChangeProperty(false); + $this->updateProjectinvalidateSessionsProperty(false); return $data; } From 9bf4361761bd7de79f3ea8bf9dcb9c8de465a43e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 16 Jun 2025 23:48:08 +0530 Subject: [PATCH 05/10] updated indentation --- app/controllers/api/projects.php | 62 ++++++++++++++++---------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 116c32fd58..8fca702850 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -2502,38 +2502,38 @@ App::delete('/v1/projects/:projectId/templates/email/:type/:locale') }); App::patch('/v1/projects/:projectId/auth/session-invalidation') -->desc('Update invalidate session option of the project') -->groups(['api', 'projects']) -->label('scope', 'projects.write') -->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateInvalidateSessions', - description: '/docs/references/projects/session-invalidation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] -)) -->param('projectId', '', new UID(), 'Project unique ID.') -->param('invalidateSessions', false, new Boolean(), 'Auth option to allow invalidating existing sessions') -->inject('response') -->inject('dbForPlatform') -->action(function (string $projectId, bool $invalidateSessions, Response $response, Database $dbForPlatform) { + ->desc('Update invalidate session option of the project') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateInvalidateSessions', + description: '/docs/references/projects/session-invalidation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('invalidateSessions', false, new Boolean(), 'Auth option to allow invalidating existing sessions') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, bool $invalidateSessions, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); + $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } - $auths = $project->getAttribute('auths', []); - $auths['invalidateSessions'] = $invalidateSessions; - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $invalidateSessions; + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); - $response->dynamic($project, Response::MODEL_PROJECT); -}); + $response->dynamic($project, Response::MODEL_PROJECT); + }); From 28fbd648645084c67d73300643441d14154da47f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 17 Jun 2025 00:40:32 +0530 Subject: [PATCH 06/10] updated session invalidation tests , models and descriptions --- app/controllers/api/projects.php | 6 ++-- .../update-session-invalidation.md} | 0 .../Utopia/Response/Model/Project.php | 14 ++++----- tests/e2e/Scopes/ProjectCustom.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 30 +++++++++++++++++-- 5 files changed, 38 insertions(+), 14 deletions(-) rename docs/references/{databases/session-invalidation.md => projects/update-session-invalidation.md} (100%) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 8fca702850..42bb6c3bcb 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -2508,8 +2508,8 @@ App::patch('/v1/projects/:projectId/auth/session-invalidation') ->label('sdk', new Method( namespace: 'projects', group: 'auth', - name: 'updateInvalidateSessions', - description: '/docs/references/projects/session-invalidation.md', + name: 'updateSessionInvalidation', + description: '/docs/references/projects/update-session-invalidation.md', auth: [AuthType::ADMIN], responses: [ new SDKResponse( @@ -2519,7 +2519,7 @@ App::patch('/v1/projects/:projectId/auth/session-invalidation') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('invalidateSessions', false, new Boolean(), 'Auth option to allow invalidating existing sessions') + ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change') ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, bool $invalidateSessions, Response $response, Database $dbForPlatform) { diff --git a/docs/references/databases/session-invalidation.md b/docs/references/projects/update-session-invalidation.md similarity index 100% rename from docs/references/databases/session-invalidation.md rename to docs/references/projects/update-session-invalidation.md diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index a944c4e6e1..ef50d81c31 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -169,6 +169,12 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authInvalidateSessions', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not all existing sessions should be invalidated on password change', + 'default' => false, + 'example' => self::TYPE_BOOLEAN, + ]) ->addRule('oAuthProviders', [ 'type' => Response::MODEL_AUTH_PROVIDER, 'description' => 'List of Auth Providers.', @@ -271,12 +277,6 @@ class Project extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) - ->addRule('invalidateSessions', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'For invalidating all sessions', - 'default' => false, - 'example' => self::TYPE_BOOLEAN, - ]) ; $services = Config::getParam('services', []); @@ -382,7 +382,7 @@ class Project extends Model $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); - $document->setAttribute('invalidateSessions', $authValues['invalidateSessions'] ?? false); + $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $index => $method) { $key = $method['key']; diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 5b079a6482..51aebeaef7 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -208,7 +208,7 @@ trait ProjectCustom 'cookie' => 'a_session_console=' . $this->getRoot()['session'], 'x-appwrite-project' => 'console', ]), [ - 'invalidateSessions' => $value, + 'enabled' => $value, ]); return $response['headers']['status-code']; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 5b7043805a..1a9cf3b586 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -963,15 +963,39 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertFalse($response['body']['invalidateSessions']); + $this->assertTrue($response['body']['authInvalidateSessions']); $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'invalidateSessions' => true, + 'enabled' => false, ]); - $this->assertTrue($response['body']['invalidateSessions']); + $this->assertFalse($response['body']['authInvalidateSessions']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authInvalidateSessions']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + ]); + $this->assertTrue($response['body']['authInvalidateSessions']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authInvalidateSessions']); return $data; } From 8d62ada545ea072aa72447a242f3600f07734601 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 18 Jun 2025 10:54:26 +0530 Subject: [PATCH 07/10] updated example of the authInvalidateSession --- src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index ef50d81c31..abe67e7e86 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -173,7 +173,7 @@ class Project extends Model 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not all existing sessions should be invalidated on password change', 'default' => false, - 'example' => self::TYPE_BOOLEAN, + 'example' => true, ]) ->addRule('oAuthProviders', [ 'type' => Response::MODEL_AUTH_PROVIDER, From 56494ca427f39e9baf1a646a5e1fafc4fff5aafe Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 18 Jun 2025 13:30:24 +0530 Subject: [PATCH 08/10] updated invalidation session during the change password in the account endpoint --- app/controllers/api/account.php | 9 ++++++ .../Account/AccountCustomClientTest.php | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0c53423325..1030a30e93 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2843,6 +2843,15 @@ App::patch('/v1/account/password') ->setAttribute('hash', Auth::DEFAULT_ALGO) ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + $sessions = $user->getAttribute('sessions', []); + $current = Auth::sessionVerify($sessions, Auth::$secret); + foreach ($sessions as $session) { + /** @var Document $session */ + if ($session->getId() !== $current) { + $dbForProject->deleteDocument('sessions', $session->getId()); + } + } + $user = $dbForProject->updateDocument('users', $user->getId(), $user); $queueForEvents->setParam('userId', $user->getId()); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 0cc2eb893a..f99b3a42a2 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -480,6 +480,20 @@ class AccountCustomClientTest extends Scope $password = $data['password'] ?? ''; $session = $data['session'] ?? ''; + for ($i = 0; $i < 5; $i++) { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + sleep(1); + } + /** * Test for SUCCESS */ @@ -500,6 +514,21 @@ class AccountCustomClientTest extends Scope $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); + // checking for all non active sessions are cleared + $sessionId = $data['sessionId'] ?? ''; + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + // checking the current session or not + $this->assertEquals($sessionId, $response['body']['sessions'][0]['$id']); + $this->assertTrue($response['body']['sessions'][0]['current']); + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', From 225b6e841302999db5e7ef9c93c801bce100db24 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 19 Jun 2025 02:26:58 +0530 Subject: [PATCH 09/10] updated tests and project invaldate session --- app/controllers/api/account.php | 11 +- .../Account/AccountCustomClientTest.php | 119 ++++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 1030a30e93..d8e3be443d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2845,10 +2845,13 @@ App::patch('/v1/account/password') $sessions = $user->getAttribute('sessions', []); $current = Auth::sessionVerify($sessions, Auth::$secret); - foreach ($sessions as $session) { - /** @var Document $session */ - if ($session->getId() !== $current) { - $dbForProject->deleteDocument('sessions', $session->getId()); + $invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false; + if ($invalidate && !empty($current)) { + foreach ($sessions as $session) { + /** @var Document $session */ + if ($session->getId() !== $current) { + $dbForProject->deleteDocument('sessions', $session->getId()); + } } } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index f99b3a42a2..9e75106606 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -494,6 +494,15 @@ class AccountCustomClientTest extends Scope sleep(1); } + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $allSessions = array_map(fn ($sessionDetails) => $sessionDetails['$id'], $response['body']['sessions']); + /** * Test for SUCCESS */ @@ -514,8 +523,7 @@ class AccountCustomClientTest extends Scope $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); - // checking for all non active sessions are cleared - $sessionId = $data['sessionId'] ?? ''; + $currentSessionId = $data['sessionId'] ?? ''; $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', @@ -526,19 +534,116 @@ class AccountCustomClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); // checking the current session or not - $this->assertEquals($sessionId, $response['body']['sessions'][0]['$id']); + $this->assertEquals($currentSessionId, $response['body']['sessions'][0]['$id']); $this->assertTrue($response['body']['sessions'][0]['current']); - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + // checking for all non active sessions are cleared + foreach ($allSessions as $sessionId) { + if ($currentSessionId === $sessionId) { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + } else { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/'.$sessionId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(404, $response['headers']['status-code']); + } + } + + $newPassword = 'new-password'; + // updating the invalidateSession to false to check sessions are not invalidated + $this->updateProjectinvalidateSessionsProperty(false); + for ($i = 0; $i < 5; $i++) { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $newPassword, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + sleep(1); + } + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $allSessions = array_map(fn ($sessionDetails) => $sessionDetails['$id'], $response['body']['sessions']); + + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ - 'email' => $email, - 'password' => 'new-password', + 'password' => $newPassword, + 'oldPassword' => $newPassword, ]); - $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(200, $response['headers']['status-code']); + + foreach ($allSessions as $sessionId) { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/'.$sessionId, headers: array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + } + + // setting invalidateSession to true to check the sessions are cleared or not + $this->updateProjectinvalidateSessionsProperty(true); + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ]), [ + 'password' => $newPassword, + 'oldPassword' => $newPassword, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $allSessions = array_map(fn ($sessionDetails) => $sessionDetails['$id'], $response['body']['sessions']); + + foreach ($allSessions as $sessionId) { + if ($currentSessionId !== $sessionId) { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/'.$sessionId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(404, $response['headers']['status-code']); + } + } /** * Test for FAILURE From d053282bcbe0c3fc5ab7f1515419b625190531ee Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 19 Jun 2025 02:29:21 +0530 Subject: [PATCH 10/10] linting --- .../e2e/Services/Account/AccountCustomClientTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 9e75106606..b114a54c30 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -645,6 +645,18 @@ class AccountCustomClientTest extends Scope } } + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $newPassword, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + /** * Test for FAILURE */