diff --git a/.env b/.env index 51e6cc370..ea666a2ac 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 c5c6a489b..a071b0cb7 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 346820d69..e04b86e57 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 a7c1ddf97..d09e5649a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,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 d25cfb0d4..9f035863e 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 4aa2462f8..4258004ec 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 65567b22e..ea24d06bd 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 */