From 27e212553d99172d60a5a9fa4f31af116532eac1 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 17 May 2023 18:11:45 -0700 Subject: [PATCH 1/5] 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 | 172 ++++++++++++++++ app/config/errors.php | 5 + app/controllers/api/account.php | 193 +++++++++++++++++- app/controllers/api/teams.php | 8 + app/controllers/api/users.php | 97 +++++++++ docs/references/account/delete-identity.md | 1 + docs/references/account/list-identities.md | 1 + docs/references/users/delete-identity.md | 1 + docs/references/users/list-identities.md | 1 + src/Appwrite/Extend/Exception.php | 1 + .../Database/Validator/Queries/Identities.php | 23 +++ src/Appwrite/Utopia/Response.php | 5 + .../Utopia/Response/Model/Identity.php | 101 +++++++++ 13 files changed, 605 insertions(+), 4 deletions(-) create mode 100644 docs/references/account/delete-identity.md create mode 100644 docs/references/account/list-identities.md create mode 100644 docs/references/users/delete-identity.md create mode 100644 docs/references/users/list-identities.md 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 958890123..e0dc79114 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -2007,6 +2007,178 @@ $collections = [ ], ], + '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], + ], + [ + '$id' => ID::custom('_key_providerAccessTokenExpiry'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['providerAccessTokenExpiry'], + 'lengths' => [], + '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 44f4899d2..429feb19f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -175,6 +175,11 @@ return [ '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 7c46e634c..439964602 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -16,6 +16,7 @@ use Appwrite\OpenSSL\OpenSSL; use Appwrite\Template\Template; use Appwrite\URL\URL as URLParser; use Appwrite\Utopia\Database\Validator\CustomId; +use Appwrite\Utopia\Database\Validator\Queries\Identities; use Appwrite\Utopia\Database\Validator\Queries; use Appwrite\Utopia\Database\Validator\Query\Limit; use Appwrite\Utopia\Database\Validator\Query\Offset; @@ -193,6 +194,14 @@ App::post('/v1/account') } } + // 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 = $userId == 'unique()' ? ID::unique() : $userId; $user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ @@ -545,6 +554,22 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 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); @@ -563,9 +588,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ]) : $user; 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); - if (empty($email)) { throw new Exception(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.'); } @@ -579,7 +601,19 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') Query::equal('email', [$email]), ]); - 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) { @@ -590,6 +624,14 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } } + // 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(); $user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ @@ -621,10 +663,56 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } } + 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)) { + throw new Exception(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')); @@ -705,6 +793,86 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ; }); +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', 'listIdentities') + ->label('sdk.description', '/docs/references/account/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) + ->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']) @@ -755,6 +923,14 @@ App::post('/v1/account/sessions/magic-url') } } + // 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 = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ @@ -1661,6 +1837,15 @@ App::patch('/v1/account/email') $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('password', $isAnonymousUser ? Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS) : $user->getAttribute('password', '')) ->setAttribute('hash', $isAnonymousUser ? Auth::DEFAULT_ALGO : $user->getAttribute('hash', '')) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 572b6f02a..20b9825e8 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -342,6 +342,14 @@ App::post('/v1/teams/:teamId/memberships') } } + // 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 dce493b02..3d45e1b8e 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -9,6 +9,7 @@ use Appwrite\Event\Event; use Appwrite\Network\Validator\Email; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries; +use Appwrite\Utopia\Database\Validator\Queries\Identities; use Appwrite\Utopia\Database\Validator\Queries\Users; use Appwrite\Utopia\Database\Validator\Query\Limit; use Appwrite\Utopia\Database\Validator\Query\Offset; @@ -42,6 +43,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 { @@ -612,6 +621,53 @@ App::get('/v1/users/:userId/logs') ]), 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']) @@ -835,6 +891,15 @@ App::patch('/v1/users/:userId/email') $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) @@ -1095,6 +1160,38 @@ App::delete('/v1/users/:userId') $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-identity.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']) diff --git a/docs/references/account/delete-identity.md b/docs/references/account/delete-identity.md new file mode 100644 index 000000000..ef480e06a --- /dev/null +++ b/docs/references/account/delete-identity.md @@ -0,0 +1 @@ +Delete an identity by its unique ID. \ No newline at end of file diff --git a/docs/references/account/list-identities.md b/docs/references/account/list-identities.md new file mode 100644 index 000000000..fdb8c22b9 --- /dev/null +++ b/docs/references/account/list-identities.md @@ -0,0 +1 @@ +Get currently logged in user list of identities. \ No newline at end of file diff --git a/docs/references/users/delete-identity.md b/docs/references/users/delete-identity.md new file mode 100644 index 000000000..ef480e06a --- /dev/null +++ b/docs/references/users/delete-identity.md @@ -0,0 +1 @@ +Delete an identity by its unique ID. \ No newline at end of file diff --git a/docs/references/users/list-identities.md b/docs/references/users/list-identities.md new file mode 100644 index 000000000..e8a66e5e4 --- /dev/null +++ b/docs/references/users/list-identities.md @@ -0,0 +1 @@ +Get identities for all users. \ No newline at end of file diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d0ba05c4c..6833bbc3f 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -69,6 +69,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 000000000..6d51740f9 --- /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)) @@ -325,6 +329,7 @@ class Response extends SwooleResponse ->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 000000000..858f1da85 --- /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 9ac4c998ae746e486b32f08e75a929161b24f07a Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:12:48 -0700 Subject: [PATCH 2/5] Ensure a user's identities are deleted when user is deleted --- app/workers/deletes.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/workers/deletes.php b/app/workers/deletes.php index 7380b5724..d52981448 100644 --- a/app/workers/deletes.php +++ b/app/workers/deletes.php @@ -345,6 +345,11 @@ class DeletesV1 extends Worker $this->deleteByGroup('tokens', [ Query::equal('userInternalId', [$userInternalId]) ], $dbForProject); + + // Delete identities + $this->deleteByGroup('identities', [ + Query::equal('userInternalId', [$userInternalId]) + ], $dbForProject); } /** From bcd44432d1bb642d91b1f60f504b329aefa9d1b9 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:17:05 -0700 Subject: [PATCH 3/5] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 439964602..8c34da5b4 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -644,7 +644,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'email' => $email, 'emailVerification' => true, 'status' => true, // Email should already be authenticated by OAuth2 provider - 'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), + 'password' => null, 'hash' => Auth::DEFAULT_ALGO, 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'passwordUpdate' => null, From cb7abdb90622b629aa57107e66410c8ed49af89a Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 2 Aug 2023 15:22:52 -0700 Subject: [PATCH 4/5] Remove identity status Until we have a clearer picture of why we need it, it would be best to remove it since it's easier to add it later than to remove it after it's released. --- app/config/collections.php | 18 ------------------ app/controllers/api/account.php | 2 -- .../Database/Validator/Queries/Identities.php | 1 - .../Utopia/Response/Model/Identity.php | 6 ------ 4 files changed, 27 deletions(-) diff --git a/app/config/collections.php b/app/config/collections.php index e0dc79114..1ace0cb20 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -2045,17 +2045,6 @@ $collections = [ '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, @@ -2148,13 +2137,6 @@ $collections = [ '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, diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8c34da5b4..fce34959a 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -697,7 +697,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'userInternalId' => $user->getInternalId(), 'userId' => $userId, 'provider' => $provider, - 'status' => 'connected', 'providerUid' => $oauth2ID, 'providerEmail' => $email, 'providerAccessToken' => $accessToken, @@ -706,7 +705,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ])); } else { $identity - ->setAttribute('status', 'connected') ->setAttribute('providerAccessToken', $accessToken) ->setAttribute('providerRefreshToken', $refreshToken) ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry)); diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php b/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php index 6d51740f9..2099d9e51 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php @@ -7,7 +7,6 @@ class Identities extends Base public const ALLOWED_ATTRIBUTES = [ 'userId', 'provider', - 'status', 'providerUid', 'providerEmail', ]; diff --git a/src/Appwrite/Utopia/Response/Model/Identity.php b/src/Appwrite/Utopia/Response/Model/Identity.php index 858f1da85..ff7f57a3e 100644 --- a/src/Appwrite/Utopia/Response/Model/Identity.php +++ b/src/Appwrite/Utopia/Response/Model/Identity.php @@ -40,12 +40,6 @@ class Identity extends Model '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.', From 57b031d7f57e0b6626afe9243316a2a70055140e Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Thu, 3 Aug 2023 11:06:25 -0700 Subject: [PATCH 5/5] Publicly allow filtering on identities.providerAccessTokenExpiry This will allow developers to set up a job to find expired access tokens so they can refresh them. --- src/Appwrite/Utopia/Database/Validator/Queries/Identities.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php b/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php index 2099d9e51..cb0462ee1 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Identities.php @@ -9,6 +9,7 @@ class Identities extends Base 'provider', 'providerUid', 'providerEmail', + 'providerAccessTokenExpiry', ]; /**