diff --git a/app/config/errors.php b/app/config/errors.php index 9d30170c2d..731c882f13 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -499,6 +499,11 @@ return [ 'description' => 'The requested file token has expired.', 'code' => 401, ], + Exception::TOKEN_RESOURCE_INVALID => [ + 'name' => Exception::TOKEN_RESOURCE_INVALID, + 'description' => 'The resource for the token is invalid.', + 'code' => 400, + ], /** VCS */ Exception::INSTALLATION_NOT_FOUND => [ diff --git a/app/init/resources.php b/app/init/resources.php index 1b941b63b3..1372240bb3 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -925,31 +925,46 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { $token = Authorization::skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId)); - if ($token->isEmpty() || $token->getAttribute('secret') !== $secret) { + if ($token->isEmpty()) { return new Document([]); } - if ($token->getAttribute('resourceType') === TOKENS_RESOURCE_TYPE_FILES) { - $internalIds = explode(':', $token->getAttribute('resourceInternalId')); - $ids = explode(':', $token->getAttribute('resourceId')); + $expiry = $token->getAttribute('expire'); - if (count($internalIds) !== 2 || count($ids) !== 2) { + if ($expiry !== null) { + $now = new \DateTime(); + $expiryDate = new \DateTime($expiry); + + if ($expiryDate < $now) { return new Document([]); } - - $accessedAt = $token->getAttribute('accessedAt', 0); - if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), - APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { - $token->setAttribute('accessedAt', DatabaseDateTime::now()); - Authorization::skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token)); - } - - return new Document([ - 'bucketId' => $ids[0], - 'fileId' => $ids[1], - 'bucketInternalId' => $internalIds[0], - 'fileInternalId' => $internalIds[1], - ]); } + + return match ($token->getAttribute('resourceType')) { + TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject) { + $internalIds = explode(':', $token->getAttribute('resourceInternalId')); + $ids = explode(':', $token->getAttribute('resourceId')); + + if (count($internalIds) !== 2 || count($ids) !== 2) { + return new Document([]); + } + + $accessedAt = $token->getAttribute('accessedAt', 0); + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), - APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { + $token->setAttribute('accessedAt', DatabaseDateTime::now()); + Authorization::skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token)); + } + + return new Document([ + 'bucketId' => $ids[0], + 'fileId' => $ids[1], + 'bucketInternalId' => $internalIds[0], + 'fileInternalId' => $internalIds[1], + ]); + })(), + + default => throw new Exception(Exception::TOKEN_RESOURCE_INVALID), + }; } return new Document([]); }, ['project', 'dbForProject', 'request']); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 7df9134c9f..f2bd2f99e9 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -322,7 +322,7 @@ class Exception extends \Exception /** Tokens */ public const TOKEN_NOT_FOUND = 'token_not_found'; public const TOKEN_EXPIRED = 'token_expired'; - + public const TOKEN_RESOURCE_INVALID = 'token_resource_invalid'; protected string $type = ''; protected array $errors = []; diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php index 4f63256ec8..5089898d33 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php @@ -71,7 +71,7 @@ class Create extends Action ->callback([$this, 'action']); } - public function action(string $bucketId, string $fileId, ?string $expire, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $queueForEvents) + public function action(string $bucketId, string $fileId, ?string $expire, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $queueForEvents): void { /** diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/JWT/Get.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/JWT/Get.php deleted file mode 100644 index 02c00b74bb..0000000000 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/JWT/Get.php +++ /dev/null @@ -1,91 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/tokens/:tokenId/jwt') - ->desc('Get token as JWT') - ->groups(['api', 'tokens']) - ->label('scope', 'tokens.read') - ->label('usage.metric', 'tokens.{scope}.requests.read') - ->label('usage.params', ['tokenId:{request.tokenId}']) - ->label('sdk', new Method( - namespace: 'tokens', - group: 'tokens', - name: 'getJWT', - description: <<param('tokenId', '', new UID(), 'File token ID.') - ->inject('response') - ->inject('dbForProject') - ->callback([$this, 'action']); - } - - public function action(string $tokenId, Response $response, Database $dbForProject) - { - $token = $dbForProject->getDocument('resourceTokens', $tokenId); - - if ($token->isEmpty()) { - throw new Exception(Exception::TOKEN_NOT_FOUND); - } - - // calculate maxAge based on expiry date - $maxAge = PHP_INT_MAX; - $expire = $token->getAttribute('expire'); - if ($expire !== null) { - $now = new \DateTime(); - $expiryDate = new \DateTime($expire); - if ($expiryDate < $now) { - throw new Exception(Exception::TOKEN_EXPIRED); - } - $maxAge = $expiryDate->getTimestamp() - $now->getTimestamp(); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $maxAge, 10); // Instantiate with key, algo, maxAge and leeway. - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document(['jwt' => $jwt->encode([ - 'resourceType' => $token->getAttribute('resourceType'), - 'resourceId' => $token->getAttribute('resourceId'), - 'resourceInternalId' => $token->getAttribute('resourceInternalId'), - 'tokenId' => $token->getId(), - 'secret' => $token->getAttribute('secret') - ])]), Response::MODEL_JWT); - } -} diff --git a/src/Appwrite/Platform/Modules/Tokens/Services/Http.php b/src/Appwrite/Platform/Modules/Tokens/Services/Http.php index bbb85e85fd..ac58c110a5 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Tokens/Services/Http.php @@ -6,7 +6,6 @@ use Appwrite\Platform\Modules\Tokens\Http\Tokens\Buckets\Files\Create as CreateF use Appwrite\Platform\Modules\Tokens\Http\Tokens\Buckets\Files\XList as ListFileTokens; use Appwrite\Platform\Modules\Tokens\Http\Tokens\Delete as DeleteToken; use Appwrite\Platform\Modules\Tokens\Http\Tokens\Get as GetToken; -use Appwrite\Platform\Modules\Tokens\Http\Tokens\JWT\Get as GetTokenJWT; use Appwrite\Platform\Modules\Tokens\Http\Tokens\Update as UpdateToken; use Utopia\Platform\Service; @@ -18,7 +17,6 @@ class Http extends Service $this ->addAction(CreateFileToken::getName(), new CreateFileToken()) ->addAction(GetToken::getName(), new GetToken()) - ->addAction(GetTokenJWT::getName(), new GetTokenJWT()) ->addAction(ListFileTokens::getName(), new ListFileTokens()) ->addAction(UpdateToken::getName(), new UpdateToken()) ->addAction(DeleteToken::getName(), new DeleteToken()) diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index 083a3e5f2d..9bc7a46a5b 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -2,8 +2,11 @@ namespace Appwrite\Utopia\Response\Model; +use Ahc\Jwt\JWT; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; +use Utopia\System\System; class ResourceToken extends Model { @@ -47,6 +50,13 @@ class ResourceToken extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('secret', [ + 'type' => self::TYPE_STRING, + 'description' => 'JWT encoded string.', + 'default' => '', + // this is a secret but is converted to a JWT token when sent back to the client after filter. + 'example' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + ]) ->addRule('accessedAt', [ 'type' => self::TYPE_DATETIME, 'description' => 'Most recent access date in ISO 8601 format. This attribute is only updated again after ' . APP_RESOURCE_TOKEN_ACCESS / 60 / 60 . ' hours.', @@ -56,6 +66,32 @@ class ResourceToken extends Model ; } + public function filter(Document $document): Document + { + $maxAge = PHP_INT_MAX; + $expire = $document->getAttribute('expire'); + + if ($expire !== null) { + $now = new \DateTime(); + $expiryDate = new \DateTime($expire); + + // set 1 min if expired, we check for expiry later on route hooks for validation! + $maxAge = min(360, $expiryDate->getTimestamp() - $now->getTimestamp()); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $maxAge, 10); + $secret = $jwt->encode([ + 'tokenId' => $document->getId(), + 'resourceId' => $document->getAttribute('resourceId'), + 'resourceType' => $document->getAttribute('resourceType'), + 'resourceInternalId' => $document->getAttribute('resourceInternalId'), + ]); + + $document->setAttribute('secret', $secret); + + return $document; + } + /** * Get Name * diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index c7ae1d0598..af93f5fc73 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -66,7 +66,7 @@ trait TokensBase return [ 'fileId' => $fileId, 'bucketId' => $bucketId, - 'tokenId' => $token['body']['$id'], + 'token' => $token['body'], 'guestHeaders' => [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -143,22 +143,12 @@ trait TokensBase */ public function testPreviewFileWithToken(array $data): array { + $token = $data['token']; $fileId = $data['fileId']; - $tokenId = $data['tokenId']; $bucketId = $data['bucketId']; $guestHeaders = $data['guestHeaders']; - $adminHeaders = array_merge($guestHeaders, ['x-appwrite-key' => $this->getProject()['apiKey']]); - // Generate JWT as an admin user. - $tokenJWT = $this->client->call( - Client::METHOD_GET, - '/tokens/' . $tokenId . '/jwt/', - $adminHeaders - ); - $this->assertEquals(200, $tokenJWT['headers']['status-code']); - $this->assertArrayHasKey('jwt', $tokenJWT['body']); - - $tokenJWT = $tokenJWT['body']['jwt']; + $tokenJWT = $token['secret']; // Generate a preview $filePreview = $this->client->call(