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