From b1ff989c3fd67e40363126eb03e8e243cc2e590d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 May 2024 09:25:54 +0000 Subject: [PATCH] Implement tests, fix JWT maxAge --- app/controllers/api/account.php | 7 +- app/controllers/api/functions.php | 7 +- app/controllers/api/messaging.php | 8 +- app/controllers/api/storage.php | 6 +- app/controllers/api/users.php | 11 +- app/controllers/shared/api.php | 6 +- app/init.php | 22 +++- src/Appwrite/Platform/Workers/Functions.php | 3 +- tests/e2e/Services/Users/UsersBase.php | 131 ++++++++++++++++++++ 9 files changed, 178 insertions(+), 23 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 93ec8e2aff..35f8979b25 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2370,15 +2370,12 @@ App::post('/v1/account/jwts') 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. + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); // Instantiate with key, algo, maxAge and leeway. $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', + 'exp' => \intval((new \DateTime())->add(new \DateInterval('PT900S'))->format('U')), 'userId' => $user->getId(), 'sessionId' => $current->getId(), ])]), Response::MODEL_JWT); diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 670ca2f99e..8787cba8dc 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1585,8 +1585,10 @@ App::post('/v1/functions/:functionId/executions') } if (!$current->isEmpty()) { - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); // Instantiate with key, algo, maxAge and leeway. $jwt = $jwtObj->encode([ + 'exp' => \intval((new \DateTime())->add(new \DateInterval('PT' . $jwtExpiry . 'S'))->format('U')), 'userId' => $user->getId(), 'sessionId' => $current->getId(), ]); @@ -1594,8 +1596,9 @@ App::post('/v1/functions/:functionId/executions') } $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); $apiKey = $jwtObj->encode([ + 'exp' => \intval((new \DateTime())->add(new \DateInterval('PT' . $jwtExpiry . 'S'))->format('U')), 'projectId' => $project->getId(), 'scopes' => $function->getAttribute('scopes', []) ]); diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index ceb2d2aca5..fc8f9292be 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -2939,11 +2939,11 @@ App::post('/v1/messaging/messages/push') $expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U'); } - $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1')); + $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); $jwt = $encoder->encode([ 'iat' => \time(), - 'exp' => $expiry, + 'exp' => \intval($expiry), 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), 'projectId' => $project->getId(), @@ -3801,11 +3801,11 @@ App::patch('/v1/messaging/messages/push/:messageId') $expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U'); } - $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1')); + $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); $jwt = $encoder->encode([ 'iat' => \time(), - 'exp' => $expiry, + 'exp' => \intval($expiry), 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), 'projectId' => $project->getId(), diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index f7334e3ebb..cba55b8c87 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1328,7 +1328,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Document $project, string $mode, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1')); + $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); try { $decoded = $decoder->decode($jwt); @@ -1336,6 +1336,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') throw new Exception(Exception::USER_UNAUTHORIZED); } + if($decoded['exp'] < \time()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + if ( $decoded['projectId'] !== $project->getId() || $decoded['bucketId'] !== $bucketId || diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index ca34a696f0..02a9d45962 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -2105,11 +2105,11 @@ App::post('/v1/users/:userId/jwts') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_JWT) ->param('userId', '', new UID(), 'User ID.') - ->param('sessionId', 'current', new UID(), 'Session ID. Use the string \'current\' to use the most recent session. Defaults to the most recent session.') - ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.') + ->param('sessionId', 'current', new UID(), 'Session ID. Use the string \'current\' to use the most recent session. Defaults to the most recent session.', true) + ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, int $duration, string $sessionId, Response $response, Database $dbForProject) { + ->action(function (string $userId, string $sessionId, int $duration, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); @@ -2136,13 +2136,14 @@ App::post('/v1/users/:userId/jwts') throw new Exception(Exception::USER_SESSION_NOT_FOUND); } - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 10); // Instantiate with key, algo, maxAge and leeway. + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); // Instantiate with key, algo, maxAge and leeway. $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic(new Document(['jwt' => $jwt->encode([ + 'exp' => \intval((new \DateTime())->add(new \DateInterval('PT' . $duration . 'S'))->format('U')), 'userId' => $user->getId(), - 'sessionId' => $session->getId(), + 'sessionId' => $session->getId() ])]), Response::MODEL_JWT); }); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fc6792a913..3a1bfce7e2 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -216,7 +216,7 @@ App::init() if($keyType === API_KEY_DYNAMIC) { // Dynamic key - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); try { $payload = $jwtObj->decode($authKey); @@ -224,6 +224,10 @@ App::init() throw new Exception(Exception::API_KEY_EXPIRED); } + if($payload['exp'] < \time()) { + throw new Exception(Exception::API_KEY_EXPIRED); + } + $projectId = $payload['projectId'] ?? ''; $tokenScopes = $payload['scopes'] ?? []; diff --git a/app/init.php b/app/init.php index 3f85b1786a..7fda855d72 100644 --- a/app/init.php +++ b/app/init.php @@ -440,12 +440,10 @@ Database::addFilter( return; }, function (mixed $value, Document $document, Database $database) { - $s = Authorization::skip(fn () => $database->find('sessions', [ + return Authorization::skip(fn () => $database->find('sessions', [ Query::equal('userInternalId', [$document->getInternalId()]), Query::limit(APP_LIMIT_SUBQUERY), ])); - \var_dump($s); - return $s; } ); @@ -1202,7 +1200,7 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons $authJWT = $request->getHeader('x-appwrite-jwt', ''); if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); // Instantiate with key, algo, maxAge and leeway. try { $payload = $jwt->decode($authJWT); @@ -1220,6 +1218,22 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token $user = new Document([]); } + + $exp = $payload['exp'] ?? ''; + + // Fallback to 15m, just in case + if(empty($exp)) { + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + try { + $payload = $jwt->decode($authJWT); + } catch (JWTException $error) { + $user = new Document([]); + } + } else { + if($exp < \time()) { + $user = new Document([]); + } + } } $dbForProject->setMetadata('user', $user->getId()); diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index fc9a1242ac..7b0d45e3c5 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -284,8 +284,9 @@ class Functions extends Action $runtime = $runtimes[$function->getAttribute('runtime')]; $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 10); $apiKey = $jwtObj->encode([ + 'exp' => \intval((new \DateTime())->add(new \DateInterval('PT' . $jwtExpiry . 'S'))->format('U')), 'projectId' => $project->getId(), 'scopes' => $function->getAttribute('scopes', []) ]); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 1737e0483d..6df3a7250c 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -1553,6 +1553,137 @@ trait UsersBase return $data; } + public function testUsetJWT() + { + // Create user + $userId = ID::unique(); + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => $userId, + 'email' => 'jwtuser@appwrite.io', + 'password' => 'password', + ], false); + $this->assertEquals($user['headers']['status-code'], 201); + + // Create two sessions + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => 'jwtuser@appwrite.io', + 'password' => 'password', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['userId']); + $this->assertNotEmpty($response['body']['$id']); + $session1Id = $response['body']['$id']; + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => 'jwtuser@appwrite.io', + 'password' => 'password', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['userId']); + $this->assertNotEmpty($response['body']['$id']); + $session2Id = $response['body']['$id']; + + // Create JWT 1 for older session by ID + $response = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'sessionId' => $session1Id + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + $jwt1 = $response['body']['jwt']; + + // Ensure JWT 1 works + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt1, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['$id']); + + // Create JWT 2 for latest session using default param + $response = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'duration' => 5 + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + $jwt2 = $response['body']['jwt']; + + // Ensure JWT 2 works + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt2, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['$id']); + + // Wait, ensure JWT 2 no longer works because of short duration + + \sleep(10); + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt2, + ])); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Delete session, ensure JWT 1 no longer works because of session missing + + $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId . '/sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'sessionId' => $session1Id + ]); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt1, + ])); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Cleanup after test + + $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($response['headers']['status-code'], 204); + } + // TODO add test for session delete // TODO add test for all sessions delete }