diff --git a/app/config/errors.php b/app/config/errors.php index 40f6ad018f..3f12b5953a 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -245,7 +245,7 @@ return [ Exception::USER_MORE_FACTORS_REQUIRED => [ 'name' => Exception::USER_MORE_FACTORS_REQUIRED, 'description' => 'More factors are required to complete the sign in process.', - 'code' => 400, + 'code' => 401, ], Exception::USER_OAUTH2_BAD_REQUEST => [ 'name' => Exception::USER_OAUTH2_BAD_REQUEST, @@ -647,11 +647,6 @@ return [ 'description' => 'Project with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], - Exception::PROJECT_UNKNOWN => [ - 'name' => Exception::PROJECT_UNKNOWN, - 'description' => 'The project ID is either missing or not valid. Please check the value of the X-Appwrite-Project header to ensure the correct project ID is being used.', - 'code' => 400, - ], Exception::PROJECT_PROVIDER_DISABLED => [ 'name' => Exception::PROJECT_PROVIDER_DISABLED, 'description' => 'The chosen OAuth provider is disabled. You can enable the OAuth provider using the Appwrite console.', diff --git a/app/controllers/general.php b/app/controllers/general.php index 9385fac435..99ed12b668 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -3,7 +3,6 @@ require_once __DIR__ . '/../init.php'; use Utopia\App; -use Utopia\Database\Helpers\Role; use Utopia\Locale\Locale; use Utopia\Logger\Logger; use Utopia\Logger\Log; @@ -15,7 +14,6 @@ use Appwrite\Utopia\View; use Appwrite\Extend\Exception as AppwriteException; use Utopia\Config\Config; use Utopia\Domains\Domain; -use Appwrite\Auth\Auth; use Appwrite\Event\Certificate; use Appwrite\Network\Validator\Origin; use Appwrite\Utopia\Response\Filters\V11 as ResponseV11; @@ -27,7 +25,6 @@ use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; use Utopia\CLI\Console; use Utopia\Database\Database; -use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -39,7 +36,6 @@ use Appwrite\Utopia\Request\Filters\V15 as RequestV15; use Appwrite\Utopia\Request\Filters\V16 as RequestV16; use Appwrite\Utopia\Request\Filters\V17 as RequestV17; use Utopia\Validator\Text; -use Utopia\Validator\WhiteList; Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); @@ -205,15 +201,11 @@ App::init() ->inject('console') ->inject('project') ->inject('dbForConsole') - ->inject('user') ->inject('locale') ->inject('localeCodes') ->inject('clients') - ->inject('servers') - ->inject('session') - ->inject('mode') ->inject('queueForCertificates') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $localeCodes, array $clients, array $servers, ?Document $session, string $mode, Certificate $queueForCertificates) { + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Locale $locale, array $localeCodes, array $clients, Certificate $queueForCertificates) { /* * Appwrite Router */ @@ -324,14 +316,6 @@ App::init() $locale->setDefault($localeParam); } - if ($project->isEmpty()) { - throw new AppwriteException(AppwriteException::PROJECT_NOT_FOUND); - } - - if (!empty($route->getLabel('sdk.auth', [])) && $project->isEmpty() && ($route->getLabel('scope', '') !== 'public')) { - throw new AppwriteException(AppwriteException::PROJECT_UNKNOWN); - } - $referrer = $request->getReferer(); $origin = \parse_url($request->getOrigin($referrer), PHP_URL_HOST); $protocol = \parse_url($request->getOrigin($referrer), PHP_URL_SCHEME); @@ -453,138 +437,6 @@ App::init() ) { throw new AppwriteException(AppwriteException::GENERAL_UNKNOWN_ORIGIN, $originValidator->getDescription()); } - - /* - * ACL Check - */ - $role = ($user->isEmpty()) - ? Role::guests()->toString() - : Role::users()->toString(); - - // Add user roles - $memberships = $user->find('teamId', $project->getAttribute('teamId'), 'memberships'); - - if ($memberships) { - foreach ($memberships->getAttribute('roles', []) as $memberRole) { - switch ($memberRole) { - case 'owner': - $role = Auth::USER_ROLE_OWNER; - break; - case 'admin': - $role = Auth::USER_ROLE_ADMIN; - break; - case 'developer': - $role = Auth::USER_ROLE_DEVELOPER; - break; - } - } - } - - $roles = Config::getParam('roles', []); - $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route - $scopes = $roles[$role]['scopes']; // Allowed scopes for user role - - $authKey = $request->getHeader('x-appwrite-key', ''); - - if (!empty($authKey)) { // API Key authentication - // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); - - /* - * Try app auth when we have project key and no user - * Mock user to app and grant API key scopes in addition to default app scopes - */ - if ($key && $user->isEmpty()) { - $user = new Document([ - '$id' => '', - 'status' => true, - 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), - 'password' => '', - 'name' => $project->getAttribute('name', 'Untitled'), - ]); - - $role = Auth::USER_ROLE_APPS; - $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); - - $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { - throw new AppwriteException(AppwriteException::PROJECT_KEY_EXPIRED); - } - - Authorization::setRole(Auth::USER_ROLE_APPS); - Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. - - $accessedAt = $key->getAttribute('accessedAt', ''); - if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) { - $key->setAttribute('accessedAt', DateTime::now()); - $dbForConsole->updateDocument('keys', $key->getId(), $key); - $dbForConsole->purgeCachedDocument('projects', $project->getId()); - } - - $sdkValidator = new WhiteList($servers, true); - $sdk = $request->getHeader('x-sdk-name', 'UNKNOWN'); - if ($sdkValidator->isValid($sdk)) { - $sdks = $key->getAttribute('sdks', []); - if (!in_array($sdk, $sdks)) { - array_push($sdks, $sdk); - $key->setAttribute('sdks', $sdks); - - /** Update access time as well */ - $key->setAttribute('accessedAt', Datetime::now()); - $dbForConsole->updateDocument('keys', $key->getId(), $key); - $dbForConsole->purgeCachedDocument('projects', $project->getId()); - } - } - } - } - - Authorization::setRole($role); - - foreach (Auth::getRoles($user) as $authRole) { - Authorization::setRole($authRole); - } - - $service = $route->getLabel('sdk.namespace', ''); - if (!empty($service)) { - if ( - array_key_exists($service, $project->getAttribute('services', [])) - && !$project->getAttribute('services', [])[$service] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) - ) { - throw new AppwriteException(AppwriteException::GENERAL_SERVICE_DISABLED); - } - } - - if (!\in_array($scope, $scopes)) { - if ($project->isEmpty()) { // Check if permission is denied because project is missing - throw new AppwriteException(AppwriteException::PROJECT_NOT_FOUND); - } - - throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scope (' . $scope . ')'); - } - - if (false === $user->getAttribute('status')) { // Account is blocked - throw new AppwriteException(AppwriteException::USER_BLOCKED); - } - - if ($user->getAttribute('reset')) { - throw new AppwriteException(AppwriteException::USER_PASSWORD_RESET_REQUIRED); - } - - if ($mode !== APP_MODE_ADMIN) { - $mfaEnabled = $user->getAttribute('mfa', false); - $hasVerifiedAuthenticator = $user->getAttribute('totpVerification', false); - $hasVerifiedEmail = $user->getAttribute('emailVerification', false); - $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); - $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; - $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; - - if (!in_array('mfa', $route->getGroups())) { - if ($session && \count($session->getAttribute('factors')) < $minimumFactors) { - throw new AppwriteException(AppwriteException::USER_MORE_FACTORS_REQUIRED); - } - } - } }); App::options() diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 1a9c9f7380..810d778a21 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -6,7 +6,6 @@ use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; -use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Extend\Exception; use Appwrite\Event\Usage; @@ -22,7 +21,9 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; -use MaxMind\Db\Reader; +use Utopia\Config\Config; +use Utopia\Database\Helpers\Role; +use Utopia\Validator\WhiteList; $parseLabel = function (string $label, array $responsePayload, array $requestParams, Document $user) { preg_match_all('/{(.*?)}/', $label, $matches); @@ -135,7 +136,7 @@ $databaseListener = function (string $event, Document $document, Document $proje $queueForUsage ->addMetric(METRIC_DEPLOYMENTS, $value) // per project ->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_FUNCTION_ID_DEPLOYMENTS), $value)// per function + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_FUNCTION_ID_DEPLOYMENTS), $value) // per function ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value); break; default: @@ -143,6 +144,155 @@ $databaseListener = function (string $event, Document $document, Document $proje } }; +App::init() + ->groups(['api']) + ->inject('utopia') + ->inject('request') + ->inject('dbForConsole') + ->inject('project') + ->inject('user') + ->inject('session') + ->inject('servers') + ->inject('mode') + ->action(function (App $utopia, Request $request, Database $dbForConsole, Document $project, Document $user, ?Document $session, array $servers, string $mode) { + $route = $utopia->getRoute(); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + /** + * ACL Check + */ + $role = ($user->isEmpty()) + ? Role::guests()->toString() + : Role::users()->toString(); + + // Add user roles + $memberships = $user->find('teamId', $project->getAttribute('teamId'), 'memberships'); + + if ($memberships) { + foreach ($memberships->getAttribute('roles', []) as $memberRole) { + switch ($memberRole) { + case 'owner': + $role = Auth::USER_ROLE_OWNER; + break; + case 'admin': + $role = Auth::USER_ROLE_ADMIN; + break; + case 'developer': + $role = Auth::USER_ROLE_DEVELOPER; + break; + } + } + } + + $roles = Config::getParam('roles', []); + $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route + $scopes = $roles[$role]['scopes']; // Allowed scopes for user role + + $authKey = $request->getHeader('x-appwrite-key', ''); + + if (!empty($authKey)) { // API Key authentication + // Check if given key match project API keys + $key = $project->find('secret', $authKey, 'keys'); + + /* + * Try app auth when we have project key and no user + * Mock user to app and grant API key scopes in addition to default app scopes + */ + if ($key && $user->isEmpty()) { + $user = new Document([ + '$id' => '', + 'status' => true, + 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), + 'password' => '', + 'name' => $project->getAttribute('name', 'Untitled'), + ]); + + $role = Auth::USER_ROLE_APPS; + $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + throw new Exception(Exception::PROJECT_KEY_EXPIRED); + } + + Authorization::setRole(Auth::USER_ROLE_APPS); + Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. + + $accessedAt = $key->getAttribute('accessedAt', ''); + if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) { + $key->setAttribute('accessedAt', DateTime::now()); + $dbForConsole->updateDocument('keys', $key->getId(), $key); + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + } + + $sdkValidator = new WhiteList($servers, true); + $sdk = $request->getHeader('x-sdk-name', 'UNKNOWN'); + if ($sdkValidator->isValid($sdk)) { + $sdks = $key->getAttribute('sdks', []); + if (!in_array($sdk, $sdks)) { + array_push($sdks, $sdk); + $key->setAttribute('sdks', $sdks); + + /** Update access time as well */ + $key->setAttribute('accessedAt', Datetime::now()); + $dbForConsole->updateDocument('keys', $key->getId(), $key); + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + } + } + } + } + + Authorization::setRole($role); + + foreach (Auth::getRoles($user) as $authRole) { + Authorization::setRole($authRole); + } + + $service = $route->getLabel('sdk.namespace', ''); + if (!empty($service)) { + if ( + array_key_exists($service, $project->getAttribute('services', [])) + && !$project->getAttribute('services', [])[$service] + && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + ) { + throw new Exception(Exception::GENERAL_SERVICE_DISABLED); + } + } + if (!\in_array($scope, $scopes)) { + if ($project->isEmpty()) { // Check if permission is denied because project is missing + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scope (' . $scope . ')'); + } + + if (false === $user->getAttribute('status')) { // Account is blocked + throw new Exception(Exception::USER_BLOCKED); + } + + if ($user->getAttribute('reset')) { + throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); + } + + if ($mode !== APP_MODE_ADMIN) { + $mfaEnabled = $user->getAttribute('mfa', false); + $hasVerifiedAuthenticator = $user->getAttribute('totpVerification', false); + $hasVerifiedEmail = $user->getAttribute('emailVerification', false); + $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); + $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; + $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; + + if (!in_array('mfa', $route->getGroups())) { + if ($session && \count($session->getAttribute('factors')) < $minimumFactors) { + throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); + } + } + } + }); + App::init() ->groups(['api']) ->inject('utopia') @@ -162,10 +312,6 @@ App::init() $route = $utopia->getRoute(); - if ($project->isEmpty() && $route->getLabel('abuse-limit', 0) > 0) { // Abuse limit requires an active project scope - throw new Exception(Exception::PROJECT_UNKNOWN); - } - /* * Abuse Check */ @@ -296,7 +442,7 @@ App::init() if ($fileSecurity && !$valid) { $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); } else { - $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); } if ($file->isEmpty()) { @@ -497,7 +643,7 @@ App::shutdown() 'resource' => $resource, 'contentType' => $response->getContentType(), 'payload' => base64_encode($data['payload']), - ]) ; + ]); $signature = md5($data); $cacheLog = Authorization::skip(fn () => $dbForProject->getDocument('cache', $key)); @@ -505,10 +651,10 @@ App::shutdown() $now = DateTime::now(); if ($cacheLog->isEmpty()) { Authorization::skip(fn () => $dbForProject->createDocument('cache', new Document([ - '$id' => $key, - 'resource' => $resource, - 'accessedAt' => $now, - 'signature' => $signature, + '$id' => $key, + 'resource' => $resource, + 'accessedAt' => $now, + 'signature' => $signature, ]))); } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { $cacheLog->setAttribute('accessedAt', $now); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 348fdacc0b..eba88480c4 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -192,7 +192,6 @@ class Exception extends \Exception /** Projects */ public const PROJECT_NOT_FOUND = 'project_not_found'; - public const PROJECT_UNKNOWN = 'project_unknown'; public const PROJECT_PROVIDER_DISABLED = 'project_provider_disabled'; public const PROJECT_PROVIDER_UNSUPPORTED = 'project_provider_unsupported'; public const PROJECT_ALREADY_EXISTS = 'project_already_exists'; diff --git a/tests/e2e/General/HooksTest.php b/tests/e2e/General/HooksTest.php new file mode 100644 index 0000000000..f4933428d1 --- /dev/null +++ b/tests/e2e/General/HooksTest.php @@ -0,0 +1,158 @@ +client->setEndpoint('http://localhost'); + } + + public function testProjectHooks() + { + /** + * Test for api controllers + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/locale', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ]), [ + 'project' => 'console' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/v1/locale', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ]), [ + 'project' => '$this_project_doesnt_exist' + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for web controllers + */ + $response = $this->client->call(Client::METHOD_GET, headers: [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ], params: [ + 'project' => 'console' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, headers: [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ], params: [ + 'project' => '$this_project_doesnt_exist' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } + + public function testUserHooks() + { + /** + * Setup blocked user + */ + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + + $response = $this->client->call(Client::METHOD_POST, '/v1/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + ]); + + $id = $response['body']['$id']; + + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/v1/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $cookie = 'a_session_' . $this->getProject()['$id'] . '=' . $session; + + $response = $this->client->call(Client::METHOD_GET, '/v1/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/v1/account/status', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ], [ + 'status' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/v1/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + /** + * Test for api controllers + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/locale', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(Exception::USER_BLOCKED, $response['body']['type']); + + /** + * Test for web controllers + */ + $response = $this->client->call(Client::METHOD_GET, headers: [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } +}