diff --git a/app/config/auth.php b/app/config/auth.php index 11b89dd9ec..170b87344e 100644 --- a/app/config/auth.php +++ b/app/config/auth.php @@ -10,6 +10,13 @@ return [ 'docs' => 'https://appwrite.io/docs/client/account?sdk=web#accountCreateSession', 'enabled' => true, ], + 'magic-url' => [ + 'name' => 'Magic URL', + 'key' => 'usersAuthMagicURL', + 'icon' => '/images/users/magic-url.png', + 'docs' => 'https://appwrite.io/docs/client/account?sdk=web#accountCreateMagicURLSession', + 'enabled' => true, + ], 'anonymous' => [ 'name' => 'Anonymous', 'key' => 'usersAuthAnonymous', diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index de6d8bb718..c00740b130 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -9,6 +9,12 @@ "emails.verification.footer": "If you didn’t ask to verify this address, you can ignore this message.", "emails.verification.thanks": "Thanks", "emails.verification.signature": "{{project}} team", + "emails.magicSession.subject": "Login", + "emails.magicSession.hello": "Hey,", + "emails.magicSession.body": "Follow this link to login.", + "emails.magicSession.footer": "If you didn’t ask to login using this email, you can ignore this message.", + "emails.magicSession.thanks": "Thanks", + "emails.magicSession.signature": "{{project}} team", "emails.recovery.subject": "Password Reset", "emails.recovery.hello": "Hello {{name}}", "emails.recovery.body": "Follow this link to reset your {{project}} password.", diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 55bcde6ca0..d007ea1fa2 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -611,6 +611,291 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ; }); + +App::post('/v1/account/sessions/magic-url') + ->desc('Create Magic URL session') + ->groups(['api', 'account']) + ->label('scope', 'public') + ->label('auth.type', 'magic-url') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createMagicURLSession') + ->label('sdk.description', '/docs/references/account/create-magic-url-session.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TOKEN) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},email:{param-email}') + ->param('email', '', new Email(), 'User email.') + ->param('url', '', function ($clients) { return new Host($clients); }, 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients']) + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('projectDB') + ->inject('locale') + ->inject('audits') + ->inject('events') + ->inject('mails') + ->action(function ($email, $url, $request, $response, $project, $projectDB, $locale, $audits, $events, $mails) { + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Document $project */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Utopia\Locale\Locale $locale */ + /** @var Appwrite\Event\Event $audits */ + /** @var Appwrite\Event\Event $events */ + /** @var Appwrite\Event\Event $mails */ + + if(empty(App::getEnv('_APP_SMTP_HOST'))) { + throw new Exception('SMTP Disabled', 503); + } + + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::$roles); + $isAppUser = Auth::isAppUser(Authorization::$roles); + + $user = $projectDB->getCollectionFirst([ // Get user by email address + 'limit' => 1, + 'filters' => [ + '$collection='.Database::SYSTEM_COLLECTION_USERS, + 'email='.$email, + ], + ]); + + if (empty($user)) { + $limit = $project->getAttribute('usersAuthLimit', 0); + + if ($limit !== 0) { + $projectDB->getCollection([ // Count users + 'filters' => [ + '$collection='.Database::SYSTEM_COLLECTION_USERS, + ], + ]); + + $sum = $projectDB->getSum(); + + if($sum >= $limit) { + throw new Exception('Project registration is restricted. Contact your administrator for more information.', 501); + } + } + + Authorization::disable(); + + $user = $projectDB->createDocument([ + '$collection' => Database::SYSTEM_COLLECTION_USERS, + '$permissions' => [ + 'read' => ['*'], + 'write' => ['user:{self}'], + ], + 'email' => $email, + 'emailVerification' => false, + 'status' => Auth::USER_STATUS_UNACTIVATED, + 'password' => null, + 'passwordUpdate' => \time(), + 'registration' => \time(), + 'reset' => false, + 'name' => null, + ], ['email' => $email]); + + Authorization::reset(); + $mails->setParam('event', 'users.create'); + $audits->setParam('event', 'users.create'); + } + + $loginSecret = Auth::tokenGenerator(); + + $expire = \time() + Auth::TOKEN_EXPIRATION_CONFIRM; + + $token = new Document([ + '$collection' => Database::SYSTEM_COLLECTION_TOKENS, + '$permissions' => ['read' => ['user:'.$user->getId()], 'write' => ['user:'.$user->getId()]], + 'userId' => $user->getId(), + 'type' => Auth::TOKEN_TYPE_MAGIC_URL, + 'secret' => Auth::hash($loginSecret), // One way hash encryption to protect DB leak + 'expire' => $expire, + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + ]); + + Authorization::setRole('user:'.$user->getId()); + + $token = $projectDB->createDocument($token->getArrayCopy()); + + if (false === $token) { + throw new Exception('Failed saving token to DB', 500); + } + + $user->setAttribute('tokens', $token, Document::SET_TYPE_APPEND); + + $user = $projectDB->updateDocument($user->getArrayCopy()); + + if (false === $user) { + throw new Exception('Failed to save user to DB', 500); + } + + if(empty($url)) { + $url = $request->getProtocol().'://'.$request->getHostname().'/auth/magic-url'; + } + + $url = Template::parseURL($url); + $url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['userId' => $user->getId(), 'secret' => $loginSecret, 'expire' => $expire, 'project' => $project->getId()]); + $url = Template::unParseURL($url); + + $mails + ->setParam('from', $project->getId()) + ->setParam('recipient', $user->getAttribute('email')) + ->setParam('url', $url) + ->setParam('locale', $locale->default) + ->setParam('project', $project->getAttribute('name', ['[APP-NAME]'])) + ->setParam('type', MAIL_TYPE_MAGIC_SESSION) + ->trigger() + ; + + $events + ->setParam('eventData', + $response->output($token->setAttribute('secret', $loginSecret), + Response::MODEL_TOKEN + )) + ; + + $token // Hide secret for clients + ->setAttribute('secret', + ($isPrivilegedUser || $isAppUser) ? $loginSecret : ''); + + $audits + ->setParam('userId', $user->getId()) + ->setParam('resource', 'users/'.$user->getId()) + ; + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($token, Response::MODEL_TOKEN) + ; + }); + +App::put('/v1/account/sessions/magic-url') + ->desc('Create Magic URL session (confirmation)') + ->groups(['api', 'account']) + ->label('scope', 'public') + ->label('event', 'account.sessions.create') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updateMagicURLSession') + ->label('sdk.description', '/docs/references/account/update-magic-url-session.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},userId:{param-userId}') + ->param('userId', '', new UID(), 'User unique ID.') + ->param('secret', '', new Text(256), 'Valid verification token.') + ->inject('request') + ->inject('response') + ->inject('projectDB') + ->inject('locale') + ->inject('geodb') + ->inject('audits') + ->action(function ($userId, $secret, $request, $response, $projectDB, $locale, $geodb, $audits) { + /** @var string $userId */ + /** @var string $secret */ + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Utopia\Locale\Locale $locale */ + /** @var MaxMind\Db\Reader $geodb */ + /** @var Appwrite\Event\Event $audits */ + + $profile = $projectDB->getCollectionFirst([ // Get user by user ID + 'limit' => 1, + 'filters' => [ + '$collection='.Database::SYSTEM_COLLECTION_USERS, + '$id='.$userId, + ], + ]); + + if (empty($profile)) { + throw new Exception('User not found', 404); + } + + $token = Auth::tokenVerify($profile->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret); + + if (!$token) { + throw new Exception('Invalid login token', 401); + } + + $detector = new Detector($request->getUserAgent('UNKNOWN')); + $record = $geodb->get($request->getIP()); + $secret = Auth::tokenGenerator(); + $expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $session = new Document(array_merge( + [ + '$collection' => Database::SYSTEM_COLLECTION_SESSIONS, + '$permissions' => ['read' => ['user:' . $profile->getId()], 'write' => ['user:' . $profile->getId()]], + 'userId' => $profile->getId(), + 'provider' => Auth::SESSION_PROVIDER_MAGIC_URL, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'expire' => $expiry, + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', + ], + $detector->getOS(), + $detector->getClient(), + $detector->getDevice() + )); + + Authorization::setRole('user:'.$profile->getId()); + + $session = $projectDB->createDocument($session->getArrayCopy()); + + if (false === $session) { + throw new Exception('Failed saving session to DB', 500); + } + + $profile->setAttribute('emailVerification', true); + $profile->setAttribute('sessions', $session, Document::SET_TYPE_APPEND); + + $user = $projectDB->updateDocument($profile->getArrayCopy()); + + if (false === $user) { + throw new Exception('Failed saving user to DB', 500); + } + + if (!$projectDB->deleteDocument($token)) { + throw new Exception('Failed to remove login token from DB', 500); + } + + $audits + ->setParam('userId', $user->getId()) + ->setParam('event', 'account.sessions.create') + ->setParam('resource', 'users/'.$user->getId()) + ; + + if (!Config::getParam('domainVerification')) { + $response + ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) + ; + } + + $protocol = $request->getProtocol(); + + $response + ->addCookie(Auth::$cookieName.'_legacy', Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->setStatusCode(Response::STATUS_CODE_CREATED) + ; + + $countryName = (isset($countries[strtoupper($session->getAttribute('countryCode'))])) + ? $countries[strtoupper($session->getAttribute('countryCode'))] + : $locale->getText('locale.country.unknown'); + + $session + ->setAttribute('current', true) + ->setAttribute('countryName', $countryName) + ; + + $response->dynamic($session, Response::MODEL_SESSION); + }); + App::post('/v1/account/sessions/anonymous') ->desc('Create Anonymous Session') ->groups(['api', 'account', 'auth']) @@ -1461,6 +1746,10 @@ App::post('/v1/account/recovery') /** @var Appwrite\Event\Event $audits */ /** @var Appwrite\Event\Event $events */ + if(empty(App::getEnv('_APP_SMTP_HOST'))) { + throw new Exception('SMTP Disabled', 503); + } + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::$roles); $isAppUser = Auth::isAppUser(Authorization::$roles); @@ -1551,7 +1840,7 @@ App::post('/v1/account/recovery') }); App::put('/v1/account/recovery') - ->desc('Complete Password Recovery') + ->desc('Create Password Recovery (confirmation)') ->groups(['api', 'account']) ->label('scope', 'public') ->label('event', 'account.recovery.update') @@ -1664,6 +1953,10 @@ App::post('/v1/account/verification') /** @var Appwrite\Event\Event $events */ /** @var Appwrite\Event\Event $mails */ + if(empty(App::getEnv('_APP_SMTP_HOST'))) { + throw new Exception('SMTP Disabled', 503); + } + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::$roles); $isAppUser = Auth::isAppUser(Authorization::$roles); @@ -1738,7 +2031,7 @@ App::post('/v1/account/verification') }); App::put('/v1/account/verification') - ->desc('Complete Email Verification') + ->desc('Create Email Verification (confirmation)') ->groups(['api', 'account']) ->label('scope', 'public') ->label('event', 'account.verification.update') diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 05b8a1542f..7421d7a885 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -274,6 +274,10 @@ App::post('/v1/teams/:teamId/memberships') /** @var Appwrite\Event\Event $audits */ /** @var Appwrite\Event\Event $mails */ + if(empty(App::getEnv('_APP_SMTP_HOST'))) { + throw new Exception('SMTP Disabled', 503); + } + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::$roles); $isAppUser = Auth::isAppUser(Authorization::$roles); diff --git a/app/controllers/general.php b/app/controllers/general.php index 0af6d92fd4..09631cf0a4 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -337,6 +337,7 @@ App::error(function ($error, $utopia, $request, $response, $layout, $project) { case 412: // Error allowed publicly case 429: // Error allowed publicly case 501: // Error allowed publicly + case 503: // Error allowed publicly $code = $error->getCode(); $message = $error->getMessage(); break; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 0df72d0578..c3134b4f86 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -141,6 +141,12 @@ App::init(function ($utopia, $request, $response, $project, $user) { } break; + case 'magic-url': + if($project->getAttribute('usersAuthMagicURL', true) === false) { + throw new Exception('Magic URL authentication is disabled for this project', 501); + } + break; + case 'anonymous': if($project->getAttribute('usersAuthAnonymous', true) === false) { throw new Exception('Anonymous authentication is disabled for this project', 501); diff --git a/app/controllers/web/console.php b/app/controllers/web/console.php index 8017eda767..69d42b8aa4 100644 --- a/app/controllers/web/console.php +++ b/app/controllers/web/console.php @@ -326,6 +326,7 @@ App::get('/console/users') $page ->setParam('auth', Config::getParam('auth')) ->setParam('providers', Config::getParam('providers')) + ->setParam('smtpEnabled', (!empty(App::getEnv('_APP_SMTP_HOST')))) ; $layout diff --git a/app/controllers/web/home.php b/app/controllers/web/home.php index f3eeb71c94..51c7c52064 100644 --- a/app/controllers/web/home.php +++ b/app/controllers/web/home.php @@ -197,6 +197,24 @@ App::get('/auth/oauth2/success') ; }); +App::get('/auth/magic-url') + ->groups(['web', 'home']) + ->label('permission', 'public') + ->label('scope', 'home') + ->inject('layout') + ->action(function ($layout) { + /** @var Utopia\View $layout */ + + $page = new View(__DIR__.'/../../views/home/auth/magicURL.phtml'); + + $layout + ->setParam('title', APP_NAME) + ->setParam('body', $page) + ->setParam('header', []) + ->setParam('footer', []) + ; + }); + App::get('/auth/oauth2/failure') ->groups(['web', 'home']) ->label('permission', 'public') diff --git a/app/init.php b/app/init.php index df71d60de1..4b8f78fc25 100644 --- a/app/init.php +++ b/app/init.php @@ -72,6 +72,7 @@ const DELETE_TYPE_ABUSE = 'abuse'; const DELETE_TYPE_CERTIFICATES = 'certificates'; // Mail Types const MAIL_TYPE_VERIFICATION = 'verification'; +const MAIL_TYPE_MAGIC_SESSION = 'magicSession'; const MAIL_TYPE_RECOVERY = 'recovery'; const MAIL_TYPE_INVITATION = 'invitation'; // Auth Types diff --git a/app/views/console/users/index.phtml b/app/views/console/users/index.phtml index 65b611be21..3e62f772bb 100644 --- a/app/views/console/users/index.phtml +++ b/app/views/console/users/index.phtml @@ -1,6 +1,7 @@ getParam('providers', []); $auth = $this->getParam('auth', []); +$smtpEnabled = $this->getParam('smtpEnabled', false); ?>
- Docs -
+ escape($name); ?>+ SMTP Disabled +
+ + ++ Docs +
+