From 8ab429b92fde55fa09a65039a96fcc85d998e136 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:38:32 +0100 Subject: [PATCH] feat: replace session confirmation endpoints --- app/controllers/api/account.php | 280 ++---------------- .../Utopia/Response/Model/Session.php | 2 +- tests/e2e/Services/Account/AccountBase.php | 8 +- .../Account/AccountCustomClientTest.php | 4 +- tests/e2e/Services/Users/UsersBase.php | 1 - 5 files changed, 39 insertions(+), 256 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a4caf320f0..0b8dd828b4 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -211,6 +211,10 @@ App::post('/v1/account/sessions/email') throw new Exception(Exception::USER_BLOCKED); // User is in status blocked } + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + $user->setAttributes($profile->getArrayCopy()); $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; @@ -274,7 +278,7 @@ App::post('/v1/account/sessions/email') ->setAttribute('current', true) ->setAttribute('countryName', $countryName) ->setAttribute('expire', $expire) - ->setAttribute('secret', $secret) + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : '') ; $events @@ -1111,8 +1115,10 @@ App::post('/v1/account/sessions/magic-url') }); App::put('/v1/account/sessions/token') -->desc('Update universal token session') -->groups(['api', 'account']) + ->alias('/v1/account/sessions/magic-url') + ->alias('/v1/account/sessions/phone') + ->desc('Update token session') + ->groups(['api', 'account']) ->label('scope', 'public') ->label('auth.type', 'token') ->label('audits.event', 'session.create') @@ -1121,7 +1127,7 @@ App::put('/v1/account/sessions/token') ->label('usage.metric', 'sessions.{scope}.requests.create') ->label('sdk.auth', []) ->label('sdk.namespace', 'account') - ->label('sdk.method', 'exchangeTokenForSession') + ->label('sdk.method', 'updateTokenSession') ->label('sdk.description', '/docs/references/account/update-universal-token-session.md') ->label('sdk.response.code', Response::STATUS_CODE_CREATED) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) @@ -1139,6 +1145,10 @@ App::put('/v1/account/sessions/token') ->inject('geodb') ->inject('events') ->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + /** @var Utopia\Database\Document $user */ $userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -1189,131 +1199,14 @@ App::put('/v1/account/sessions/token') Authorization::skip(fn () => $dbForProject->deleteDocument('tokens', $verifiedToken->getId())); $dbForProject->deleteCachedDocument('users', $user->getId()); - try { - $dbForProject->updateDocument('users', $user->getId(), $user); - } catch (\Throwable $th) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); + if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_MAGIC_URL) { + $user->setAttribute('emailVerification', true); } - - $events - ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()); - - - if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $sessionSecret)])); + + if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_PHONE) { + $user->setAttribute('phoneVerification', true); } - $protocol = $request->getProtocol(); - - $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) - ->setStatusCode(Response::STATUS_CODE_CREATED); - - $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); - - $session - ->setAttribute('current', true) - ->setAttribute('countryName', $countryName) - ->setAttribute('expire', $expire) - ->setAttribute('secret', $sessionSecret) - ; - - $response->dynamic($session, Response::MODEL_SESSION); - }); - -App::put('/v1/account/sessions/magic-url') - ->desc('Create magic URL session (confirmation)') - ->groups(['api', 'account', 'session']) - ->label('scope', 'public') - ->label('event', 'users.[userId].sessions.[sessionId].create') - ->label('audits.event', 'session.update') - ->label('audits.resource', 'user/{response.userId}') - ->label('audits.userId', '{response.userId}') - ->label('usage.metric', 'sessions.{scope}.requests.create') - ->label('usage.params', ['provider:magic-url']) - ->label('sdk.auth', []) - ->label('sdk.namespace', 'account') - ->label('sdk.method', 'updateMagicURLSession') - ->label('sdk.description', '/docs/references/account/update-magic-url-session.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_SESSION) - ->label('abuse-limit', 10) - ->label('abuse-key', 'url:{url},userId:{param-userId}') - ->param('userId', '', new CustomId(), 'User ID.') - ->param('secret', '', new Text(256), 'Valid verification token.') - ->inject('request') - ->inject('response') - ->inject('user') - ->inject('dbForProject') - ->inject('project') - ->inject('locale') - ->inject('geodb') - ->inject('events') - ->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { - - /** @var Utopia\Database\Document $user */ - - $userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); - - if ($userFromRequest->isEmpty()) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret); - - if (!$verifiedToken) { - throw new Exception(Exception::USER_INVALID_TOKEN); - } - - $user->setAttributes($userFromRequest->getArrayCopy()); - - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; - $detector = new Detector($request->getUserAgent('UNKNOWN')); - $record = $geodb->get($request->getIP()); - $sessionSecret = Auth::tokenGenerator(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); - - $session = new Document(array_merge( - [ - '$id' => ID::unique(), - 'userId' => $user->getId(), - 'userInternalId' => $user->getInternalId(), - 'provider' => Auth::SESSION_PROVIDER_MAGIC_URL, - 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak - 'userAgent' => $request->getUserAgent('UNKNOWN'), - 'ip' => $request->getIP(), - 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', - ], - $detector->getOS(), - $detector->getClient(), - $detector->getDevice() - )); - - Authorization::setRole(Role::user($user->getId())->toString()); - - $session = $dbForProject->createDocument('sessions', $session - ->setAttribute('$permissions', [ - Permission::read(Role::user($user->getId())), - Permission::update(Role::user($user->getId())), - Permission::delete(Role::user($user->getId())), - ])); - - $dbForProject->deleteCachedDocument('users', $user->getId()); - - $tokens = $user->getAttribute('tokens', []); - - /** - * We act like we're updating and validating - * the recovery token but actually we don't need it anymore. - */ - $dbForProject->deleteDocument('tokens', $verifiedToken->getId()); - $dbForProject->deleteCachedDocument('users', $user->getId()); - - $user->setAttribute('emailVerification', true); - try { $dbForProject->updateDocument('users', $user->getId(), $user); } catch (\Throwable $th) { @@ -1324,15 +1217,19 @@ App::put('/v1/account/sessions/magic-url') ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()); + + $encodedSession = Auth::encodeSession($user->getId(), $sessionSecret); + if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $sessionSecret)])); + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => $encodedSession])); } $protocol = $request->getProtocol(); + $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie(Auth::$cookieName . '_legacy', $encodedSession, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie(Auth::$cookieName, $encodedSession, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); @@ -1341,7 +1238,7 @@ App::put('/v1/account/sessions/magic-url') ->setAttribute('current', true) ->setAttribute('countryName', $countryName) ->setAttribute('expire', $expire) - ->setAttribute('secret', $sessionSecret) + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $sessionSecret : '') ; $response->dynamic($session, Response::MODEL_SESSION); @@ -1483,123 +1380,6 @@ App::post('/v1/account/sessions/phone') ; }); -App::put('/v1/account/sessions/phone') - ->desc('Create phone session (confirmation)') - ->groups(['api', 'account', 'session']) - ->label('scope', 'public') - ->label('event', 'users.[userId].sessions.[sessionId].create') - ->label('sdk.auth', []) - ->label('sdk.namespace', 'account') - ->label('sdk.method', 'updatePhoneSession') - ->label('sdk.description', '/docs/references/account/update-phone-session.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_SESSION) - ->label('abuse-limit', 10) - ->label('abuse-key', 'url:{url},userId:{param-userId}') - ->param('userId', '', new CustomId(), 'User ID.') - ->param('secret', '', new Text(256), 'Valid verification token.') - ->inject('request') - ->inject('response') - ->inject('user') - ->inject('dbForProject') - ->inject('project') - ->inject('locale') - ->inject('geodb') - ->inject('events') - ->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) { - - $userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); - - if ($userFromRequest->isEmpty()) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), Auth::TOKEN_TYPE_PHONE, $secret); - - if (!$verifiedToken) { - throw new Exception(Exception::USER_INVALID_TOKEN); - } - - $user->setAttributes($userFromRequest->getArrayCopy()); - - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; - $detector = new Detector($request->getUserAgent('UNKNOWN')); - $record = $geodb->get($request->getIP()); - $sessionSecret = Auth::tokenGenerator(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); - - $session = new Document(array_merge( - [ - '$id' => ID::unique(), - 'userId' => $user->getId(), - 'userInternalId' => $user->getInternalId(), - 'provider' => Auth::SESSION_PROVIDER_PHONE, - 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak - 'userAgent' => $request->getUserAgent('UNKNOWN'), - 'ip' => $request->getIP(), - 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', - ], - $detector->getOS(), - $detector->getClient(), - $detector->getDevice() - )); - - Authorization::setRole(Role::user($user->getId())->toString()); - - $session = $dbForProject->createDocument('sessions', $session - ->setAttribute('$permissions', [ - Permission::read(Role::user($user->getId())), - Permission::update(Role::user($user->getId())), - Permission::delete(Role::user($user->getId())), - ])); - - $dbForProject->deleteCachedDocument('users', $user->getId()); - - /** - * We act like we're updating and validating - * the recovery token but actually we don't need it anymore. - */ - $dbForProject->deleteDocument('tokens', $verifiedToken->getId()); - $dbForProject->deleteCachedDocument('users', $user->getId()); - - $user->setAttribute('phoneVerification', true); - - $dbForProject->updateDocument('users', $user->getId(), $user); - - if (false === $user) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); - } - - $events - ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()) - ; - - if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); - } - - $protocol = $request->getProtocol(); - - $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) - ->setStatusCode(Response::STATUS_CODE_CREATED) - ; - - $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); - - $session - ->setAttribute('current', true) - ->setAttribute('countryName', $countryName) - ->setAttribute('expire', $expire) - ->setAttribute('secret', $sessionSecret) - ; - - $response->dynamic($session, Response::MODEL_SESSION); - }); - App::post('/v1/account/sessions/anonymous') ->desc('Create anonymous session') ->groups(['api', 'account', 'auth', 'session']) @@ -1640,6 +1420,10 @@ App::post('/v1/account/sessions/anonymous') throw new Exception(Exception::USER_SESSION_ALREADY_EXISTS, 'Cannot create an anonymous user when logged in'); } + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -1732,7 +1516,7 @@ App::post('/v1/account/sessions/anonymous') ->setAttribute('current', true) ->setAttribute('countryName', $countryName) ->setAttribute('expire', $expire) - ->setAttribute('secret', $secret) + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : '') ; $response->dynamic($session, Response::MODEL_SESSION); diff --git a/src/Appwrite/Utopia/Response/Model/Session.php b/src/Appwrite/Utopia/Response/Model/Session.php index f65e9cf2ac..d249897d76 100644 --- a/src/Appwrite/Utopia/Response/Model/Session.php +++ b/src/Appwrite/Utopia/Response/Model/Session.php @@ -162,7 +162,7 @@ class Session extends Model ]) ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Secret used to authenticate the user.', + 'description' => 'Secret used to authenticate the user. Only included if the request was made with an API key', 'default' => '', 'example' => '5e5bb8c16897e', ]) diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index cd6166fbc0..980f46828c 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -153,7 +153,7 @@ trait AccountBase ]); $this->assertEquals($response['headers']['status-code'], 201); - $this->assertNotEmpty($response['body']['secret']); + $this->assertEmpty($response['body']['secret']); $this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['expire'])); /** @@ -1391,7 +1391,7 @@ trait AccountBase $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['userId']); - $this->assertNotEmpty($response['body']['secret']); + $this->assertEmpty($response['body']['secret']); $sessionId = $response['body']['$id']; $session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']]; @@ -1422,7 +1422,7 @@ trait AccountBase 'secret' => $token, ]); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/magic-url', array_merge([ 'origin' => 'http://localhost', @@ -1479,7 +1479,7 @@ trait AccountBase ]); $this->assertEquals($response['headers']['status-code'], 201); - $this->assertNotEmpty($response['body']['secret']); + $this->assertEmpty($response['body']['secret']); /** * Test for FAILURE diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 9c662a9648..629fd0bad0 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -818,7 +818,7 @@ class AccountCustomClientTest extends Scope 'secret' => $token, ]); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ 'origin' => 'http://localhost', @@ -1012,7 +1012,7 @@ class AccountCustomClientTest extends Scope $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['userId']); $this->assertNotEmpty($response['body']['expire']); - $this->assertNotEmpty($response['body']['secret']); + $this->assertEmpty($response['body']['secret']); /** * Test for FAILURE diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 674d0f85f8..432bc95ffb 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -254,7 +254,6 @@ trait UsersBase ], $this->getHeaders())); $this->assertEquals($token['headers']['status-code'], 404); - $this->assertEmpty($token['body']['secret']); } /**