From 3a3d5b61a6bc855d3a36dc9762a94967d73fc373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 6 May 2024 09:55:59 +0000 Subject: [PATCH] PR review changes --- app/config/collections.php | 2 +- app/config/errors.php | 5 + app/controllers/api/functions.php | 18 ++-- app/controllers/shared/api.php | 111 ++++++++++---------- composer.lock | 22 ++-- src/Appwrite/Extend/Exception.php | 2 + src/Appwrite/Platform/Workers/Functions.php | 6 +- 7 files changed, 87 insertions(+), 79 deletions(-) diff --git a/app/config/collections.php b/app/config/collections.php index ae6e4d2e62..f8e6a78d82 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -3056,7 +3056,7 @@ $projectCollections = array_merge([ 'filters' => [], ], [ - '$id' => ID::custom('apiTokenScopes'), + '$id' => ID::custom('scopes'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, diff --git a/app/config/errors.php b/app/config/errors.php index 287e639f6b..05409cf8cb 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -332,6 +332,11 @@ return [ 'description' => 'API key and session used in the same request. Use either `setSession` or `setKey`. Learn about which authentication method to use in the SSR docs: https://appwrite.io/docs/products/auth/server-side-rendering', 'code' => 403, ], + Exception::API_KEY_EXPIRED => [ + 'name' => Exception::API_KEY_EXPIRED, + 'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.', + 'code' => 401, + ], /** Teams */ Exception::TEAM_NOT_FOUND => [ diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index b58c468b78..7f65943419 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -150,7 +150,7 @@ App::post('/v1/functions') ->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', true) ->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true) ->param('commands', '', new Text(8192, 0), 'Build Commands.', true) - ->param('apiTokenScopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Token auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Control System) deployment.', true) ->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function.', true) ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) @@ -169,7 +169,7 @@ App::post('/v1/functions') ->inject('queueForBuilds') ->inject('dbForConsole') ->inject('gitHub') - ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $apiTokenScopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { + ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { $functionId = ($functionId == 'unique()') ? ID::unique() : $functionId; $allowList = \array_filter(\explode(',', System::getEnv('_APP_FUNCTIONS_RUNTIMES', ''))); @@ -219,7 +219,7 @@ App::post('/v1/functions') 'timeout' => $timeout, 'entrypoint' => $entrypoint, 'commands' => $commands, - 'apiTokenScopes' => $apiTokenScopes, + 'scopes' => $scopes, 'search' => implode(' ', [$functionId, $name, $runtime]), 'version' => 'v3', 'installationId' => $installation->getId(), @@ -683,7 +683,7 @@ App::put('/v1/functions/:functionId') ->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', true) ->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true) ->param('commands', '', new Text(8192, 0), 'Build Commands.', true) - ->param('apiTokenScopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Token auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Controle System) deployment.', true) ->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function', true) ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) @@ -697,7 +697,7 @@ App::put('/v1/functions/:functionId') ->inject('queueForBuilds') ->inject('dbForConsole') ->inject('gitHub') - ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $apiTokenScopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { + ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { // TODO: If only branch changes, re-deploy $function = $dbForProject->getDocument('functions', $functionId); @@ -809,7 +809,7 @@ App::put('/v1/functions/:functionId') 'logging' => $logging, 'entrypoint' => $entrypoint, 'commands' => $commands, - 'apiTokenScopes' => $apiTokenScopes, + 'scopes' => $scopes, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getInternalId(), 'providerRepositoryId' => $providerRepositoryId, @@ -1595,12 +1595,12 @@ App::post('/v1/functions/:functionId/executions') $jwtExpiry = $function->getAttribute('timeout', 900); $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); - $apiToken = $jwtObj->encode([ + $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), - 'scopes' => $function->getAttribute('apiTokenScopes', []) + 'scopes' => $function->getAttribute('scopes', []) ]); - $headers['x-appwrite-api-token'] = $apiToken; + $headers['x-appwrite-key'] = $apiKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; $headers['x-appwrite-user-jwt'] = $jwt ?? ''; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 6c01837195..a03661a7c3 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -199,70 +199,24 @@ App::init() $authKey = $request->getHeader('x-appwrite-key', ''); - if (!empty($authKey)) { // API Key authentication + // API Key authentication + if (!empty($authKey)) { // Do not allow API key and session to be set at the same time if (!$user->isEmpty()) { throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } - // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); - if ($key) { - $user = new Document([ - '$id' => '', - 'status' => true, - 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), - 'password' => '', - 'name' => $project->getAttribute('name', 'Untitled'), - ]); + if(str_contains($authKey, '.')) { + // Dynamic key - $role = Auth::USER_ROLE_APPS; - $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); - $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { - throw new Exception(Exception::PROJECT_KEY_EXPIRED); + try { + $payload = $jwtObj->decode($authKey); + } catch (JWTException $error) { + throw new Exception(Exception::API_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()); - } - } - } - } - - // API Token authentication (like API Key but JWT short-term) - $apiToken = $request->getHeader('x-appwrite-token', ''); - if (!empty($apiToken) && $user->isEmpty() && empty($authKey)) { - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); - - try { - $payload = $jwtObj->decode($apiToken); - } catch (JWTException $error) { - // Ignore if token is invalid - } - - if (!empty($payload)) { $projectId = $payload['projectId'] ?? ''; $tokenScopes = $payload['scopes'] ?? []; @@ -282,6 +236,53 @@ App::init() Authorization::setRole(Auth::USER_ROLE_APPS); Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. } + } else { + // Regular key + + // Check if given key match project API keys + $key = $project->find('secret', $authKey, 'keys'); + if ($key) { + $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()); + } + } + } } } diff --git a/composer.lock b/composer.lock index 34a3cc143c..7c660bf176 100644 --- a/composer.lock +++ b/composer.lock @@ -1966,16 +1966,16 @@ }, { "name": "utopia-php/migration", - "version": "0.4.0", + "version": "0.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "a72f27bd3dde68752fb185d306c4820e1b8d9657" + "reference": "ae3cfe93f6d313105d226aeb68806660c806a925" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/a72f27bd3dde68752fb185d306c4820e1b8d9657", - "reference": "a72f27bd3dde68752fb185d306c4820e1b8d9657", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/ae3cfe93f6d313105d226aeb68806660c806a925", + "reference": "ae3cfe93f6d313105d226aeb68806660c806a925", "shasum": "" }, "require": { @@ -2008,9 +2008,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/0.4.0" + "source": "https://github.com/utopia-php/migration/tree/0.4.1" }, - "time": "2024-02-25T12:35:21+00:00" + "time": "2024-05-01T13:19:18+00:00" }, { "name": "utopia-php/mongo", @@ -2899,16 +2899,16 @@ }, { "name": "laravel/pint", - "version": "v1.15.2", + "version": "v1.15.3", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "2c9f8004899815f3f0ee3cb28ef7281e2b589134" + "reference": "3600b5d17aff52f6100ea4921849deacbbeb8656" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/2c9f8004899815f3f0ee3cb28ef7281e2b589134", - "reference": "2c9f8004899815f3f0ee3cb28ef7281e2b589134", + "url": "https://api.github.com/repos/laravel/pint/zipball/3600b5d17aff52f6100ea4921849deacbbeb8656", + "reference": "3600b5d17aff52f6100ea4921849deacbbeb8656", "shasum": "" }, "require": { @@ -2961,7 +2961,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-04-23T15:42:34+00:00" + "time": "2024-04-30T15:02:26+00:00" }, { "name": "matthiasmullie/minify", diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 1c5692cd9d..08fb899992 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -107,6 +107,8 @@ class Exception extends \Exception public const USER_TARGET_ALREADY_EXISTS = 'user_target_already_exists'; public const USER_API_KEY_AND_SESSION_SET = 'user_key_and_session_set'; + public const API_KEY_EXPIRED = 'api_key_expired'; + /** Teams */ public const TEAM_NOT_FOUND = 'team_not_found'; public const TEAM_INVITE_ALREADY_EXISTS = 'team_invite_already_exists'; diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 4f4136ee18..93a74e9eda 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -285,12 +285,12 @@ class Functions extends Action $jwtExpiry = $function->getAttribute('timeout', 900); $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); - $apiToken = $jwtObj->encode([ + $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), - 'scopes' => $function->getAttribute('apiTokenScopes', []) + 'scopes' => $function->getAttribute('scopes', []) ]); - $headers['x-appwrite-api-token'] = $apiToken; + $headers['x-appwrite-key'] = $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId() ?? '';