diff --git a/.env b/.env index f650d0bc8..9f6050fe5 100644 --- a/.env +++ b/.env @@ -1,6 +1,9 @@ _APP_ENV=production _APP_ENV=development _APP_LOCALE=en +_APP_CONSOLE_WHITELIST_ROOT=disabled +_APP_CONSOLE_WHITELIST_EMAILS= +_APP_CONSOLE_WHITELIST_IPS= _APP_SYSTEM_EMAIL_NAME=Appwrite _APP_SYSTEM_EMAIL_ADDRESS=team@appwrite.io _APP_SYSTEM_SECURITY_EMAIL_ADDRESS=security@appwrite.io diff --git a/CHANGES.md b/CHANGES.md index 89f460919..f0050dde5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,24 +1,24 @@ # Version 0.8.0 (Not Released Yet) ## Features - +- Refactoring SSL generation to work on every request so no domain environment variable is required for SSL generation (#1133) - Added Anonymous Login ([RFC-010](https://github.com/appwrite/rfc/blob/main/010-anonymous-login.md), #914) - Added events for functions and executions (#971) - Added JWT support (#784) - Added ARM support (#726) -- Splitted token & session models to become 2 different internal entities (#922) +- Split token & session models to become 2 different internal entities (#922) - Added Dart 2.12 as a new Cloud Functions runtime (#989) - Added option to disable email/password (#947) - Added option to disable anonymous login (need to merge and apply changed) (#947) - Added option to disable JWT auth (#947) - Added option to disable team invites (#947) -- Option to limit number of users (good for app launches + god account PR) (#947) +- Option to limit number of users (good for app launches + root account PR) (#947) - Added 2 new endpoints to the projects API to allow new settings - Enabled 501 errors (Not Implemented) from the error handler - Added Python 3.9 as a new Cloud Functions runtime (#1044) - Added Deno 1.8 as a new Cloud Functions runtime (#989) - Upgraded to PHP 8.0 (#713) -- ClamAV is now disabled by default to allow lower min requirments for Appwrite (#1064) +- ClamAV is now disabled by default to allow lower min requirements for Appwrite (#1064) - Added a new env var named `_APP_LOCALE` that allow to change the default `en` locale value (#1056) - Updated all the console bottom control to be consistent. Dropped the `+` icon (#1062) - Added Response Models for Documents and Preferences (#1075, #1102) @@ -28,6 +28,9 @@ - Fixed default value for HTTPS force option - Fixed form array casting in dashboard (#1070) - Fixed collection document rule form in dashboard (#1069) +- Bugs in the Teams API: + - Fixed incorrect audit worker event names (#1143) + - Increased limit of memberships fetched in `createTeamMembership` to 2000 (#1143) ## Breaking Changes (Read before upgrading!) diff --git a/Dockerfile b/Dockerfile index 83f5c07b3..8c59bb726 100755 --- a/Dockerfile +++ b/Dockerfile @@ -88,6 +88,13 @@ ENV _APP_SERVER=swoole \ _APP_DOMAIN_TARGET=localhost \ _APP_HOME=https://appwrite.io \ _APP_EDITION=community \ + _APP_CONSOLE_WHITELIST_ROOT=enabled \ + _APP_CONSOLE_WHITELIST_EMAILS= \ + _APP_CONSOLE_WHITELIST_IPS= \ + _APP_SYSTEM_EMAIL_NAME= \ + _APP_SYSTEM_EMAIL_ADDRESS= \ + _APP_SYSTEM_RESPONSE_FORMAT= \ + _APP_SYSTEM_SECURITY_EMAIL_ADDRESS= \ _APP_OPTIONS_ABUSE=enabled \ _APP_OPTIONS_FORCE_HTTPS=disabled \ _APP_OPENSSL_KEY_V1=your-secret-key \ diff --git a/app/config/collections.php b/app/config/collections.php index 09b27490c..1cff0031a 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -46,7 +46,7 @@ $collections = [ 'legalTaxId' => '', 'authWhitelistEmails' => (!empty(App::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', App::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [], 'authWhitelistIPs' => (!empty(App::getEnv('_APP_CONSOLE_WHITELIST_IPS', null))) ? \explode(',', App::getEnv('_APP_CONSOLE_WHITELIST_IPS', null)) : [], - 'authWhitelistDomains' => (!empty(App::getEnv('_APP_CONSOLE_WHITELIST_DOMAINS', null))) ? \explode(',', App::getEnv('_APP_CONSOLE_WHITELIST_DOMAINS', null)) : [], + 'usersAuthLimit' => (App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user ], Database::SYSTEM_COLLECTION_COLLECTIONS => [ '$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS, diff --git a/app/config/variables.php b/app/config/variables.php index a5f598d80..57a913127 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -63,9 +63,17 @@ return [ 'required' => true, 'question' => 'Enter a DNS A record hostname to serve as a CNAME for your custom domains.\nYou can use the same value as used for the Appwrite hostname.', ], + [ + 'name' => '_APP_CONSOLE_WHITELIST_ROOT', + 'description' => 'This option allows you to disable the creation of new users on the Appwrite console. When enabled only 1 user will be able to use the registration form. New users can be added by invting them to your project. By default this option is enabled.', + 'introduction' => '0.8.0', + 'default' => 'enabled', + 'required' => false, + 'question' => '', + ], [ 'name' => '_APP_CONSOLE_WHITELIST_EMAILS', - 'description' => 'This option allows you to limit creation of users to Appwrite console. This option is very useful for small teams or sole developers. To enable it, pass a list of allowed email addresses separated by a comma.', + 'description' => 'This option allows you to limit creation of new users on the Appwrite console. This option is very useful for small teams or sole developers. To enable it, pass a list of allowed email addresses separated by a comma.', 'introduction' => '', 'default' => '', 'required' => false, diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 2cf9770fc..e6d2cf00e 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -61,7 +61,6 @@ App::post('/v1/account') if ('console' === $project->getId()) { $whitlistEmails = $project->getAttribute('authWhitelistEmails'); $whitlistIPs = $project->getAttribute('authWhitelistIPs'); - $whitlistDomains = $project->getAttribute('authWhitelistDomains'); if (!empty($whitlistEmails) && !\in_array($email, $whitlistEmails)) { throw new Exception('Console registration is restricted to specific emails. Contact your administrator for more information.', 401); @@ -70,10 +69,6 @@ App::post('/v1/account') if (!empty($whitlistIPs) && !\in_array($request->getIP(), $whitlistIPs)) { throw new Exception('Console registration is restricted to specific IPs. Contact your administrator for more information.', 401); } - - if (!empty($whitlistDomains) && !\in_array(\substr(\strrchr($email, '@'), 1), $whitlistDomains)) { - throw new Exception('Console registration is restricted to specific domains. Contact your administrator for more information.', 401); - } } $limit = $project->getAttribute('usersAuthLimit', 0); @@ -514,7 +509,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'emailVerification' => true, 'status' => Auth::USER_STATUS_ACTIVATED, // Email should already be authenticated by OAuth2 provider 'password' => Auth::passwordHash(Auth::passwordGenerator()), - 'passwordUpdate' => \time(), + 'passwordUpdate' => 0, 'registration' => \time(), 'reset' => false, 'name' => $name, @@ -1012,7 +1007,7 @@ App::patch('/v1/account/password') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_USER) ->param('password', '', new Password(), 'New user password. Must be between 6 to 32 chars.') - ->param('oldPassword', '', new Password(), 'Old user password. Must be between 6 to 32 chars.') + ->param('oldPassword', '', new Password(), 'Old user password. Must be between 6 to 32 chars.', true) ->inject('response') ->inject('user') ->inject('projectDB') @@ -1023,12 +1018,14 @@ App::patch('/v1/account/password') /** @var Appwrite\Database\Database $projectDB */ /** @var Appwrite\Event\Event $audits */ - if (!Auth::passwordVerify($oldPassword, $user->getAttribute('password'))) { // Double check user password + // Check old password only if its an existing user. + if ($user->getAttribute('passwordUpdate') !== 0 && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'))) { // Double check user password throw new Exception('Invalid credentials', 401); } $user = $projectDB->updateDocument(\array_merge($user->getArrayCopy(), [ 'password' => Auth::passwordHash($password), + 'passwordUpdate' => \time(), ])); if (false === $user) { diff --git a/app/controllers/api/health.php b/app/controllers/api/health.php index f18c31ef0..01a9050e0 100644 --- a/app/controllers/api/health.php +++ b/app/controllers/api/health.php @@ -272,7 +272,7 @@ App::get('/v1/health/anti-virus') App::get('/v1/health/stats') // Currently only used internally ->desc('Get System Stats') ->groups(['api', 'health']) - ->label('scope', 'god') + ->label('scope', 'root') // ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) // ->label('sdk.namespace', 'health') // ->label('sdk.method', 'getStats') diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 994af027d..f0c63ce7b 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -612,7 +612,7 @@ App::delete('/v1/storage/files/:fileId') // App::get('/v1/storage/files/:fileId/scan') // ->desc('Scan Storage') // ->groups(['api', 'storage']) -// ->label('scope', 'god') +// ->label('scope', 'root') // ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) // ->label('sdk.namespace', 'storage') // ->label('sdk.method', 'getFileScan') diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 5476b6518..00f0f8dac 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -285,7 +285,7 @@ App::post('/v1/teams/:teamId/memberships') } $memberships = $projectDB->getCollection([ - 'limit' => 50, + 'limit' => 2000, 'offset' => 0, 'filters' => [ '$collection='.Database::SYSTEM_COLLECTION_MEMBERSHIPS, @@ -332,7 +332,12 @@ App::post('/v1/teams/:teamId/memberships') 'emailVerification' => false, 'status' => Auth::USER_STATUS_UNACTIVATED, 'password' => Auth::passwordHash(Auth::passwordGenerator()), - 'passwordUpdate' => \time(), + /** + * Set the password update time to 0 for users created using + * team invite and OAuth to allow password updates without an + * old password + */ + 'passwordUpdate' => 0, 'registration' => \time(), 'reset' => false, 'name' => $name, @@ -437,7 +442,7 @@ App::post('/v1/teams/:teamId/memberships') if (!$isPrivilegedUser && !$isAppUser) { // No need in comfirmation when in admin or app mode $mails - ->setParam('event', 'teams.membership.create') + ->setParam('event', 'teams.memberships.create') ->setParam('from', ($project->getId() === 'console') ? '' : \sprintf($locale->getText('account.emails.team'), $project->getAttribute('name'))) ->setParam('recipient', $email) ->setParam('name', $name) @@ -449,7 +454,7 @@ App::post('/v1/teams/:teamId/memberships') $audits ->setParam('userId', $invitee->getId()) - ->setParam('event', 'teams.membership.create') + ->setParam('event', 'teams.memberships.create') ->setParam('resource', 'teams/'.$teamId) ; @@ -571,7 +576,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') } if ($userId != $membership->getAttribute('userId')) { - throw new Exception('Invite not belong to current user ('.$user->getAttribute('email').')', 401); + throw new Exception('Invite does not belong to current user ('.$user->getAttribute('email').')', 401); } if (empty($user->getId())) { @@ -585,7 +590,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') } if ($membership->getAttribute('userId') !== $user->getId()) { - throw new Exception('Invite not belong to current user ('.$user->getAttribute('email').')', 401); + throw new Exception('Invite does not belong to current user ('.$user->getAttribute('email').')', 401); } $membership // Attach user to team @@ -641,7 +646,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') $audits ->setParam('userId', $user->getId()) - ->setParam('event', 'teams.membership.update') + ->setParam('event', 'teams.memberships.update.status') ->setParam('resource', 'teams/'.$teamId) ; @@ -717,7 +722,7 @@ App::delete('/v1/teams/:teamId/memberships/:membershipId') $audits ->setParam('userId', $membership->getAttribute('userId')) - ->setParam('event', 'teams.membership.delete') + ->setParam('event', 'teams.memberships.delete') ->setParam('resource', 'teams/'.$teamId) ; diff --git a/app/controllers/general.php b/app/controllers/general.php index 1f7d4ebae..57d1df596 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -14,8 +14,6 @@ use Appwrite\Database\Database; use Appwrite\Database\Document; use Appwrite\Database\Validator\Authorization; use Appwrite\Network\Validator\Origin; -use Utopia\Storage\Device\Local; -use Utopia\Storage\Storage; use Appwrite\Utopia\Response\Filters\V06; use Utopia\CLI\Console; @@ -23,15 +21,61 @@ Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); -App::init(function ($utopia, $request, $response, $console, $project, $user, $locale, $clients) { +App::init(function ($utopia, $request, $response, $console, $project, $consoleDB, $user, $locale, $clients) { /** @var Utopia\Swoole\Request $request */ /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $consoleDB */ /** @var Appwrite\Database\Document $console */ /** @var Appwrite\Database\Document $project */ /** @var Appwrite\Database\Document $user */ /** @var Utopia\Locale\Locale $locale */ /** @var bool $mode */ /** @var array $clients */ + + $domain = $request->getHostname(); + $domains = Config::getParam('domains', []); + if (!array_key_exists($domain, $domains)) { + $domain = new Domain(!empty($domain) ? $domain : ''); + + if (empty($domain->get()) || !$domain->isKnown() || $domain->isTest()) { + $domains[$domain->get()] = false; + Console::warning($domain->get() . ' is not a publicly accessible domain. Skipping SSL certificate generation.'); + } else { + Authorization::disable(); + $dbDomain = $consoleDB->getCollectionFirst([ + 'limit' => 1, + 'offset' => 0, + 'filters' => [ + '$collection=' . Database::SYSTEM_COLLECTION_CERTIFICATES, + 'domain=' . $domain->get(), + ], + ]); + + if (empty($dbDomain)) { + $dbDomain = [ + '$collection' => Database::SYSTEM_COLLECTION_CERTIFICATES, + '$permissions' => [ + 'read' => [], + 'write' => [], + ], + 'domain' => $domain->get(), + ]; + $dbDomain = $consoleDB->createDocument($dbDomain); + Authorization::enable(); + + Console::info('Issuing a TLS certificate for the master domain (' . $domain->get() . ') in ~30 seconds..'); // TODO move this to installation script + + ResqueScheduler::enqueueAt(\time() + 30, 'v1-certificates', 'CertificatesV1', [ + 'document' => $dbDomain, + 'domain' => $domain->get(), + 'validateTarget' => false, + 'validateCNAME' => false, + ]); + } + $domains[$domain->get()] = true; + } + Config::setParam('domains', $domains); + } $localeParam = (string)$request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -208,7 +252,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $user, $lo } }, $user->getAttribute('memberships', [])); - // TDOO Check if user is god + // TDOO Check if user is root if (!\in_array($scope, $scopes)) { if (empty($project->getId()) || Database::SYSTEM_COLLECTION_PROJECTS !== $project->getCollection()) { // Check if permission is denied because project is missing @@ -226,7 +270,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $user, $lo throw new Exception('Password reset is required', 412); } -}, ['utopia', 'request', 'response', 'console', 'project', 'user', 'locale', 'clients']); +}, ['utopia', 'request', 'response', 'console', 'project', 'consoleDB', 'user', 'locale', 'clients']); App::options(function ($request, $response) { /** @var Utopia\Swoole\Request $request */ @@ -424,4 +468,4 @@ include_once __DIR__ . '/shared/web.php'; foreach (Config::getParam('services', []) as $service) { include_once $service['controller']; -} \ No newline at end of file +} diff --git a/app/controllers/web/home.php b/app/controllers/web/home.php index cf3f3fe67..15b10a2f1 100644 --- a/app/controllers/web/home.php +++ b/app/controllers/web/home.php @@ -1,5 +1,6 @@ label('permission', 'public') ->label('scope', 'home') ->inject('response') - ->action(function ($response) { + ->inject('consoleDB') + ->inject('project') + ->action(function ($response, $consoleDB, $project) { /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $consoleDB */ + /** @var Appwrite\Database\Document $project */ - $response->redirect('/auth/signin'); + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Expires', 0) + ->addHeader('Pragma', 'no-cache') + ; + + if ('console' === $project->getId() || $project->isEmpty()) { + $whitlistRoot = App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled'); + + if($whitlistRoot !== 'disabled') { + $consoleDB->getCollection([ // Count users + 'filters' => [ + '$collection='.Database::SYSTEM_COLLECTION_USERS, + ], + ]); + + $sum = $consoleDB->getSum(); + + if($sum !== 0) { + return $response->redirect('/auth/signin'); + } + } + } + + $response->redirect('/auth/signup'); }); App::get('/auth/signin') @@ -58,6 +87,10 @@ App::get('/auth/signin') $page = new View(__DIR__.'/../../views/home/auth/signin.phtml'); + $page + ->setParam('root', App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled')) + ; + $layout ->setParam('title', 'Sign In - '.APP_NAME) ->setParam('body', $page); @@ -72,6 +105,10 @@ App::get('/auth/signup') /** @var Utopia\View $layout */ $page = new View(__DIR__.'/../../views/home/auth/signup.phtml'); + $page + ->setParam('root', App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled')) + ; + $layout ->setParam('title', 'Sign Up - '.APP_NAME) ->setParam('body', $page); @@ -87,6 +124,10 @@ App::get('/auth/recovery') $page = new View(__DIR__.'/../../views/home/auth/recovery.phtml'); + $page + ->setParam('smtpEnabled', (!empty(App::getEnv('_APP_SMTP_HOST')))) + ; + $layout ->setParam('title', 'Password Recovery - '.APP_NAME) ->setParam('body', $page); diff --git a/app/http.php b/app/http.php index d144c2e54..efa47ffbd 100644 --- a/app/http.php +++ b/app/http.php @@ -12,6 +12,8 @@ use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; use Utopia\App; use Utopia\CLI\Console; +use Utopia\Config\Config; +use Utopia\Domains\Domain; // xdebug_start_trace('/tmp/trace'); @@ -65,18 +67,6 @@ Files::load(__DIR__ . '/../public'); include __DIR__ . '/controllers/general.php'; -$domain = App::getEnv('_APP_DOMAIN', ''); - -Console::info('Issuing a TLS certificate for the master domain ('.$domain.') in 30 seconds. - Make sure your domain points to your server IP or restart your Appwrite server to try again.'); // TODO move this to installation script - -ResqueScheduler::enqueueAt(\time() + 30, 'v1-certificates', 'CertificatesV1', [ - 'document' => [], - 'domain' => $domain, - 'validateTarget' => false, - 'validateCNAME' => false, -]); - $http->on('request', function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { $request = new Request($swooleRequest); $response = new Response($swooleResponse); diff --git a/app/tasks/doctor.php b/app/tasks/doctor.php index 0f601bb5b..095adfb95 100644 --- a/app/tasks/doctor.php +++ b/app/tasks/doctor.php @@ -61,12 +61,12 @@ $cli Console::log('🟢 Abuse protection is enabled'); } + $authWhitelistRoot = App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', null); $authWhitelistEmails = App::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null); $authWhitelistIPs = App::getEnv('_APP_CONSOLE_WHITELIST_IPS', null); - $authWhitelistDomains = App::getEnv('_APP_CONSOLE_WHITELIST_DOMAINS', null); - if(empty($authWhitelistEmails) - && empty($authWhitelistDomains) + if(empty($authWhitelistRoot) + && empty($authWhitelistEmails) && empty($authWhitelistIPs) ) { Console::log('🔴 Console access limits are disabled'); diff --git a/app/views/console/comps/footer.phtml b/app/views/console/comps/footer.phtml index be5696ce4..065dabb14 100644 --- a/app/views/console/comps/footer.phtml +++ b/app/views/console/comps/footer.phtml @@ -1,6 +1,6 @@ getParam('home', ''); -$version = $this->getParam('version', '').'.'.APP_CACHE_BUSTER; +$version = $this->getParam('version', '') . '.' . APP_CACHE_BUSTER; ?>