From 2442cc4ceadd51840209c7bec070c6b4c1617bf8 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 17 May 2023 18:11:45 -0700 Subject: [PATCH 1/7] Separate OAuth2 info from Sessions into Identities This allows us to retain the OAuth2 info even if the session is deleted. This also provides a foundation for allowing multiple emails, phone numbers, etc, not from an OAuth2 provider. --- app/config/collections.php | 165 +++++++++++++++ app/config/errors.php | 5 + app/controllers/api/account.php | 193 +++++++++++++++++- app/controllers/api/teams.php | 8 + app/controllers/api/users.php | 97 +++++++++ src/Appwrite/Extend/Exception.php | 1 + .../Database/Validator/Queries/Identities.php | 23 +++ src/Appwrite/Utopia/Response.php | 5 + .../Utopia/Response/Model/Identity.php | 101 +++++++++ 9 files changed, 594 insertions(+), 4 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Identities.php create mode 100644 src/Appwrite/Utopia/Response/Model/Identity.php diff --git a/app/config/collections.php b/app/config/collections.php index 6a608fe5f5c..267d099fdc1 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1921,6 +1921,171 @@ ], ], + 'identities' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('identities'), + 'name' => 'Identities', + 'attributes' => [ + [ + '$id' => ID::custom('userInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('userId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('provider'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => true, + 'default' => 'connected', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('providerUid'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 2048, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('providerEmail'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 320, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('providerAccessToken'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['encrypt'], + ], + [ + '$id' => ID::custom('providerAccessTokenExpiry'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('providerRefreshToken'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['encrypt'], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_userInternalId_provider_providerUid'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['userInternalId', 'provider', 'providerUid'], + 'lengths' => [Database::LENGTH_KEY, 100, 385], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_provider_providerUid'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['provider', 'providerUid'], + 'lengths' => [100, 640], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_userId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['userId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_userInternalId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['userInternalId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_provider'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['provider'], + 'lengths' => [100], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['status'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_providerUid'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['providerUid'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_providerEmail'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['providerEmail'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + ], + ], + 'teams' => [ '$collection' => ID::custom(Database::METADATA), '$id' => ID::custom('teams'), diff --git a/app/config/errors.php b/app/config/errors.php index 0c5067f1f62..3ac2e307d97 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -170,6 +170,11 @@ 'description' => 'The current user session could not be found.', 'code' => 404, ], + Exception::USER_IDENTITY_NOT_FOUND => [ + 'name' => Exception::USER_IDENTITY_NOT_FOUND, + 'description' => 'The identity could not be found.', + 'code' => 404, + ], Exception::USER_UNAUTHORIZED => [ 'name' => Exception::USER_UNAUTHORIZED, 'description' => 'The current user is not authorized to perform the requested action.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a96f537f761..4eb91ad732a 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -16,6 +16,7 @@ use Appwrite\Template\Template; use Appwrite\URL\URL as URLParser; use Appwrite\Utopia\Database\Validator\CustomId; +use Appwrite\Utopia\Database\Validator\Queries\Identities; use Utopia\Database\Validator\Queries; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; @@ -99,6 +100,14 @@ } } + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } + $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); try { @@ -461,6 +470,7 @@ } $oauth2ID = $oauth2->getUserID($accessToken); + if (empty($oauth2ID)) { if (!empty($state['failure'])) { @@ -470,6 +480,22 @@ throw new Exception(Exception::USER_MISSING_ID); } + $name = $oauth2->getUserName($accessToken); + $email = $oauth2->getUserEmail($accessToken); + + // Check if this identity is connected to a different user + if (!$user->isEmpty()) { + $userId = $user->getId(); + + $identitiesWithMatchingEmail = $dbForProject->find('identities', [ + Query::equal('providerEmail', [$email]), + Query::notEqual('userId', $userId), + ]); + if (!empty($identitiesWithMatchingEmail)) { + throw new Exception(Exception::USER_ALREADY_EXISTS); + } + } + $sessions = $user->getAttribute('sessions', []); $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $current = Auth::sessionVerify($sessions, Auth::$secret, $authDuration); @@ -493,8 +519,6 @@ } if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email - $name = $oauth2->getUserName($accessToken); - $email = $oauth2->getUserEmail($accessToken); /** * Is verified is not used yet, since we don't know after an accout is created anymore if it was verified or not. @@ -508,7 +532,19 @@ $user->setAttributes($userWithEmail->getArrayCopy()); } - if ($user === false || $user->isEmpty()) { // Last option -> create the user, generate random password + // If user is not found, check if there is an identity with the same provider user ID + if ($user === false || $user->isEmpty()) { + $identity = $dbForProject->findOne('identities', [ + Query::equal('provider', [$provider]), + Query::equal('providerUid', [$oauth2ID]), + ]); + + if ($identity !== false && !$identity->isEmpty()) { + $user = $dbForProject->getDocument('users', $identity->getAttribute('userId')); + } + } + + if ($user === false || $user->isEmpty()) { // Last option -> create the user $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -519,7 +555,13 @@ } } - $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + $failureRedirect(Exception::USER_EMAIL_ALREADY_EXISTS); + } try { $userId = ID::unique(); @@ -555,10 +597,56 @@ } } + Authorization::setRole(Role::user($user->getId())->toString()); + Authorization::setRole(Role::users()->toString()); + if (false === $user->getAttribute('status')) { // Account is blocked throw new Exception(Exception::USER_BLOCKED); // User is in status blocked } + $identity = $dbForProject->findOne('identities', [ + Query::equal('userInternalId', [$user->getInternalId()]), + Query::equal('provider', [$provider]), + Query::equal('providerUid', [$oauth2ID]), + ]); + if ($identity === false || $identity->isEmpty()) { + // Before creating the identity, check if the email is already associated with another user + $userId = $user->getId(); + + $identitiesWithMatchingEmail = $dbForProject->find('identities', [ + Query::equal('providerEmail', [$email]), + Query::notEqual('userId', $user->getId()), + ]); + if (!empty($identitiesWithMatchingEmail)) { + $failureRedirect(Exception::USER_EMAIL_ALREADY_EXISTS); + } + + $dbForProject->createDocument('identities', new Document([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + 'userInternalId' => $user->getInternalId(), + 'userId' => $userId, + 'provider' => $provider, + 'status' => 'connected', + 'providerUid' => $oauth2ID, + 'providerEmail' => $email, + 'providerAccessToken' => $accessToken, + 'providerRefreshToken' => $refreshToken, + 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry), + ])); + } else { + $identity + ->setAttribute('status', 'connected') + ->setAttribute('providerAccessToken', $accessToken) + ->setAttribute('providerRefreshToken', $refreshToken) + ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry)); + $dbForProject->updateDocument('identities', $identity->getId(), $identity); + } + // Create session token, verify user account and update OAuth2 ID and Access Token $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); @@ -638,6 +726,86 @@ ; }); +App::get('/v1/account/identities') + ->desc('List Identities') + ->groups(['api', 'account']) + ->label('scope', 'account') + ->label('usage.metric', 'users.{scope}.requests.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'listConnected') + ->label('sdk.description', '/docs/references/account/list-connected.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_IDENTITY_LIST) + ->label('sdk.offline.model', '/account/identities') + ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->action(function (array $queries, Response $response, Document $user, Database $dbForProject) { + + $queries = Query::parseQueries($queries); + + $queries[] = Query::equal('userInternalId', [$user->getInternalId()]); + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, [Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE]); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + $identityId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('identities', $identityId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Identity '{$identityId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $results = $dbForProject->find('identities', $queries); + $total = $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT); + + $response->dynamic(new Document([ + 'identities' => $results, + 'total' => $total, + ]), Response::MODEL_IDENTITY_LIST); + }); + +App::delete('/v1/account/identities/:identityId') + ->desc('Delete Identity') + ->groups(['api', 'account']) + ->label('scope', 'account') + ->label('event', 'users.[userId].identities.[identityId].delete') + ->label('audits.event', 'identity.delete') + ->label('audits.resource', 'identity/{request.$identityId}') + ->label('audits.userId', '{user.$id}') + ->label('usage.metric', 'identities.{scope}.requests.delete') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'deleteIdentity') + ->label('sdk.description', '/docs/references/account/delete-identity.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('identityId', [], new UID(), '') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $identityId, Response $response, Database $dbForProject) { + + $identity = $dbForProject->getDocument('identities', $identityId); + + if ($identity->isEmpty()) { + throw new Exception(Exception::USER_IDENTITY_NOT_FOUND); + } + + $dbForProject->deleteDocument('identities', $identityId); + + return $response->noContent(); + }); + App::post('/v1/account/sessions/magic-url') ->desc('Create Magic URL session') ->groups(['api', 'account']) @@ -690,6 +858,14 @@ } } + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } + $userId = $userId == 'unique()' ? ID::unique() : $userId; $user->setAttributes([ @@ -1686,6 +1862,15 @@ $email = \strtolower($email); + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + Query::notEqual('userId', $user->getId()), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } + $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 4687cc0f984..8f94e78406a 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -454,6 +454,14 @@ } } + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } + try { $userId = ID::unique(); $invitee = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 683858aed4b..5355fb09e28 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -9,6 +9,7 @@ use Appwrite\Network\Validator\Email; use Appwrite\Utopia\Database\Validator\CustomId; use Utopia\Database\Validator\Queries; +use Appwrite\Utopia\Database\Validator\Queries\Identities; use Appwrite\Utopia\Database\Validator\Queries\Users; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; @@ -46,6 +47,14 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e if (!empty($email)) { $email = \strtolower($email); + + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } } try { @@ -628,6 +637,53 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e ]), Response::MODEL_LOG_LIST); }); +App::get('/v1/users/identities') + ->desc('List Identities') + ->groups(['api', 'users']) + ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'listIdentities') + ->label('sdk.description', '/docs/references/users/list-identities.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_IDENTITY_LIST) + ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForProject') + ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { + + $queries = Query::parseQueries($queries); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, [Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE]); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + $identityId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('identities', $identityId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "User '{$identityId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $response->dynamic(new Document([ + 'identities' => $dbForProject->find('identities', $queries), + 'total' => $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT), + ]), Response::MODEL_IDENTITY_LIST); + }); + App::patch('/v1/users/:userId/status') ->desc('Update User Status') ->groups(['api', 'users']) @@ -904,6 +960,15 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e $email = \strtolower($email); + // Makes sure this email is not already used in another identity + $identityWithMatchingEmail = $dbForProject->findOne('identities', [ + Query::equal('providerEmail', [$email]), + Query::notEqual('userId', $user->getId()), + ]); + if ($identityWithMatchingEmail !== false && !$identityWithMatchingEmail->isEmpty()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } + $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) @@ -1165,6 +1230,38 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e $response->noContent(); }); +App::delete('/v1/users/identities/:identityId') + ->desc('Delete Identity') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].identities.[identityId].delete') + ->label('scope', 'users.write') + ->label('audits.event', 'identity.delete') + ->label('audits.resource', 'identity/{request.$identityId}') + ->label('usage.metric', 'users.{scope}.requests.delete') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'deleteIdentity') + ->label('sdk.description', '/docs/references/users/delete-dentity.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('identityId', '', new UID(), 'Identity ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('events') + ->inject('deletes') + ->action(function (string $identityId, Response $response, Database $dbForProject, Event $events, Delete $deletes) { + + $identity = $dbForProject->getDocument('identities', $identityId); + + if ($identity->isEmpty()) { + throw new Exception(Exception::USER_IDENTITY_NOT_FOUND); + } + + $dbForProject->deleteDocument('identities', $identityId); + + return $response->noContent(); + }); + App::get('/v1/users/usage') ->desc('Get usage stats for the users API') ->groups(['api', 'users', 'usage']) diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d97032bd81f..573c01fbfc1 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -70,6 +70,7 @@ class Exception extends \Exception public const USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists'; public const USER_PASSWORD_MISMATCH = 'user_password_mismatch'; public const USER_SESSION_NOT_FOUND = 'user_session_not_found'; + public const USER_IDENTITY_NOT_FOUND = 'user_identity_not_found'; public const USER_UNAUTHORIZED = 'user_unauthorized'; public const USER_AUTH_METHOD_UNSUPPORTED = 'user_auth_method_unsupported'; public const USER_PHONE_ALREADY_EXISTS = 'user_phone_already_exists'; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php b/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php new file mode 100644 index 00000000000..6d51740f92a --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php @@ -0,0 +1,23 @@ +setModel(new BaseList('Indexes List', self::MODEL_INDEX_LIST, 'indexes', self::MODEL_INDEX)) ->setModel(new BaseList('Users List', self::MODEL_USER_LIST, 'users', self::MODEL_USER)) ->setModel(new BaseList('Sessions List', self::MODEL_SESSION_LIST, 'sessions', self::MODEL_SESSION)) + ->setModel(new BaseList('Identities List', self::MODEL_IDENTITY_LIST, 'identities', self::MODEL_IDENTITY)) ->setModel(new BaseList('Logs List', self::MODEL_LOG_LIST, 'logs', self::MODEL_LOG)) ->setModel(new BaseList('Files List', self::MODEL_FILE_LIST, 'files', self::MODEL_FILE)) ->setModel(new BaseList('Buckets List', self::MODEL_BUCKET_LIST, 'buckets', self::MODEL_BUCKET)) @@ -317,6 +321,7 @@ public function __construct(SwooleHTTPResponse $response) ->setModel(new Account()) ->setModel(new Preferences()) ->setModel(new Session()) + ->setModel(new Identity()) ->setModel(new Token()) ->setModel(new JWT()) ->setModel(new Locale()) diff --git a/src/Appwrite/Utopia/Response/Model/Identity.php b/src/Appwrite/Utopia/Response/Model/Identity.php new file mode 100644 index 00000000000..858f1da8561 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Identity.php @@ -0,0 +1,101 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Identity ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Identity creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Identity update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('userId', [ + 'type' => self::TYPE_STRING, + 'description' => 'User ID.', + 'default' => '', + 'example' => '5e5bb8c16897e', + ]) + ->addRule('provider', [ + 'type' => self::TYPE_STRING, + 'description' => 'Identity Provider.', + 'default' => '', + 'example' => 'email', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Connection status. Can be connected or disconnected', + 'default' => '', + 'example' => 'connected', + ]) + ->addRule('providerUid', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the User in the Identity Provider.', + 'default' => '', + 'example' => '5e5bb8c16897e', + ]) + ->addRule('providerEmail', [ + 'type' => self::TYPE_STRING, + 'description' => 'Email of the User in the Identity Provider.', + 'default' => '', + 'example' => 'user@example.com', + ]) + ->addRule('providerAccessToken', [ + 'type' => self::TYPE_STRING, + 'description' => 'Identity Provider Access Token.', + 'default' => '', + 'example' => 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3', + ]) + ->addRule('providerAccessTokenExpiry', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'The date of when the access token expires in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('providerRefreshToken', [ + 'type' => self::TYPE_STRING, + 'description' => 'Identity Provider Refresh Token.', + 'default' => '', + 'example' => 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Identity'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_IDENTITY; + } +} From 21bea8e7fec100f479ea375284503d29594ea02f Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:12:48 -0700 Subject: [PATCH 2/7] Ensure a user's identities are deleted when user is deleted --- app/workers/deletes.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/workers/deletes.php b/app/workers/deletes.php index f27bc4feb92..351c70cc2cd 100644 --- a/app/workers/deletes.php +++ b/app/workers/deletes.php @@ -366,6 +366,7 @@ protected function deleteProject(Document $document): void protected function deleteUser(Document $document, string $projectId): void { $userId = $document->getId(); + $userInternalId = $document->getInternalId(); // Delete all sessions of this user from the sessions table and update the sessions field of the user record $this->deleteByGroup('sessions', [ @@ -399,6 +400,11 @@ protected function deleteUser(Document $document, string $projectId): void $this->deleteByGroup('tokens', [ Query::equal('userId', [$userId]) ], $this->getProjectDB($projectId)); + + // Delete identities + $this->deleteByGroup('identities', [ + Query::equal('userInternalId', [$userInternalId]) + ], $this->getProjectDB($projectId)); } /** From 183d0d788cc2806f009b2e0d215b2b73b2f4eda5 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:17:05 -0700 Subject: [PATCH 3/7] Don't set password when oauth2 creates a user Setting a password can cause problems with other APIs that expect the password to be null. In addition, it doesn't match the implementation for the other APIs that create a user without a password (Create Magic URL Session, Create Phone Session, Create Anonymous Session, etc). --- app/controllers/api/account.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 4eb91ad732a..8ebbeff846c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -565,7 +565,6 @@ try { $userId = ID::unique(); - $password = Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); $user->setAttributes([ '$id' => $userId, '$permissions' => [ @@ -576,8 +575,7 @@ 'email' => $email, 'emailVerification' => true, 'status' => true, // Email should already be authenticated by OAuth2 provider - 'passwordHistory' => $passwordHistory > 0 ? [$password] : null, - 'password' => $password, + 'password' => null, 'hash' => Auth::DEFAULT_ALGO, 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'passwordUpdate' => null, From e9c2786e20b69f31cd3b07b0832508681463fb42 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:22:30 -0700 Subject: [PATCH 4/7] Fix typing issues in OAuth2 --- src/Appwrite/Auth/OAuth2.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index d7c1c99546e..28de8dd9142 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -73,6 +73,13 @@ abstract protected function getTokens(string $code): array; */ abstract public function refreshTokens(string $refreshToken): array; + /** + * @param string $accessToken + * + * @return string + */ + abstract public function getUserID(string $accessToken): string; + /** * @param string $accessToken * @@ -148,11 +155,11 @@ public function getRefreshToken(string $code): string * * @return string */ - public function getAccessTokenExpiry(string $code): string + public function getAccessTokenExpiry(string $code): int { $tokens = $this->getTokens($code); - return $tokens['expires_in'] ?? ''; + return $tokens['expires_in'] ?? 0; } // The parseState function was designed specifically for Amazon OAuth2 Adapter to override. From 2bced2e701853bb45c7aaaf00ca3dacc4bae0ded Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 17:23:17 -0700 Subject: [PATCH 5/7] Create OAuth2 exception class --- app/config/errors.php | 10 +++++ src/Appwrite/Auth/OAuth2/Exception.php | 51 ++++++++++++++++++++++++++ src/Appwrite/Extend/Exception.php | 2 + 3 files changed, 63 insertions(+) create mode 100644 src/Appwrite/Auth/OAuth2/Exception.php diff --git a/app/config/errors.php b/app/config/errors.php index 3ac2e307d97..c8835429285 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -200,6 +200,16 @@ 'description' => 'Missing ID from OAuth2 provider.', 'code' => 400, ], + Exception::USER_OAUTH2_BAD_REQUEST => [ + 'name' => Exception::USER_OAUTH2_BAD_REQUEST, + 'description' => 'OAuth2 provider rejected the bad request.', + 'code' => 400, + ], + Exception::USER_OAUTH2_UNAUTHORIZED => [ + 'name' => Exception::USER_OAUTH2_UNAUTHORIZED, + 'description' => 'OAuth2 provider rejected the unauthorized request.', + 'code' => 401, + ], /** Teams */ Exception::TEAM_NOT_FOUND => [ diff --git a/src/Appwrite/Auth/OAuth2/Exception.php b/src/Appwrite/Auth/OAuth2/Exception.php new file mode 100644 index 00000000000..244fc255ed7 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Exception.php @@ -0,0 +1,51 @@ +response = $response; + $this->message = $response; + $decoded = json_decode($response, true); + if (\is_array($decoded)) { + $this->error = $decoded['error']; + $this->errorDescription = $decoded['error_description']; + $this->message = $this->error . ': ' . $this->errorDescription; + } + $type = match ($code) { + 400 => AppwriteException::USER_OAUTH2_BAD_REQUEST, + 401 => AppwriteException::USER_OAUTH2_UNAUTHORIZED, + default => AppwriteException::GENERAL_SERVER_ERROR + }; + + parent::__construct($type, $this->message, $code, $previous); + } + + /** + * Get the error parameter from the response. + * + * See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 for more information. + */ + public function getError(): string + { + return $this->error; + } + + /** + * Get the error_description parameter from the response. + * + * See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 for more information. + */ + public function getErrorDescription(): string + { + return $this->errorDescription; + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 573c01fbfc1..1c441069227 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -76,6 +76,8 @@ class Exception extends \Exception public const USER_PHONE_ALREADY_EXISTS = 'user_phone_already_exists'; public const USER_PHONE_NOT_FOUND = 'user_phone_not_found'; public const USER_MISSING_ID = 'user_missing_id'; + public const USER_OAUTH2_BAD_REQUEST = 'user_oauth2_bad_request'; + public const USER_OAUTH2_UNAUTHORIZED = 'user_oauth2_unauthorized'; /** Teams */ public const TEAM_NOT_FOUND = 'team_not_found'; From c3636b3e2a70c059e71de23a4da7f4f050a678b2 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 17:28:45 -0700 Subject: [PATCH 6/7] Improve OAuth2 Error Handling Update the OAuth2 class to throw an exception if an API call to the OAuth2 provider fails and update the endpoint to redirect to the failure url with the information from the OAuth2 provider. --- app/controllers/api/account.php | 75 +++++++++++++++++++++------------ src/Appwrite/Auth/OAuth2.php | 10 ++++- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8ebbeff846c..6378273ef35 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2,6 +2,7 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; +use Appwrite\Auth\OAuth2\Exception as OAuth2Exception; use Appwrite\Auth\Validator\Password; use Appwrite\Auth\Validator\Phone; use Appwrite\Detector\Detector; @@ -422,21 +423,13 @@ $appSecret = $project->getAttribute('authProviders', [])[$provider . 'Secret'] ?? '{}'; $providerEnabled = $project->getAttribute('authProviders', [])[$provider . 'Enabled'] ?? false; - if (!$providerEnabled) { - throw new Exception(Exception::PROJECT_PROVIDER_DISABLED, 'This provider is disabled. Please enable the provider from your ' . APP_NAME . ' console to continue.'); - } - - if (!empty($appSecret) && isset($appSecret['version'])) { - $key = App::getEnv('_APP_OPENSSL_KEY_V' . $appSecret['version']); - $appSecret = OpenSSL::decrypt($appSecret['data'], $appSecret['method'], $key, 0, \hex2bin($appSecret['iv']), \hex2bin($appSecret['tag'])); - } - $className = 'Appwrite\\Auth\\OAuth2\\' . \ucfirst($provider); if (!\class_exists($className)) { throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } + /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); if (!empty($state)) { @@ -456,28 +449,54 @@ if (!empty($state['failure']) && !$validateURL->isValid($state['failure'])) { throw new Exception(Exception::PROJECT_INVALID_FAILURE_URL); } + $failure = []; + if (!empty($state['failure'])) { + $failure = URLParser::parse($state['failure']); + } + $failureRedirect = (function (string $type, ?string $message = null, ?int $code = null) use ($failure, $response) { + $exception = new Exception($type, $message, $code); + if (!empty($failure)) { + $query = URLParser::parseQuery($failure['query']); + $query['error'] = json_encode([ + 'message' => $exception->getMessage(), + 'type' => $exception->getType(), + 'code' => !\is_null($code) ? $code : $exception->getCode(), + ]); + $failure['query'] = URLParser::unparseQuery($query); + $response->redirect(URLParser::unparse($failure), 301); + } - $accessToken = $oauth2->getAccessToken($code); - $refreshToken = $oauth2->getRefreshToken($code); - $accessTokenExpiry = $oauth2->getAccessTokenExpiry($code); + throw $exception; + }); - if (empty($accessToken)) { - if (!empty($state['failure'])) { - $response->redirect($state['failure'], 301, 0); - } + if (!$providerEnabled) { + $failureRedirect(Exception::PROJECT_PROVIDER_DISABLED, 'This provider is disabled. Please enable the provider from your ' . APP_NAME . ' console to continue.'); + } - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to obtain access token'); + if (!empty($appSecret) && isset($appSecret['version'])) { + $key = App::getEnv('_APP_OPENSSL_KEY_V' . $appSecret['version']); + $appSecret = OpenSSL::decrypt($appSecret['data'], $appSecret['method'], $key, 0, \hex2bin($appSecret['iv']), \hex2bin($appSecret['tag'])); } - $oauth2ID = $oauth2->getUserID($accessToken); - + $accessToken = ''; + $refreshToken = ''; + $accessTokenExpiry = 0; - if (empty($oauth2ID)) { - if (!empty($state['failure'])) { - $response->redirect($state['failure'], 301, 0); - } + try { + $accessToken = $oauth2->getAccessToken($code); + $refreshToken = $oauth2->getRefreshToken($code); + $accessTokenExpiry = $oauth2->getAccessTokenExpiry($code); + } catch (OAuth2Exception $ex) { + $failureRedirect( + $ex->getType(), + 'Failed to obtain access token: ' . $ex->getMessage(), + $ex->getCode(), + ); + } - throw new Exception(Exception::USER_MISSING_ID); + $oauth2ID = $oauth2->getUserID($accessToken); + if (empty($oauth2ID)) { + $failureRedirect(Exception::USER_MISSING_ID); } $name = $oauth2->getUserName($accessToken); @@ -492,7 +511,7 @@ Query::notEqual('userId', $userId), ]); if (!empty($identitiesWithMatchingEmail)) { - throw new Exception(Exception::USER_ALREADY_EXISTS); + $failureRedirect(Exception::USER_ALREADY_EXISTS); } } @@ -551,7 +570,7 @@ $total = $dbForProject->count('users', max: APP_LIMIT_USERS); if ($total >= $limit) { - throw new Exception(Exception::USER_COUNT_EXCEEDED); + $failureRedirect(Exception::USER_COUNT_EXCEEDED); } } @@ -590,7 +609,7 @@ ]); Authorization::skip(fn() => $dbForProject->createDocument('users', $user)); } catch (Duplicate $th) { - throw new Exception(Exception::USER_ALREADY_EXISTS); + $failureRedirect(Exception::USER_ALREADY_EXISTS); } } } @@ -599,7 +618,7 @@ Authorization::setRole(Role::users()->toString()); if (false === $user->getAttribute('status')) { // Account is blocked - throw new Exception(Exception::USER_BLOCKED); // User is in status blocked + $failureRedirect(Exception::USER_BLOCKED); // User is in status blocked } $identity = $dbForProject->findOne('identities', [ diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 28de8dd9142..c737e183f81 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -2,6 +2,8 @@ namespace Appwrite\Auth; +use Appwrite\Auth\OAuth2\Exception; + abstract class OAuth2 { /** @@ -75,7 +77,7 @@ abstract public function refreshTokens(string $refreshToken): array; /** * @param string $accessToken - * + * * @return string */ abstract public function getUserID(string $accessToken): string; @@ -202,8 +204,14 @@ protected function request(string $method, string $url = '', array $headers = [] // Send the request & save response to $response $response = \curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + \curl_close($ch); + if ($code != 200) { + throw new Exception($response, $code); + } + return (string)$response; } } From 2e07ac15267e213784d04097228163d722c7a5e2 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 24 Jul 2023 18:38:54 -0700 Subject: [PATCH 7/7] Remove the OAuth2 Callback endpoints from api group Prevent missing project ID error. --- app/controllers/api/account.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6378273ef35..fc1afe81492 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -342,7 +342,7 @@ App::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->desc('OAuth2 Callback') - ->groups(['api', 'account']) + ->groups(['account']) ->label('error', __DIR__ . '/../../views/general/error.phtml') ->label('scope', 'public') ->label('docs', false) @@ -366,7 +366,7 @@ App::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->desc('OAuth2 Callback') - ->groups(['api', 'account']) + ->groups(['account']) ->label('error', __DIR__ . '/../../views/general/error.phtml') ->label('scope', 'public') ->label('origin', '*')