diff --git a/.env b/.env index 51e6cc3705..ea666a2acc 100644 --- a/.env +++ b/.env @@ -3,6 +3,7 @@ _APP_LOCALE=en _APP_WORKER_PER_CORE=2 _APP_CONSOLE_WHITELIST_ROOT=disabled _APP_CONSOLE_WHITELIST_EMAILS= +_APP_CONSOLE_WHITELIST_CODES=code-zero,code-one _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_INVITES=enabled _APP_SYSTEM_EMAIL_NAME=Appwrite @@ -72,4 +73,4 @@ _APP_LOGGING_PROVIDER= _APP_LOGGING_CONFIG= _APP_REGION=default _APP_DOCKER_HUB_USERNAME= -_APP_DOCKER_HUB_PASSWORD= +_APP_DOCKER_HUB_PASSWORD= \ No newline at end of file diff --git a/app/config/errors.php b/app/config/errors.php index c5c6a489b8..a071b0cb7e 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -125,6 +125,11 @@ return [ 'description' => 'Console registration is restricted to specific emails. Contact your administrator for more information.', 'code' => 401, ], + Exception::USER_CODE_INVALID => [ + 'name' => Exception::USER_CODE_INVALID, + 'description' => 'The specified code is not valid. Contact your administrator for more information.', + 'code' => 401, + ], Exception::USER_IP_NOT_WHITELISTED => [ 'name' => Exception::USER_IP_NOT_WHITELISTED, 'description' => 'Console registration is restricted to specific IPs. Contact your administrator for more information.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 346820d699..e04b86e57e 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -44,6 +44,98 @@ use Utopia\Validator\WhiteList; $oauthDefaultSuccess = '/auth/oauth2/success'; $oauthDefaultFailure = '/auth/oauth2/failure'; +App::post('/v1/account/invite') + ->desc('Create account using an invite code') + ->groups(['api', 'account', 'auth']) + ->label('event', 'users.[userId].create') + ->label('scope', 'public') + ->label('auth.type', 'emailPassword') + ->label('audits.event', 'user.create') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createWithInviteCode') + ->label('sdk.description', '/docs/references/account/create.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_ACCOUNT) + ->label('abuse-limit', 10) + ->param('userId', '', new CustomId(), 'Unique Id. Choose your own unique ID or pass the string `ID.unique()` to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('email', '', new Email(), 'User email.') + ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') + ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) + ->param('code', '', new Text(128), 'An invite code to restrict user signups on the Appwrite console. Users with an invite code will be able to create accounts irrespective of email and IP whitelists.', true) + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('dbForProject') + ->inject('events') + ->action(function (string $userId, string $email, string $password, string $name, string $code, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) { + + if ($project->getId() !== 'console') { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN); + } + + $email = \strtolower($email); + + $whitelistCodes = (!empty(App::getEnv('_APP_CONSOLE_WHITELIST_CODES', null))) ? \explode(',', App::getEnv('_APP_CONSOLE_WHITELIST_CODES', null)) : []; + + if (!empty($whitelistCodes) && !\in_array($code, $whitelistCodes)) { + throw new Exception(Exception::USER_CODE_INVALID); + } + + $limit = $project->getAttribute('auths', [])['limit'] ?? 0; + + if ($limit !== 0) { + $total = $dbForProject->count('users', max: APP_LIMIT_USERS); + + if ($total >= $limit) { + throw new Exception(Exception::USER_COUNT_EXCEEDED); + } + } + + try { + $userId = $userId == 'unique()' ? ID::unique() : $userId; + $user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ + '$id' => $userId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + 'email' => $email, + 'emailVerification' => false, + 'status' => true, + 'password' => Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'passwordUpdate' => DateTime::now(), + 'registration' => DateTime::now(), + 'reset' => false, + 'name' => $name, + 'prefs' => new \stdClass(), + 'sessions' => null, + 'tokens' => null, + 'memberships' => null, + 'search' => implode(' ', [$userId, $email, $name]) + ]))); + } catch (Duplicate $th) { + throw new Exception(Exception::USER_ALREADY_EXISTS); + } + + Authorization::unsetRole(Role::guests()->toString()); + Authorization::setRole(Role::user($user->getId())->toString()); + Authorization::setRole(Role::users()->toString()); + + $events->setParam('userId', $user->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($user, Response::MODEL_ACCOUNT); + }); + App::post('/v1/account') ->desc('Create Account') ->groups(['api', 'account', 'auth']) diff --git a/docker-compose.yml b/docker-compose.yml index afaaef2f6b..882c890425 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,7 @@ services: - _APP_LOCALE - _APP_CONSOLE_WHITELIST_ROOT - _APP_CONSOLE_WHITELIST_EMAILS + - _APP_CONSOLE_WHITELIST_CODES - _APP_CONSOLE_WHITELIST_IPS - _APP_CONSOLE_INVITES - _APP_SYSTEM_EMAIL_NAME diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d25cfb0d40..9f035863eb 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -60,6 +60,7 @@ class Exception extends \Exception public const USER_PASSWORD_RESET_REQUIRED = 'user_password_reset_required'; public const USER_EMAIL_NOT_WHITELISTED = 'user_email_not_whitelisted'; public const USER_IP_NOT_WHITELISTED = 'user_ip_not_whitelisted'; + public const USER_CODE_INVALID = 'user_code_invalid'; public const USER_INVALID_CREDENTIALS = 'user_invalid_credentials'; public const USER_ANONYMOUS_CONSOLE_PROHIBITED = 'user_anonymous_console_prohibited'; public const USER_SESSION_ALREADY_EXISTS = 'user_session_already_exists'; diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 4aa2462f89..4258004ecf 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -2,13 +2,98 @@ namespace Tests\E2E\Services\Account; +use Appwrite\Extend\Exception; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\SideClient; +use Utopia\Database\ID; +use Utopia\Database\DateTime; +use Tests\E2E\Client; class AccountConsoleClientTest extends Scope { use AccountBase; use ProjectConsole; use SideClient; + + public function testCreateAccountWithInvite(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_POST, '/account/invite', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + 'code' => 'Invalid Code' + ]); + + $this->assertEquals($response['headers']['status-code'], 401); + $this->assertEquals($response['body']['type'], Exception::USER_CODE_INVALID); + + $response = $this->client->call(Client::METHOD_POST, '/account/invite', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals($response['headers']['status-code'], 401); + $this->assertEquals($response['body']['type'], Exception::USER_CODE_INVALID); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/account/invite', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + 'code' => 'code-zero' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(true, DateTime::isValid($response['body']['registration'])); + $this->assertEquals($response['body']['email'], $email); + $this->assertEquals($response['body']['name'], $name); + + $email = uniqid() . 'user@localhost.test'; + $response = $this->client->call(Client::METHOD_POST, '/account/invite', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + 'code' => 'code-one' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(true, DateTime::isValid($response['body']['registration'])); + $this->assertEquals($response['body']['email'], $email); + $this->assertEquals($response['body']['name'], $name); + } } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 65567b22e5..ea24d06bdb 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Account; +use Appwrite\Extend\Exception; use Appwrite\SMS\Adapter\Mock; use Tests\E2E\Client; use Tests\E2E\Scopes\Scope; @@ -18,6 +19,32 @@ class AccountCustomClientTest extends Scope use ProjectCustom; use SideClient; + public function testCreateAccountWithInvite(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Test for FAILURE + * Make sure the invite endpoint is only accessible through the console project. + */ + $response = $this->client->call(Client::METHOD_POST, '/account/invite', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + 'code' => 'Invalid Code' + ]); + + $this->assertEquals($response['headers']['status-code'], 401); + $this->assertEquals($response['body']['type'], Exception::GENERAL_ACCESS_FORBIDDEN); + } + /** * @depends testCreateAccountSession */