diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 45fb03062f..af5c8e4f53 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -140,7 +140,7 @@ App::post('/v1/account') App::post('/v1/account/sessions/email') ->alias('/v1/account/sessions') ->desc('Create Email Session') - ->groups(['api', 'account', 'auth']) + ->groups(['api', 'account', 'auth', 'session']) ->label('event', 'users.[userId].sessions.[sessionId].create') ->label('scope', 'public') ->label('auth.type', 'emailPassword') @@ -365,7 +365,7 @@ App::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') App::get('/v1/account/sessions/oauth2/:provider/redirect') ->desc('OAuth2 Redirect') - ->groups(['api', 'account']) + ->groups(['api', 'account', 'session']) ->label('error', __DIR__ . '/../../views/general/error.phtml') ->label('event', 'users.[userId].sessions.[sessionId].create') ->label('scope', 'public') @@ -739,7 +739,7 @@ App::post('/v1/account/sessions/magic-url') App::put('/v1/account/sessions/magic-url') ->desc('Create Magic URL session (confirmation)') - ->groups(['api', 'account']) + ->groups(['api', 'account', 'session']) ->label('scope', 'public') ->label('event', 'users.[userId].sessions.[sessionId].create') ->label('audits.event', 'session.update') @@ -981,7 +981,7 @@ App::post('/v1/account/sessions/phone') App::put('/v1/account/sessions/phone') ->desc('Create Phone Session (confirmation)') - ->groups(['api', 'account']) + ->groups(['api', 'account', 'session']) ->label('scope', 'public') ->label('event', 'users.[userId].sessions.[sessionId].create') ->label('usage.metric', 'sessions.{scope}.requests.create') @@ -1096,7 +1096,7 @@ App::put('/v1/account/sessions/phone') App::post('/v1/account/sessions/anonymous') ->desc('Create Anonymous Session') - ->groups(['api', 'account', 'auth']) + ->groups(['api', 'account', 'auth', 'session']) ->label('event', 'users.[userId].sessions.[sessionId].create') ->label('scope', 'public') ->label('auth.type', 'anonymous') diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 1fc60c3725..26def36fa5 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -81,7 +81,7 @@ App::post('/v1/projects') } $auth = Config::getParam('auth', []); - $auths = ['limit' => 0, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG]; + $auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG]; foreach ($auth as $index => $method) { $auths[$method['key'] ?? ''] = true; } @@ -576,6 +576,37 @@ App::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); +App::patch('/v1/projects/:projectId/auth/max-sessions') + ->desc('Update Project users limit') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'projects') + ->label('sdk.method', 'updateAuthLimit') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROJECT) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT) + ->inject('response') + ->inject('dbForConsole') + ->action(function (string $projectId, int $limit, Response $response, Database $dbForConsole) { + + $project = $dbForConsole->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['maxSessions'] = $limit; + + $dbForConsole->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + App::delete('/v1/projects/:projectId') ->desc('Delete Project') ->groups(['api', 'projects']) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index dd088c222c..fa247b8e78 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -318,6 +318,45 @@ App::init() } }); +/** + * Limit user session + * + * Delete older sessions if the number of sessions have crossed + * the session limit set for the project + */ +App::shutdown() + ->groups(['session']) + ->inject('utopia') + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('dbForProject') + ->action(function (App $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { + $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + $session = $response->getPayload(); + $userId = $session['userId'] ?? ''; + if (empty($userId)) { + return; + } + + $user = $dbForProject->getDocument('users', $userId); + if ($user->isEmpty()) { + return; + } + + $sessions = $user->getAttribute('sessions', []); + $count = \count($sessions); + if ($count <= $sessionLimit) { + return; + } + + for ($i = 0; $i < ($count - $sessionLimit); $i++) { + $session = array_shift($sessions); + $dbForProject->deleteDocument('sessions', $session->getId()); + } + $dbForProject->deleteCachedDocument('users', $userId); + }); + App::shutdown() ->groups(['api']) ->inject('utopia') diff --git a/app/init.php b/app/init.php index 6527d1cd6f..c81d7b455d 100644 --- a/app/init.php +++ b/app/init.php @@ -86,6 +86,8 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10000; +const APP_LIMIT_USER_SESSIONS_MAX = 100; +const APP_LIMIT_USER_SESSIONS_DEFAULT = 10; const APP_LIMIT_ANTIVIRUS = 20000000; //20MB const APP_LIMIT_ENCRYPTION = 20000000; //20MB const APP_LIMIT_COMPRESSION = 20000000; //20MB diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 42f360d6ab..28a9c86ff0 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -114,6 +114,12 @@ class Project extends Model 'default' => 0, 'example' => 100, ]) + ->addRule('authSessionsLimit', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Max sessions allowed per user. 100 maximum.', + 'default' => 10, + 'example' => 10, + ]) ->addRule('providers', [ 'type' => Response::MODEL_PROVIDER, 'description' => 'List of Providers.', @@ -233,6 +239,7 @@ class Project extends Model $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG); + $document->setAttribute('authSessionLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); foreach ($auth as $index => $method) { $key = $method['key']; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 413b665897..889b73d612 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -874,6 +874,124 @@ class ProjectsConsoleClientTest extends Scope return $data; } + /** + * @depends testUpdateProjectAuthLimit + */ + public function testUpdateProjectAuthSessionLimit($data): array + { + $id = $data['projectId'] ?? ''; + + /** + * Test for failure + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 0, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 1, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['$id']); + + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Create new user + */ + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionId1 = $response['body']['$id']; + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionCookie = $response['headers']['set-cookie']; + $sessionId2 = $response['body']['$id']; + + // request was called in parallel and test failed + sleep(5); + + /** + * List sessions + */ + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'Cookie' => $sessionCookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $sessions = $response['body']['sessions']; + + $this->assertEquals(1, count($sessions)); + $this->assertEquals($sessionId2, $sessions[0]['$id']); + + /** + * Reset Limit + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 10, + ]); + + return $data; + } + public function testUpdateProjectServiceStatusAdmin(): array { $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([