diff --git a/app/config/collections.php b/app/config/collections.php index 171463c033..1d19c3b848 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -800,7 +800,7 @@ $collections = [ 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, - 'default' => null, + 'default' => 0, 'array' => false, 'filters' => [], ], @@ -837,6 +837,17 @@ $collections = [ 'array' => false, 'filters' => ['encrypt'], ], + [ + '$id' => 'expire', + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index c0f0fdd966..4b385574be 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -26,6 +26,7 @@ use Appwrite\Extend\Exception; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Hostname; +use Utopia\Validator\Integer; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -775,9 +776,10 @@ App::post('/v1/projects/:projectId/keys') ->param('projectId', null, new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') ->param('scopes', null, new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', 0, new Integer() , 'Key expiration time in Unix timestamp. Use 0 for unlimited expiration.', true) ->inject('response') ->inject('dbForConsole') - ->action(function (string $projectId, string $name, array $scopes, Response $response, Database $dbForConsole) { + ->action(function (string $projectId, string $name, array $scopes, int $expire, Response $response, Database $dbForConsole) { $project = $dbForConsole->getDocument('projects', $projectId); @@ -792,6 +794,7 @@ App::post('/v1/projects/:projectId/keys') 'projectId' => $project->getId(), 'name' => $name, 'scopes' => $scopes, + 'expire' => $expire, 'secret' => \bin2hex(\random_bytes(128)), ]); @@ -882,9 +885,10 @@ App::put('/v1/projects/:projectId/keys/:keyId') ->param('keyId', null, new UID(), 'Key unique ID.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') ->param('scopes', null, new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->param('expire', 0, new Integer() , 'Key expiration time in Unix timestamp. Use 0 for unlimited expiration.', true) ->inject('response') ->inject('dbForConsole') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, Response $response, Database $dbForConsole) { + ->action(function (string $projectId, string $keyId, string $name, array $scopes, int $expire, Response $response, Database $dbForConsole) { $project = $dbForConsole->getDocument('projects', $projectId); @@ -904,6 +908,7 @@ App::put('/v1/projects/:projectId/keys/:keyId') $key ->setAttribute('name', $name) ->setAttribute('scopes', $scopes) + ->setAttribute('expire', $expire) ; $dbForConsole->updateDocument('keys', $key->getId(), $key); diff --git a/app/controllers/general.php b/app/controllers/general.php index 747799e369..d25762f624 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -279,6 +279,12 @@ App::init(function (App $utopia, Request $request, Response $response, Document $role = Auth::USER_ROLE_APP; $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + $expire = $key->getAttribute('expire', 0); + + if(!empty($expire) && $expire < \time()){ + throw new AppwriteException('Project key expired', 401, AppwriteException:: PROJECT_KEY_EXPIRED); + } + Authorization::setRole('role:' . Auth::USER_ROLE_APP); Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index a74ec609db..47b77be353 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -46,7 +46,7 @@ class Exception extends \Exception const GENERAL_ROUTE_NOT_FOUND = 'general_route_not_found'; const GENERAL_CURSOR_NOT_FOUND = 'general_cursor_not_found'; const GENERAL_SERVER_ERROR = 'general_server_error'; - const GENERAL_PROTOCOL_UNSUPPORTED = 'general_protocol_unsupported'; + const GENERAL_PROTOCOL_UNSUPPORTED = 'general_protocol_unsupported'; /** Users */ const USER_COUNT_EXCEEDED = 'user_count_exceeded'; @@ -147,6 +147,7 @@ class Exception extends \Exception const PROJECT_INVALID_FAILURE_URL = 'project_invalid_failure_url'; const PROJECT_MISSING_USER_ID = 'project_missing_user_id'; const PROJECT_RESERVED_PROJECT = 'project_reserved_project'; + const PROJECT_KEY_EXPIRED = 'project_key_expired'; /** Webhooks */ const WEBHOOK_NOT_FOUND = 'webhook_not_found'; diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php index d5a19012cd..ea553ea64b 100644 --- a/src/Appwrite/Utopia/Response/Model/Key.php +++ b/src/Appwrite/Utopia/Response/Model/Key.php @@ -27,6 +27,12 @@ class Key extends Model 'default' => '', 'example' => 'My API Key', ]) + ->addRule('expire', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Key expiration in Unix timestamp.', + 'default' => 0, + 'example' => '1653990687', + ]) ->addRule('scopes', [ 'type' => self::TYPE_STRING, 'description' => 'Allowed permission scopes.', diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index b550456692..1b9fefc64d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1162,7 +1162,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('teams.write', $response['body']['scopes']); $this->assertNotEmpty($response['body']['secret']); - $data = array_merge($data, ['keyId' => $response['body']['$id']]); + $data = array_merge($data, [ + 'keyId' => $response['body']['$id'], + 'secret' => $response['body']['secret'] + ]); /** * Test for FAILURE @@ -1180,6 +1183,7 @@ class ProjectsConsoleClientTest extends Scope return $data; } + /** * @depends testCreateProjectKey */ @@ -1192,6 +1196,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); + $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); @@ -1202,6 +1207,7 @@ class ProjectsConsoleClientTest extends Scope return $data; } + /** * @depends testCreateProjectKey */ @@ -1213,6 +1219,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys/' . $keyId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $keyId ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -1237,6 +1244,76 @@ class ProjectsConsoleClientTest extends Scope return $data; } + /** + * @depends testCreateProject + */ + public function testValidateProjectKey($data): void + { + $id = $data['projectId'] ?? ''; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['health.read'], + 'expire' => time()+3600, + ]); + + $response = $this->client->call(Client::METHOD_GET, '/health' , [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $response['body']['secret'] + ], []); + + $this->assertEquals(200, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['health.read'], + 'expire' => 0, + ]); + + $response = $this->client->call(Client::METHOD_GET, '/health' , [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $response['body']['secret'] + ], []); + + $this->assertEquals(200, $response['headers']['status-code']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['health.read'], + 'expire' => time()-3600, + ]); + + $response = $this->client->call(Client::METHOD_GET, '/health' , [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $response['body']['secret'] + ], []); + + $this->assertEquals(401, $response['headers']['status-code']); + + } + + /** * @depends testCreateProjectKey */ @@ -1251,6 +1328,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'name' => 'Key Test Update', 'scopes' => ['users.read', 'users.write', 'collections.read'], + 'expire' => time()+360, ]); $this->assertEquals(200, $response['headers']['status-code']);