diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index de6d8bb718..663e6b39dc 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": "Create Session", + "emails.magicSession.hello": "Hey,", + "emails.magicSession.body": "Follow this link to create a session.", + "emails.magicSession.footer": "If you didn’t ask to create session for 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..33f7cf51d0 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -611,6 +611,273 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ; }); + +App::post('/v1/account/sessions/url') + ->desc('Create Magic URL for creating sessions') + ->groups(['api', 'account']) + ->label('scope', 'account') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createMagicURL') + ->label('sdk.description', '/docs/references/account/create-magic-url.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},userId:{userId}') + ->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 */ + + $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(); + $userAdded = true; + } + + $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 verification 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); + } + + $url = Template::parseURL($url); + $url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['userId' => $user->getId(), 'secret' => $loginSecret, 'expire' => $expire]); + $url = Template::unParseURL($url); + + $mails + ->setParam('event', ($userAdded ?? false) ? 'users.create' : '') + ->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, sp + ->setAttribute('secret', + ($isPrivilegedUser || $isAppUser) ? $loginSecret : ''); + + $audits + ->setParam('userId', $user->getId()) + ->setParam('event', ($userAdded ?? false) ? 'users.create' : '') + ->setParam('resource', 'users/'.$user->getId()) + ; + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($token, Response::MODEL_TOKEN) + ; + }); + +App::put('/v1/account/sessions/url') + ->desc('Create session with Magic URL') + ->groups(['api', 'account']) + ->label('scope', 'public') + ->label('event', 'account.sessions.create') + ->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_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 Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Appwrite\Event\Event $audits */ + + $profile = $projectDB->getCollectionFirst([ // Get user by email address + '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_VERIFICATION, $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('sessions', $session, Document::SET_TYPE_APPEND); + + $user = $projectDB->updateDocument($profile->getArrayCopy()); + + if (false === $user) { + throw new Exception('Failed saving user to 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']) 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/workers/mails.php b/app/workers/mails.php index 25abe54aa5..578c71ce98 100644 --- a/app/workers/mails.php +++ b/app/workers/mails.php @@ -58,6 +58,9 @@ class MailsV1 extends Worker case MAIL_TYPE_VERIFICATION: $subject = $locale->getText("$prefix.subject"); break; + case MAIL_TYPE_MAGIC_SESSION: + $subject = $locale->getText("$prefix.subject"); + break; default: throw new Exception('Undefined Mail Type : ' . $type, 500); } @@ -132,6 +135,8 @@ class MailsV1 extends Worker return 'emails.invitation'; case MAIL_TYPE_VERIFICATION: return 'emails.verification'; + case MAIL_TYPE_MAGIC_SESSION: + return 'emails.magicSession'; default: throw new Exception('Undefined Mail Type : ' . $type, 500); } diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 9fa181486e..ec47871b9d 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -32,12 +32,14 @@ class Auth const TOKEN_TYPE_VERIFICATION = 2; const TOKEN_TYPE_RECOVERY = 3; const TOKEN_TYPE_INVITE = 4; + const TOKEN_TYPE_MAGIC_URL = 5; /** * Session Providers. */ const SESSION_PROVIDER_EMAIL = 'email'; const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; + const SESSION_PROVIDER_MAGIC_URL = 'magicUrl'; /** * Token Expiration times.