From a23502426815c9cc79691253db2a60068f012d11 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 17 May 2023 18:11:45 -0700 Subject: [PATCH 1/8] 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 | 192 +++++++++++++++++- 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, 604 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 6a608fe5f5..bd1b634d4b 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1921,6 +1921,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 0c5067f1f6..3ac2e307d9 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -170,6 +170,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 a96f537f76..2ac517cf24 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 Utopia\Database\Validator\Queries; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; @@ -99,6 +100,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); + } + $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); try { @@ -470,6 +479,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); @@ -493,8 +518,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } 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 +531,19 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $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 +554,13 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } } - $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()) { + throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); + } try { $userId = ID::unique(); @@ -555,10 +596,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')); @@ -638,6 +725,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']) @@ -690,6 +857,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->setAttributes([ @@ -1686,6 +1861,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('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 4687cc0f98..8f94e78406 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -454,6 +454,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 683858aed4..9300d0e9f3 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 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 @@ 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']) @@ -904,6 +960,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) @@ -1165,6 +1230,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', 'usage']) diff --git a/docs/references/account/delete-identity.md b/docs/references/account/delete-identity.md new file mode 100644 index 0000000000..ef480e06a4 --- /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 0000000000..fdb8c22b9d --- /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 0000000000..ef480e06a4 --- /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 0000000000..e8a66e5e40 --- /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 d97032bd81..573c01fbfc 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 0000000000..6d51740f92 --- /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 @@ 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 0000000000..858f1da856 --- /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 43d5c96f7dc43c84efd8f26722facab8a6d28a33 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:12:48 -0700 Subject: [PATCH 2/8] 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 f27bc4feb9..351c70cc2c 100644 --- a/app/workers/deletes.php +++ b/app/workers/deletes.php @@ -366,6 +366,7 @@ class DeletesV1 extends Worker 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 @@ class DeletesV1 extends Worker $this->deleteByGroup('tokens', [ Query::equal('userId', [$userId]) ], $this->getProjectDB($projectId)); + + // Delete identities + $this->deleteByGroup('identities', [ + Query::equal('userInternalId', [$userInternalId]) + ], $this->getProjectDB($projectId)); } /** From b9c2b9322fc5b1b312b93b345a835a54ff76f6d6 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Fri, 14 Jul 2023 16:17:05 -0700 Subject: [PATCH 3/8] 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 2ac517cf24..0f9db07e9f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -564,7 +564,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') try { $userId = ID::unique(); - $password = Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); $user->setAttributes([ '$id' => $userId, '$permissions' => [ @@ -575,8 +574,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') '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 b8e22151f64e6f623815ab4df0cca7f06a8503e2 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 2 Aug 2023 15:22:52 -0700 Subject: [PATCH 4/8] 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 bd1b634d4b..a484f379cf 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1959,17 +1959,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, @@ -2062,13 +2051,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 0f9db07e9f..c1874df7e1 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -628,7 +628,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'userInternalId' => $user->getInternalId(), 'userId' => $userId, 'provider' => $provider, - 'status' => 'connected', 'providerUid' => $oauth2ID, 'providerEmail' => $email, 'providerAccessToken' => $accessToken, @@ -637,7 +636,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 6d51740f92..2099d9e51f 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 858f1da856..ff7f57a3e6 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 a32d9abd988856e57902d797bf03ef50d69bc084 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Thu, 3 Aug 2023 11:06:25 -0700 Subject: [PATCH 5/8] 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 2099d9e51f..cb0462ee13 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', ]; /** From c7c546bfe72650b12a80caed5721b394eb4ee573 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 9 Aug 2023 08:11:15 -0700 Subject: [PATCH 6/8] Add a secrets attribute to the identities collection These secrets can be used to store data from the provider that may or may not be sensitive. For example, this will be used by the migration API when connecting to Firebase to store the service account used for the migration. This data will only be used internally inside Appwrite and not exposed to an end user or developer. --- app/config/collections.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/config/collections.php b/app/config/collections.php index 3fb404c053..292658b17d 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -782,6 +782,18 @@ $commonCollections = [ 'array' => false, 'filters' => ['encrypt'], ], + [ + // Used to store data from provider that may or may not be sensitive + '$id' => ID::custom('secrets'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json', 'encrypt'], + ], ], 'indexes' => [ [ From 09254aeae0a88568082cdd80c4a5abe7350908ee Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 9 Aug 2023 09:25:29 -0700 Subject: [PATCH 7/8] Add description for the delete account identity's identityId param --- 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 1d53ba8b54..e6d7a087e5 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -940,7 +940,7 @@ App::delete('/v1/account/identities/:identityId') ->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(), '') + ->param('identityId', [], new UID(), 'Identity ID.') ->inject('response') ->inject('dbForProject') ->action(function (string $identityId, Response $response, Database $dbForProject) { From 4a9ac08e752e8934d2c038cfc1b36f8fca806828 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Wed, 9 Aug 2023 10:02:01 -0700 Subject: [PATCH 8/8] Update description for some of the account endpoints for clarity --- docs/references/account/get-prefs.md | 2 +- docs/references/account/get.md | 2 +- docs/references/account/list-identities.md | 2 +- docs/references/account/list-logs.md | 2 +- docs/references/account/list-sessions.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/references/account/get-prefs.md b/docs/references/account/get-prefs.md index 8edfbb6883..867684dad7 100644 --- a/docs/references/account/get-prefs.md +++ b/docs/references/account/get-prefs.md @@ -1 +1 @@ -Get currently logged in user preferences as a key-value object. \ No newline at end of file +Get the preferences as a key-value object for the currently logged in user. \ No newline at end of file diff --git a/docs/references/account/get.md b/docs/references/account/get.md index f62b1f52bf..4dcae53117 100644 --- a/docs/references/account/get.md +++ b/docs/references/account/get.md @@ -1 +1 @@ -Get currently logged in user data as JSON object. \ No newline at end of file +Get the currently logged in user. \ No newline at end of file diff --git a/docs/references/account/list-identities.md b/docs/references/account/list-identities.md index fdb8c22b9d..4378bfb67a 100644 --- a/docs/references/account/list-identities.md +++ b/docs/references/account/list-identities.md @@ -1 +1 @@ -Get currently logged in user list of identities. \ No newline at end of file +Get the list of identities for the currently logged in user. \ No newline at end of file diff --git a/docs/references/account/list-logs.md b/docs/references/account/list-logs.md index 5cd2a4b87e..1f3a8d67a4 100644 --- a/docs/references/account/list-logs.md +++ b/docs/references/account/list-logs.md @@ -1 +1 @@ -Get currently logged in user list of latest security activity logs. Each log returns user IP address, location and date and time of log. \ No newline at end of file +Get the list of latest security activity logs for the currently logged in user. Each log returns user IP address, location and date and time of log. \ No newline at end of file diff --git a/docs/references/account/list-sessions.md b/docs/references/account/list-sessions.md index e944fd3675..f4eadf285c 100644 --- a/docs/references/account/list-sessions.md +++ b/docs/references/account/list-sessions.md @@ -1 +1 @@ -Get currently logged in user list of active sessions across different devices. \ No newline at end of file +Get the list of active sessions across different devices for the currently logged in user. \ No newline at end of file