From 2befa60350ca7facbe8eef45d02a00ff8cd469b4 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Thu, 6 Jul 2023 17:12:39 -0700 Subject: [PATCH] Track a user's last activity A user will have an accessedAt timestamp that will update at most once per day if they make some API call. This timestamp can then be used find active users and calculate daily, weekly, and monthly active users. To ensure consistent updates to the user the $user from the resource is always updated to the user making the request, including requests like Create Account, Update Team Membership Status, and Create Phone Session (confirmation). This ensures the shutdown can update the accessedAt timestamp if there was a $user set. --- app/config/collections.php | 22 ++- app/controllers/api/account.php | 151 +++++++++++------- app/controllers/api/teams.php | 4 +- app/controllers/shared/api.php | 33 ++-- app/init.php | 1 + composer.json | 6 +- composer.lock | 56 +++---- src/Appwrite/Utopia/Response/Model/User.php | 6 + tests/e2e/Services/Account/AccountBase.php | 19 +++ .../Account/AccountCustomClientTest.php | 14 ++ .../Webhooks/WebhooksCustomClientTest.php | 8 +- 11 files changed, 215 insertions(+), 105 deletions(-) diff --git a/app/config/collections.php b/app/config/collections.php index b59239957b..6a608fe5f5 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1463,7 +1463,18 @@ $collections = [ 'default' => null, 'array' => false, 'filters' => ['userSearch'], - ] + ], + [ + '$id' => ID::custom('accessedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], ], 'indexes' => [ [ @@ -1528,7 +1539,14 @@ $collections = [ 'attributes' => ['search'], 'lengths' => [], 'orders' => [], - ] + ], + [ + '$id' => '_key_accessedAt', + 'type' => Database::INDEX_KEY, + 'attributes' => ['accessedAt'], + 'lengths' => [], + 'orders' => [], + ], ], ], diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 2c86b83614..a96f537f76 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -70,10 +70,11 @@ App::post('/v1/account') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('request') ->inject('response') + ->inject('user') ->inject('project') ->inject('dbForProject') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) { + ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events) { $email = \strtolower($email); if ('console' === $project->getId()) { $whitelistEmails = $project->getAttribute('authWhitelistEmails'); @@ -102,7 +103,7 @@ App::post('/v1/account') $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); try { $userId = $userId == 'unique()' ? ID::unique() : $userId; - $user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ + $user->setAttributes([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -124,8 +125,10 @@ App::post('/v1/account') 'sessions' => null, 'tokens' => null, 'memberships' => null, - 'search' => implode(' ', [$userId, $email, $name]) - ]))); + 'search' => implode(' ', [$userId, $email, $name]), + 'accessedAt' => DateTime::now(), // Add this here to make sure it's returned in the response + ]); + Authorization::skip(fn() => $dbForProject->createDocument('users', $user)); } catch (Duplicate $th) { throw new Exception(Exception::USER_ALREADY_EXISTS); } @@ -166,12 +169,13 @@ App::post('/v1/account/sessions/email') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') ->inject('request') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('project') ->inject('locale') ->inject('geodb') ->inject('events') - ->action(function (string $email, string $password, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { + ->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -188,6 +192,8 @@ App::post('/v1/account/sessions/email') throw new Exception(Exception::USER_BLOCKED); // User is in status blocked } + $user->setAttributes($profile->getArrayCopy()); + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); @@ -197,8 +203,8 @@ App::post('/v1/account/sessions/email') $session = new Document(array_merge( [ '$id' => ID::unique(), - 'userId' => $profile->getId(), - 'userInternalId' => $profile->getInternalId(), + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), 'provider' => Auth::SESSION_PROVIDER_EMAIL, 'providerUid' => $email, 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak @@ -211,35 +217,35 @@ App::post('/v1/account/sessions/email') $detector->getDevice() )); - Authorization::setRole(Role::user($profile->getId())->toString()); + Authorization::setRole(Role::user($user->getId())->toString()); // Re-hash if not using recommended algo - if ($profile->getAttribute('hash') !== Auth::DEFAULT_ALGO) { - $profile + if ($user->getAttribute('hash') !== Auth::DEFAULT_ALGO) { + $user ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) ->setAttribute('hash', Auth::DEFAULT_ALGO) ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); - $dbForProject->updateDocument('users', $profile->getId(), $profile); + $dbForProject->updateDocument('users', $user->getId(), $user); } - $dbForProject->deleteCachedDocument('users', $profile->getId()); + $dbForProject->deleteCachedDocument('users', $user->getId()); $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ - Permission::read(Role::user($profile->getId())), - Permission::update(Role::user($profile->getId())), - Permission::delete(Role::user($profile->getId())), + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), ])); if (!Config::getParam('domainVerification')) { $response - ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($profile->getId(), $secret)])) + ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) ; } $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($profile->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($profile->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -252,7 +258,7 @@ App::post('/v1/account/sessions/email') ; $events - ->setParam('userId', $profile->getId()) + ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()) ; @@ -476,10 +482,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } } - $user = ($user->isEmpty()) ? $dbForProject->findOne('sessions', [ // Get user by provider id - Query::equal('provider', [$provider]), - Query::equal('providerUid', [$oauth2ID]), - ]) : $user; + if ($user->isEmpty()) { + $session = $dbForProject->findOne('sessions', [ // Get user by provider id + Query::equal('provider', [$provider]), + Query::equal('providerUid', [$oauth2ID]), + ]); + if ($session !== false && !$session->isEmpty()) { + $user->setAttributes($dbForProject->getDocument('users', $session->getAttribute('userId'))->getArrayCopy()); + } + } if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email $name = $oauth2->getUserName($accessToken); @@ -490,9 +501,12 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') */ $isVerified = $oauth2->isEmailVerified($accessToken); - $user = $dbForProject->findOne('users', [ + $userWithEmail = $dbForProject->findOne('users', [ Query::equal('email', [$email]), ]); + if ($userWithEmail !== false && !$userWithEmail->isEmpty()) { + $user->setAttributes($userWithEmail->getArrayCopy()); + } if ($user === false || $user->isEmpty()) { // Last option -> create the user, generate random password $limit = $project->getAttribute('auths', [])['limit'] ?? 0; @@ -510,7 +524,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') try { $userId = ID::unique(); $password = Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); - $user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ + $user->setAttributes([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -533,7 +547,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $name]) - ]))); + ]); + Authorization::skip(fn() => $dbForProject->createDocument('users', $user)); } catch (Duplicate $th) { throw new Exception(Exception::USER_ALREADY_EXISTS); } @@ -645,12 +660,13 @@ App::post('/v1/account/sessions/magic-url') ->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients']) ->inject('request') ->inject('response') + ->inject('user') ->inject('project') ->inject('dbForProject') ->inject('locale') ->inject('events') ->inject('mails') - ->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $project, Database $dbForProject, Locale $locale, Event $events, Mail $mails) { + ->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $events, Mail $mails) { if (empty(App::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); @@ -660,9 +676,10 @@ App::post('/v1/account/sessions/magic-url') $isPrivilegedUser = Auth::isPrivilegedUser($roles); $isAppUser = Auth::isAppUser($roles); - $user = $dbForProject->findOne('users', [Query::equal('email', [$email])]); - - if (!$user) { + $result = $dbForProject->findOne('users', [Query::equal('email', [$email])]); + if ($result !== false && !$result->isEmpty()) { + $user->setAttributes($result->getArrayCopy()); + } else { $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -675,7 +692,7 @@ App::post('/v1/account/sessions/magic-url') $userId = $userId == 'unique()' ? ID::unique() : $userId; - $user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ + $user->setAttributes([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -696,7 +713,9 @@ App::post('/v1/account/sessions/magic-url') 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email]) - ]))); + ]); + + Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); } $loginSecret = Auth::tokenGenerator(); @@ -808,27 +827,30 @@ App::put('/v1/account/sessions/magic-url') ->param('secret', '', new Text(256), 'Valid verification token.') ->inject('request') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('project') ->inject('locale') ->inject('geodb') ->inject('events') - ->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { + ->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { /** @var Utopia\Database\Document $user */ - $user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); + $userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); - if ($user->isEmpty()) { + if ($userFromRequest->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $token = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret); + $token = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret); if (!$token) { throw new Exception(Exception::USER_INVALID_TOKEN); } + $user->setAttributes($userFromRequest->getArrayCopy()); + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); @@ -873,9 +895,9 @@ App::put('/v1/account/sessions/magic-url') $user->setAttribute('emailVerification', true); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); - - if (false === $user) { + try { + $dbForProject->updateDocument('users', $user->getId(), $user); + } catch (\Throwable $th) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); } @@ -928,12 +950,13 @@ App::post('/v1/account/sessions/phone') ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.') ->inject('request') ->inject('response') + ->inject('user') ->inject('project') ->inject('dbForProject') ->inject('events') ->inject('messaging') ->inject('locale') - ->action(function (string $userId, string $phone, Request $request, Response $response, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) { + ->action(function (string $userId, string $phone, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) { if (empty(App::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); @@ -943,9 +966,10 @@ App::post('/v1/account/sessions/phone') $isPrivilegedUser = Auth::isPrivilegedUser($roles); $isAppUser = Auth::isAppUser($roles); - $user = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]); - - if (!$user) { + $result = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]); + if ($result !== false && !$result->isEmpty()) { + $user->setAttributes($result->getArrayCopy()); + } else { $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -957,8 +981,7 @@ App::post('/v1/account/sessions/phone') } $userId = $userId == 'unique()' ? ID::unique() : $userId; - - $user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ + $user->setAttributes([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -979,7 +1002,9 @@ App::post('/v1/account/sessions/phone') 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $phone]) - ]))); + ]); + + Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); } $secret = Auth::codeGenerator(); @@ -1058,25 +1083,28 @@ App::put('/v1/account/sessions/phone') ->param('secret', '', new Text(256), 'Valid verification token.') ->inject('request') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('project') ->inject('locale') ->inject('geodb') ->inject('events') - ->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { + ->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { - $user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); + $userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); - if ($user->isEmpty()) { + if ($userFromRequest->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $token = Auth::phoneTokenVerify($user->getAttribute('tokens', []), $secret); + $token = Auth::phoneTokenVerify($userFromRequest->getAttribute('tokens', []), $secret); if (!$token) { throw new Exception(Exception::USER_INVALID_TOKEN); } + $user->setAttributes($userFromRequest->getArrayCopy()); + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); @@ -1119,7 +1147,7 @@ App::put('/v1/account/sessions/phone') $user->setAttribute('phoneVerification', true); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), $user); if (false === $user) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); @@ -1204,7 +1232,7 @@ App::post('/v1/account/sessions/anonymous') } $userId = ID::unique(); - $user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ + $user->setAttributes([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -1225,8 +1253,9 @@ App::post('/v1/account/sessions/anonymous') 'sessions' => null, 'tokens' => null, 'memberships' => null, - 'search' => $userId - ]))); + 'search' => $userId, + ]); + Authorization::skip(fn() => $dbForProject->createDocument('users', $user)); // Create session token $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; @@ -2068,12 +2097,13 @@ App::post('/v1/account/recovery') ->param('url', '', fn ($clients) => new Host($clients), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['clients']) ->inject('request') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('project') ->inject('locale') ->inject('mails') ->inject('events') - ->action(function (string $email, string $url, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) { + ->action(function (string $email, string $url, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) { if (empty(App::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -2093,6 +2123,8 @@ App::post('/v1/account/recovery') throw new Exception(Exception::USER_NOT_FOUND); } + $user->setAttributes($profile->getArrayCopy()); + if (false === $profile->getAttribute('status')) { // Account is blocked throw new Exception(Exception::USER_BLOCKED); } @@ -2207,9 +2239,10 @@ App::put('/v1/account/recovery') ->param('password', '', new Password(), 'New user password. Must be at least 8 chars.') ->param('passwordAgain', '', new Password(), 'Repeat new user password. Must be at least 8 chars.') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('events') - ->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Database $dbForProject, Event $events) { + ->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Document $user, Database $dbForProject, Event $events) { if ($password !== $passwordAgain) { throw new Exception(Exception::USER_PASSWORD_MISMATCH); } @@ -2236,6 +2269,8 @@ App::put('/v1/account/recovery') ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) ->setAttribute('emailVerification', true)); + $user->setAttributes($profile->getArrayCopy()); + $recoveryDocument = $dbForProject->getDocument('tokens', $recovery); /** @@ -2417,6 +2452,8 @@ App::put('/v1/account/verification') $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('emailVerification', true)); + $user->setAttributes($profile->getArrayCopy()); + $verificationDocument = $dbForProject->getDocument('tokens', $verification); /** @@ -2573,6 +2610,8 @@ App::put('/v1/account/verification/phone') $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('phoneVerification', true)); + $user->setAttributes($profile->getArrayCopy()); + $verificationDocument = $dbForProject->getDocument('tokens', $verification); /** diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index d08d83f8fa..4687cc0f98 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -867,7 +867,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') } if ($user->isEmpty()) { - $user = $dbForProject->getDocument('users', $userId); // Get user + $user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user } if ($membership->getAttribute('userId') !== $user->getId()) { @@ -883,7 +883,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ->setAttribute('confirm', true) ; - $user = Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); + Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); // Log user in diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 97ec83e8c6..c89bf0252e 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -169,9 +169,9 @@ App::init() } } - /* - * Background Jobs - */ + /* + * Background Jobs + */ $events ->setEvent($route->getLabel('event', '')) ->setProject($project) @@ -369,6 +369,7 @@ App::shutdown() ->inject('request') ->inject('response') ->inject('project') + ->inject('user') ->inject('events') ->inject('audits') ->inject('usage') @@ -376,8 +377,8 @@ App::shutdown() ->inject('database') ->inject('mode') ->inject('dbForProject') - ->action(function (App $utopia, Request $request, Response $response, Document $project, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject) use ($parseLabel) { - + ->inject('dbForConsole') + ->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject, Database $dbForConsole) use ($parseLabel) { $responsePayload = $response->getPayload(); if (!empty($events->getEvent())) { @@ -437,7 +438,6 @@ App::shutdown() $route = $utopia->match($request); $requestParams = $route->getParamsValues(); - $user = $audits->getUser(); /** * Audit labels @@ -450,10 +450,7 @@ App::shutdown() } } - $pattern = $route->getLabel('audits.userId', null); - if (!empty($pattern)) { - $userId = $parseLabel($pattern, $responsePayload, $requestParams, $user); - $user = $dbForProject->getDocument('users', $userId); + if (!$user->isEmpty()) { $audits->setUser($user); } @@ -564,6 +561,22 @@ App::shutdown() ->setParam('project.{scope}.network.outbound', $response->getSize()) ->submit(); } + + /** + * Update user last activity + */ + if (!$user->isEmpty()) { + $accessedAt = $user->getAttribute('accessedAt', ''); + if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCCESS)) > $accessedAt) { + $user->setAttribute('accessedAt', DateTime::now()); + + if (APP_MODE_ADMIN !== $mode) { + $dbForProject->updateDocument('users', $user->getId(), $user); + } else { + $dbForConsole->updateDocument('users', $user->getId(), $user); + } + } + } }); App::init() diff --git a/app/init.php b/app/init.php index 0252de539b..3f8b4046fe 100644 --- a/app/init.php +++ b/app/init.php @@ -100,6 +100,7 @@ const APP_LIMIT_WRITE_RATE_DEFAULT = 60; // Default maximum write rate per rate const APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT = 60; // Default maximum write rate period in seconds const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return in list API calls const APP_KEY_ACCCESS = 24 * 60 * 60; // 24 hours +const APP_USER_ACCCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours const APP_CACHE_BUSTER = 506; const APP_VERSION_STABLE = '1.3.7'; diff --git a/composer.json b/composer.json index 792e757ff8..04f3ed88cf 100644 --- a/composer.json +++ b/composer.json @@ -43,13 +43,13 @@ "ext-sockets": "*", "appwrite/php-clamav": "1.1.*", "appwrite/php-runtimes": "0.11.*", - "utopia-php/abuse": "0.26.*", + "utopia-php/abuse": "0.27.*", "utopia-php/analytics": "0.2.*", - "utopia-php/audit": "0.28.*", + "utopia-php/audit": "0.29.*", "utopia-php/cache": "0.8.*", "utopia-php/cli": "0.13.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "0.37.*", + "utopia-php/database": "0.38.*", "utopia-php/domains": "1.1.*", "utopia-php/framework": "0.28.*", "utopia-php/image": "0.5.*", diff --git a/composer.lock b/composer.lock index 8eea8d9ceb..6be017d420 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0f20fb41e9b250b6763af1b734bb8d2d", + "content-hash": "0299a46dacaa5ae6607931f91b459db4", "packages": [ { "name": "adhocore/jwt", @@ -1802,23 +1802,23 @@ }, { "name": "utopia-php/abuse", - "version": "0.26.0", + "version": "0.27.0", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "fb73180f0588bc8826b85d433393b983bdc37cfa" + "reference": "d1115f5843e903ffaba9c23e450b33c0fe265ae0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/fb73180f0588bc8826b85d433393b983bdc37cfa", - "reference": "fb73180f0588bc8826b85d433393b983bdc37cfa", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/d1115f5843e903ffaba9c23e450b33c0fe265ae0", + "reference": "d1115f5843e903ffaba9c23e450b33c0fe265ae0", "shasum": "" }, "require": { "ext-curl": "*", "ext-pdo": "*", "php": ">=8.0", - "utopia-php/database": "0.37.*" + "utopia-php/database": "0.38.*" }, "require-dev": { "laravel/pint": "1.5.*", @@ -1845,9 +1845,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/0.26.0" + "source": "https://github.com/utopia-php/abuse/tree/0.27.0" }, - "time": "2023-06-15T00:53:36+00:00" + "time": "2023-07-15T00:53:50+00:00" }, { "name": "utopia-php/analytics", @@ -1906,21 +1906,21 @@ }, { "name": "utopia-php/audit", - "version": "0.28.0", + "version": "0.29.0", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "abf4124bec20b6ab3555869b64afe5b274e37165" + "reference": "5318538f457bf73623629345c98ea06371ca5dd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/abf4124bec20b6ab3555869b64afe5b274e37165", - "reference": "abf4124bec20b6ab3555869b64afe5b274e37165", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/5318538f457bf73623629345c98ea06371ca5dd4", + "reference": "5318538f457bf73623629345c98ea06371ca5dd4", "shasum": "" }, "require": { "php": ">=8.0", - "utopia-php/database": "0.37.*" + "utopia-php/database": "0.38.*" }, "require-dev": { "laravel/pint": "1.5.*", @@ -1947,9 +1947,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/0.28.0" + "source": "https://github.com/utopia-php/audit/tree/0.29.0" }, - "time": "2023-06-15T00:52:49+00:00" + "time": "2023-07-15T00:51:10+00:00" }, { "name": "utopia-php/cache", @@ -2106,16 +2106,16 @@ }, { "name": "utopia-php/database", - "version": "0.37.1", + "version": "0.38.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "4035d3f7e3393385eabc7816055047659c6fb4d3" + "reference": "59e4684cf87e03c12dab9240158c1dfc6888e534" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/4035d3f7e3393385eabc7816055047659c6fb4d3", - "reference": "4035d3f7e3393385eabc7816055047659c6fb4d3", + "url": "https://api.github.com/repos/utopia-php/database/zipball/59e4684cf87e03c12dab9240158c1dfc6888e534", + "reference": "59e4684cf87e03c12dab9240158c1dfc6888e534", "shasum": "" }, "require": { @@ -2156,9 +2156,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.37.1" + "source": "https://github.com/utopia-php/database/tree/0.38.0" }, - "time": "2023-06-15T06:36:27+00:00" + "time": "2023-07-14T07:49:38+00:00" }, { "name": "utopia-php/domains", @@ -3029,16 +3029,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "0.33.6", + "version": "0.33.7", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "237fe97b68090a244382c36f96482c352880a38c" + "reference": "9f5db4a637b23879ceacea9ed2d33b0486771ffc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/237fe97b68090a244382c36f96482c352880a38c", - "reference": "237fe97b68090a244382c36f96482c352880a38c", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9f5db4a637b23879ceacea9ed2d33b0486771ffc", + "reference": "9f5db4a637b23879ceacea9ed2d33b0486771ffc", "shasum": "" }, "require": { @@ -3074,9 +3074,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/0.33.6" + "source": "https://github.com/appwrite/sdk-generator/tree/0.33.7" }, - "time": "2023-07-10T16:27:53+00:00" + "time": "2023-07-12T12:15:43+00:00" }, { "name": "doctrine/deprecations", @@ -5674,5 +5674,5 @@ "platform-overrides": { "php": "8.0" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index 0fa07cb79a..32e1d51aad 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -120,6 +120,12 @@ class User extends Model 'default' => new \stdClass(), 'example' => ['theme' => 'pink', 'timezone' => 'UTC'], ]) + ->addRule('accessedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Most recent access date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) ; } diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index 67ff280015..3e4bd8a5a6 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -40,6 +40,8 @@ trait AccountBase $this->assertEquals($response['body']['email'], $email); $this->assertEquals($response['body']['name'], $name); $this->assertEquals($response['body']['labels'], []); + $this->assertArrayHasKey('accessedAt', $response['body']); + $this->assertNotEmpty($response['body']['accessedAt']); /** * Test for FAILURE @@ -127,6 +129,21 @@ trait AccountBase $sessionId = $response['body']['$id']; $session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']]; + // apiKey is only available in custom client test + $apiKey = $this->getProject()['apiKey']; + if (!empty($apiKey)) { + $userId = $response['body']['userId']; + $response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $apiKey, + ])); + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertArrayHasKey('accessedAt', $response['body']); + $this->assertNotEmpty($response['body']['accessedAt']); + } + $response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', @@ -207,6 +224,8 @@ trait AccountBase $this->assertEquals(true, $dateValidator->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $this->assertEquals($response['body']['name'], $name); + $this->assertArrayHasKey('accessedAt', $response['body']); + $this->assertNotEmpty($response['body']['accessedAt']); /** * Test for FAILURE diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 6e9d2b6dbb..a908f206c4 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -369,6 +369,20 @@ class AccountCustomClientTest extends Scope $session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']]; + \usleep(1000 * 30); // wait for 30ms to let the shutdown update accessedAt + + $apiKey = $this->getProject()['apiKey']; + $userId = $response['body']['userId']; + $response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $apiKey, + ])); + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertArrayHasKey('accessedAt', $response['body']); + $this->assertNotEmpty($response['body']['accessedAt']); + /** * Test for FAILURE */ diff --git a/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php b/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php index 22c90b5453..e3b300bac1 100644 --- a/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php +++ b/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php @@ -57,7 +57,7 @@ class WebhooksCustomClientTest extends Scope $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id); $this->assertNotEmpty($webhook['data']['$id']); $this->assertEquals($webhook['data']['name'], $name); $dateValidator = new DatetimeValidator(); @@ -195,7 +195,7 @@ class WebhooksCustomClientTest extends Scope $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id); $this->assertNotEmpty($webhook['data']['$id']); $this->assertNotEmpty($webhook['data']['userId']); $dateValidator = new DatetimeValidator(); @@ -744,7 +744,7 @@ class WebhooksCustomClientTest extends Scope $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id); $this->assertNotEmpty($webhook['data']['$id']); $this->assertNotEmpty($webhook['data']['userId']); $this->assertNotEmpty($webhook['data']['secret']); @@ -923,7 +923,7 @@ class WebhooksCustomClientTest extends Scope $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '', $userUid); $this->assertNotEmpty($webhook['data']['$id']); $this->assertNotEmpty($webhook['data']['userId']); $this->assertNotEmpty($webhook['data']['teamId']);