From ea2302238d766f58e2bafb9e21df38d483fb2132 Mon Sep 17 00:00:00 2001 From: Khushboo Verma <43381712+vermakhushboo@users.noreply.github.com> Date: Wed, 6 Dec 2023 17:30:37 +0100 Subject: [PATCH] Alert on new session creation --- app/config/locale/translations/en.json | 6 ++ app/controllers/api/account.php | 75 ++++++++++++++++++- app/controllers/api/projects.php | 35 ++++++++- app/init.php | 1 + .../Utopia/Response/Model/Project.php | 41 +++++----- 5 files changed, 137 insertions(+), 21 deletions(-) diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index 681c88ae94..8776900704 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -15,6 +15,12 @@ "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.authNotify.subject": "New session alert for {{project}}", + "emails.authNotify.hello": "Hey {{user}},", + "emails.authNotify.body": "We're writing to inform you that a new session has been initiated on your {{project}} account on {{date}} at {{time}}.

Here are the details:
Device: {{device}}
Location: {{country}}
IP Address: {{ip}}

If you did not initiate this session, we strongly recommend that you review your account settings.", + "emails.authNotify.footer": "If you recognize this activity, no further action is required.", + "emails.authNotify.thanks": "Thanks", + "emails.authNotify.signature": "{{project}} team", "emails.recovery.subject": "Password Reset", "emails.recovery.hello": "Hello {{user}}", "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 d52b2ef14f..571566b0ce 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -234,6 +234,7 @@ App::post('/v1/account/sessions/email') ->label('abuse-key', 'url:{url},email:{param-email}') ->param('email', '', new Email(), 'User email.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') + ->param('notify', false, new Boolean(), 'Send email notification about new session creation to user email.', true) ->inject('request') ->inject('response') ->inject('user') @@ -242,7 +243,8 @@ App::post('/v1/account/sessions/email') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) { + ->inject('queueForMails') + ->action(function (string $email, string $password, bool $notify, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -310,6 +312,77 @@ App::post('/v1/account/sessions/email') ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); } + if ($project->getAttribute('auths', [])['notify'] ?? false) { + $body = $locale->getText("emails.authNotify.body"); + $subject = $locale->getText("emails.authNotify.subject"); + + $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); + $message + ->setParam('{{body}}', $body) + ->setParam('{{hello}}', $locale->getText("emails.authNotify.hello")) + ->setParam('{{footer}}', $locale->getText("emails.authNotify.footer")) + ->setParam('{{thanks}}', $locale->getText("emails.authNotify.thanks")) + ->setParam('{{signature}}', $locale->getText("emails.authNotify.signature")); + $body = $message->render(); + + $smtp = $project->getAttribute('smtp', []); + $smtpEnabled = $smtp['enabled'] ?? false; + + $senderEmail = App::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); + $senderName = App::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); + $replyTo = ""; + + if ($smtpEnabled) { + if (!empty($smtp['senderEmail'])) { + $senderEmail = $smtp['senderEmail']; + } + if (!empty($smtp['senderName'])) { + $senderName = $smtp['senderName']; + } + if (!empty($smtp['replyTo'])) { + $replyTo = $smtp['replyTo']; + } + + $queueForMails + ->setSmtpHost($smtp['host'] ?? '') + ->setSmtpPort($smtp['port'] ?? '') + ->setSmtpUsername($smtp['username'] ?? '') + ->setSmtpPassword($smtp['password'] ?? '') + ->setSmtpSecure($smtp['secure'] ?? ''); + + $queueForMails + ->setSmtpReplyTo($replyTo) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName); + } + + $device = $detector->getDevice(); + + $sessionCreatedAt = $session->getCreatedAt(); + $dateTime = new DateTimeImmutable($sessionCreatedAt); + $date = $dateTime->format('Y-m-d'); + $time = $dateTime->format('H:i:s'); + + $emailVariables = [ + 'direction' => $locale->getText('settings.direction'), + 'user' => $user->getAttribute('name'), + 'project' => $project->getAttribute('name'), + 'date' => $date, + 'time' => $time, + 'device' => $device['deviceBrand'] . " " . $device['deviceModel'] . " " . $device['deviceName'], + 'country' => $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')), + 'ip' => $request->getIP(), + 'redirect' => '' + ]; + + $queueForMails + ->setSubject($subject) + ->setBody($body) + ->setVariables($emailVariables) + ->setRecipient($email) + ->trigger(); + } + $response ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 1f8913c84f..f4d2d08fb5 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -85,7 +85,7 @@ App::post('/v1/projects') } $auth = Config::getParam('auth', []); - $auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordAi' => false, 'sessionRefresh' => false, 'passwordDictionary' => false, 'passwordDictionaryLength' => '10k', 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false]; + $auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordAi' => false, 'sessionRefresh' => false, 'passwordDictionary' => false, 'passwordDictionaryLength' => '10k', 'notify' => true, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false]; foreach ($auth as $index => $method) { $auths[$method['key'] ?? ''] = true; } @@ -853,6 +853,39 @@ App::patch('/v1/projects/:projectId/auth/password-ai') $response->dynamic($project, Response::MODEL_PROJECT); }); +App::patch('/v1/projects/:projectId/auth/notify') + ->desc('Update authentication notify status. Enable or disable notifications for new session creations.') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'projects') + ->label('sdk.method', 'notifyAuthCreate') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROJECT) + ->param('projectId', '', new UID(), 'Project unique ID.', true) + ->param('enabled', false, new Boolean(false), 'Set whether or not to enable notifications for new session creations. Default is false.') + ->inject('response') + ->inject('dbForConsole') + ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForConsole) { + + if (!empty($projectId)) { + $project = $dbForConsole->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + } + + $auths = $project->getAttribute('auths', []); + $auths['notify'] = $enabled; + + $dbForConsole->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + App::patch('/v1/projects/:projectId/auth/personal-data') ->desc('Enable or disable checking user passwords for similarity with their personal data.') ->groups(['api', 'projects']) diff --git a/app/init.php b/app/init.php index 6372491d2b..e98742b694 100644 --- a/app/init.php +++ b/app/init.php @@ -1224,6 +1224,7 @@ App::setResource('console', function () { 'invites' => App::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds + 'notify' => true, ], '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)) : [], diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 138d9ae926..4402e3e5e3 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -138,18 +138,24 @@ class Project extends Model 'default' => '10k', 'example' => '1m', ]) - ->addRule('authPasswordAi', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'Whether or not to check user\'s password against against AI opinion', - 'default' => false, - 'example' => true, - ]) - ->addRule('authSessionRefresh', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'Whether or not sessions are automatically extended to session duration on every request', - 'default' => false, - 'example' => true, - ]) + ->addRule('authPasswordAi', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to check user\'s password against against AI opinion', + 'default' => false, + 'example' => true, + ]) + ->addRule('authNotify', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to send notification on new session creation.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authSessionRefresh', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not sessions are automatically extended to session duration on every request', + 'default' => false, + 'example' => true, + ]) ->addRule('authPersonalDataCheck', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not to check the user password for similarity with their personal data.', @@ -238,8 +244,7 @@ class Project extends Model 'description' => 'SMTP server secure protocol', 'default' => '', 'example' => 'tls', - ]) - ; + ]); $services = Config::getParam('services', []); $auth = Config::getParam('auth', []); @@ -254,8 +259,7 @@ class Project extends Model 'description' => $name . ' auth method status', 'example' => true, 'default' => true, - ]) - ; + ]); } foreach ($services as $service) { @@ -272,8 +276,7 @@ class Project extends Model 'description' => $name . ' service status', 'example' => true, 'default' => true, - ]) - ; + ]); } } @@ -340,8 +343,8 @@ class Project extends Model $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPasswordDictionaryLength', $authValues['passwordDictionaryLength'] ?? false); $document->setAttribute('authPasswordAi', $authValues['passwordAi'] ?? false); + $document->setAttribute('authNotify', $authValues['notify'] ?? false); $document->setAttribute('authSessionRefresh', $authValues['sessionRefresh'] ?? false); - // TODO: Khushboo add here $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); foreach ($auth as $index => $method) {