diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 9461af661b..509054c369 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -160,8 +160,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res ->setAttribute('current', true) ->setAttribute('countryName', $countryName) ->setAttribute('expire', $expire) - ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $sessionSecret) : '') - ; + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $sessionSecret) : ''); $response->dynamic($session, Response::MODEL_SESSION); }; @@ -228,7 +227,8 @@ App::post('/v1/account') Query::equal('providerEmail', [$email]), ]); if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { - throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ + /** Return a generic bad request to prevent exposing existing accounts */ + throw new Exception(Exception::GENERAL_BAD_REQUEST); } if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) { @@ -405,7 +405,8 @@ App::get('/v1/account/sessions') $sessions = $user->getAttribute('sessions', []); $current = Auth::sessionVerify($sessions, Auth::$secret); - foreach ($sessions as $key => $session) {/** @var Document $session */ + foreach ($sessions as $key => $session) { + /** @var Document $session */ $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session->setAttribute('countryName', $countryName); @@ -447,7 +448,8 @@ App::delete('/v1/account/sessions') $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); - foreach ($sessions as $session) {/** @var Document $session */ + foreach ($sessions as $session) { + /** @var Document $session */ $dbForProject->deleteDocument('sessions', $session->getId()); if (!Config::getParam('domainVerification')) { @@ -515,15 +517,15 @@ App::get('/v1/account/sessions/:sessionId') ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) : $sessionId; - foreach ($sessions as $session) {/** @var Document $session */ + foreach ($sessions as $session) { + /** @var Document $session */ if ($sessionId === $session->getId()) { $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session ->setAttribute('current', ($session->getAttribute('secret') == Auth::hash(Auth::$secret))) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $session->getAttribute('secret', '') : '') - ; + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $session->getAttribute('secret', '') : ''); return $response->dynamic($session, Response::MODEL_SESSION); } @@ -682,8 +684,7 @@ App::patch('/v1/account/sessions/:sessionId') $queueForEvents ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()) - ->setPayload($response->output($session, Response::MODEL_SESSION)) - ; + ->setPayload($response->output($session, Response::MODEL_SESSION)); return $response->dynamic($session, Response::MODEL_SESSION); }); @@ -786,8 +787,7 @@ App::post('/v1/account/sessions/email') if (!Config::getParam('domainVerification')) { $response - ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) - ; + ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); @@ -795,21 +795,18 @@ App::post('/v1/account/sessions/email') $response ->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) - ; + ->setStatusCode(Response::STATUS_CODE_CREATED); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session ->setAttribute('current', true) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '') - ; + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : ''); $queueForEvents ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()) - ; + ->setParam('sessionId', $session->getId()); $response->dynamic($session, Response::MODEL_SESSION); }); @@ -916,7 +913,7 @@ App::post('/v1/account/sessions/anonymous') Authorization::setRole(Role::user($user->getId())->toString()); - $session = $dbForProject->createDocument('sessions', $session-> setAttribute('$permissions', [ + $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ Permission::read(Role::user($user->getId())), Permission::update(Role::user($user->getId())), Permission::delete(Role::user($user->getId())), @@ -926,8 +923,7 @@ App::post('/v1/account/sessions/anonymous') $queueForEvents ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()) - ; + ->setParam('sessionId', $session->getId()); if (!Config::getParam('domainVerification')) { $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); @@ -938,16 +934,14 @@ App::post('/v1/account/sessions/anonymous') $response ->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) - ; + ->setStatusCode(Response::STATUS_CODE_CREATED); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session ->setAttribute('current', true) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '') - ; + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : ''); $response->dynamic($session, Response::MODEL_SESSION); }); @@ -1338,7 +1332,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') Query::equal('providerEmail', [$email]), ]); if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { - throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ + /** Return a generic bad request to prevent exposing existing accounts */ + throw new Exception(Exception::GENERAL_BAD_REQUEST); } try { @@ -1409,7 +1404,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') Query::notEqual('userInternalId', $user->getInternalId()), ]); if (!empty($identitiesWithMatchingEmail)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ + /** Return a generic bad request to prevent exposing existing accounts */ + throw new Exception(Exception::GENERAL_BAD_REQUEST); } $dbForProject->createDocument('identities', new Document([ @@ -1482,8 +1478,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $queueForEvents ->setEvent('users.[userId].tokens.[tokenId].create') ->setParam('userId', $user->getId()) - ->setParam('tokenId', $token->getId()) - ; + ->setParam('tokenId', $token->getId()); $query['secret'] = $secret; $query['userId'] = $user->getId(); @@ -1526,8 +1521,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $queueForEvents ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()) - ->setPayload($response->output($session, Response::MODEL_SESSION)) - ; + ->setPayload($response->output($session, Response::MODEL_SESSION)); // TODO: Remove this deprecated workaround - support only token if ($state['success']['path'] == $oauthDefaultSuccess) { @@ -1566,8 +1560,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($state['success']) - ; + ->redirect($state['success']); }); App::get('/v1/account/tokens/oauth2/:provider') @@ -1943,7 +1936,8 @@ App::post('/v1/account/tokens/email') Query::equal('providerEmail', [$email]), ]); if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { - throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ + /** Return a generic bad request to prevent exposing existing accounts */ + throw new Exception(Exception::GENERAL_BAD_REQUEST); } $userId = $userId === 'unique()' ? ID::unique() : $userId; @@ -2356,15 +2350,16 @@ App::post('/v1/account/jwt') ->label('abuse-key', 'url:{url},userId:{userId}') ->inject('response') ->inject('user') - ->inject('dbForProject') - ->action(function (Response $response, Document $user, Database $dbForProject) { + ->action(function (Response $response, Document $user) { $sessions = $user->getAttribute('sessions', []); $current = new Document(); - foreach ($sessions as $session) { /** @var Utopia\Database\Document $session */ - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + foreach ($sessions as $session) { + /** @var Document $session */ + if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { + // If current session delete the cookies too $current = $session; } } @@ -2373,15 +2368,12 @@ App::post('/v1/account/jwt') throw new Exception(Exception::USER_SESSION_NOT_FOUND); } - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + // Instantiate with key, algo, maxAge and leeway. + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic(new Document(['jwt' => $jwt->encode([ - // 'uid' => 1, - // 'aud' => 'http://site.com', - // 'scopes' => ['user'], - // 'iss' => 'http://api.mysite.com', 'userId' => $user->getId(), 'sessionId' => $current->getId(), ])]), Response::MODEL_JWT); @@ -2403,7 +2395,6 @@ App::get('/v1/account/prefs') ->inject('response') ->inject('user') ->action(function (Response $response, Document $user) { - $prefs = $user->getAttribute('prefs', []); $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); @@ -2509,7 +2500,7 @@ App::patch('/v1/account/name') App::patch('/v1/account/password') ->desc('Update password') - ->groups(['api', 'account']) + ->groups(['api', 'account', 'credentials']) ->label('event', 'users.[userId].update.password') ->label('scope', 'account') ->label('audits.event', 'user.update') @@ -2624,7 +2615,8 @@ App::patch('/v1/account/email') Query::notEqual('userInternalId', $user->getInternalId()), ]); if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { - throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ + throw new Exception(Exception::GENERAL_BAD_REQUEST); + /** Return a generic bad request to prevent exposing existing accounts */ } $user @@ -2660,7 +2652,8 @@ App::patch('/v1/account/email') } $dbForProject->purgeCachedDocument('users', $user->getId()); } catch (Duplicate) { - throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ + throw new Exception(Exception::GENERAL_BAD_REQUEST); + /** Return a generic bad request to prevent exposing existing accounts */ } $queueForEvents->setParam('userId', $user->getId()); @@ -2819,8 +2812,7 @@ App::patch('/v1/account/status') $protocol = $request->getProtocol(); $response ->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) - ; + ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); $response->dynamic($user, Response::MODEL_ACCOUNT); }); @@ -3063,12 +3055,12 @@ App::put('/v1/account/recovery') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile - ->setAttribute('password', $newPassword) - ->setAttribute('passwordHistory', $history) - ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) - ->setAttribute('emailVerification', true)); + ->setAttribute('password', $newPassword) + ->setAttribute('passwordHistory', $history) + ->setAttribute('passwordUpdate', DateTime::now()) + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) + ->setAttribute('emailVerification', true)); $user->setAttributes($profile->getArrayCopy()); @@ -3083,8 +3075,7 @@ App::put('/v1/account/recovery') $queueForEvents ->setParam('userId', $profile->getId()) - ->setParam('tokenId', $recoveryDocument->getId()) - ; + ->setParam('tokenId', $recoveryDocument->getId()); $response->dynamic($recoveryDocument, Response::MODEL_TOKEN); }); @@ -3477,8 +3468,7 @@ App::put('/v1/account/verification/phone') $queueForEvents ->setParam('userId', $user->getId()) - ->setParam('tokenId', $verificationDocument->getId()) - ; + ->setParam('tokenId', $verificationDocument->getId()); $response->dynamic($verificationDocument, Response::MODEL_TOKEN); }); @@ -4147,8 +4137,8 @@ App::put('/v1/account/mfa/challenge') $dbForProject->updateDocument('sessions', $session->getId(), $session); $queueForEvents - ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()); + ->setParam('userId', $user->getId()) + ->setParam('sessionId', $session->getId()); $response->dynamic($session, Response::MODEL_SESSION); }); @@ -4203,7 +4193,7 @@ App::post('/v1/account/targets/push') ], 'providerId' => !empty($providerId) ? $providerId : null, 'providerInternalId' => !empty($providerId) ? $provider->getInternalId() : null, - 'providerType' => MESSAGE_TYPE_PUSH, + 'providerType' => MESSAGE_TYPE_PUSH, 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'sessionId' => $session->getId(), @@ -4354,8 +4344,8 @@ App::get('/v1/account/identities') $queries[] = Query::equal('userInternalId', [$user->getInternalId()]); /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ $cursor = \array_filter($queries, function ($query) { return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); }); @@ -4418,3 +4408,29 @@ App::delete('/v1/account/identities/:identityId') return $response->noContent(); }); + +App::shutdown() + ->groups(['credentials']) + ->desc('Delete existing sessions on credential changes') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->inject('user') + ->action(function (Database $dbForProject, Delete $queueForDeletes, Document $user) { + foreach ($user->getAttribute('sessions', []) as $session) { + $session = $dbForProject->getDocument('sessions', $session); + + if ($session->isEmpty()) { + continue; + } + + $queueForDeletes + ->setType(DELETE_TYPE_SESSION_TARGETS) + ->setDocument($session) + ->trigger(); + + $dbForProject->deleteDocument( + 'sessions', + $session->getId() + ); + } + }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index abfcb34a5a..86200ef345 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1097,7 +1097,7 @@ App::patch('/v1/users/:userId/name') App::patch('/v1/users/:userId/password') ->desc('Update password') - ->groups(['api', 'users']) + ->groups(['api', 'users', 'credentials']) ->label('event', 'users.[userId].update.password') ->label('scope', 'users.write') ->label('audits.event', 'user.update')