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']);