diff --git a/.env b/.env index 19215e23ee..9cccf5ee7e 100644 --- a/.env +++ b/.env @@ -4,6 +4,7 @@ _APP_LOCALE=en _APP_WORKER_PER_CORE=6 _APP_CONSOLE_WHITELIST_ROOT=disabled _APP_CONSOLE_WHITELIST_EMAILS= +_APP_CONSOLE_SESSION_ALERTS=enabled _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_COUNTRIES_DENYLIST=AQ _APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io diff --git a/app/config/locale/templates/email-session-alert.tpl b/app/config/locale/templates/email-session-alert.tpl new file mode 100644 index 0000000000..9855175b6f --- /dev/null +++ b/app/config/locale/templates/email-session-alert.tpl @@ -0,0 +1,14 @@ +

{{hello}},

+ +

{{body}}

+ +
    +
  1. {{listDevice}}
  2. +
  3. {{listIpAddress}}
  4. +
  5. {{listCountry}}
  6. +
+ +

{{footer}}

+ +

{{thanks}}

+

{{signature}}

diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index 72bb9c099e..953888013a 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -18,6 +18,15 @@ "emails.magicSession.securityPhrase": "Security phrase for this email is {{b}}{{phrase}}{{/b}}. You can trust this email if this phrase matches the phrase shown during sign in.", "emails.magicSession.thanks": "Thanks,", "emails.magicSession.signature": "{{project}} team", + "emails.sessionAlert.subject": "New session alert for {{project}}", + "emails.sessionAlert.hello":"Hello {{user}}", + "emails.sessionAlert.body": "We're writing to inform you that a new session has been initiated on your {{b}}{{project}}{{/b}} account, on {{b}}{{dateTime}}{{/b}}. \nHere are the details of the new session: ", + "emails.sessionAlert.listDevice": "Device: {{b}}{{device}}{{/b}}", + "emails.sessionAlert.listIpAddress": "IP Address: {{b}}{{ipAddress}}{{/b}}", + "emails.sessionAlert.listCountry": "Country: {{b}}{{country}}{{/b}}", + "emails.sessionAlert.footer": "If you didn't request the sign in, you can safely ignore this email. If you suspect unauthorized activity, please secure your account immediately.", + "emails.sessionAlert.thanks": "Thanks,", + "emails.sessionAlert.signature": "{{project}} team", "emails.otpSession.subject": "OTP for {{project}} Login", "emails.otpSession.hello": "Hello {{user}}", "emails.otpSession.description": "Enter the following verification code when prompted to securely sign in to your {{b}}{{project}}{{/b}} account. This code will expire in 15 minutes.", @@ -34,7 +43,7 @@ "emails.recovery.subject": "Password Reset", "emails.recovery.hello": "Hello {{user}}", "emails.recovery.body": "Follow this link to reset your {{b}}{{project}}{{/b}} password.", - "emails.recovery.footer": "If you didn’t ask to reset your password, you can ignore this message.", + "emails.recovery.footer": "If you didn't ask to reset your password, you can ignore this message.", "emails.recovery.thanks": "Thanks", "emails.recovery.signature": "{{project}} team", "emails.invitation.subject": "Invitation to %s Team at %s", diff --git a/app/config/variables.php b/app/config/variables.php index ef30d4d17b..b986ce4247 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -250,6 +250,15 @@ return [ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_CONSOLE_SESSION_ALERTS', + 'description' => 'This option allows you configure if a new login in the Appwrite Console should send an alert email to the user. It\'s disabled by default with value "disabled", and to enable it, pass value "enabled".', + 'introduction' => '1.6.0', + 'default' => 'disabled', + 'required' => false, + 'question' => '', + 'filter' => '' + ], ], ], [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 9b6f1750e4..8f46ca3c62 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -58,7 +58,92 @@ use Utopia\Validator\WhiteList; $oauthDefaultSuccess = '/auth/oauth2/success'; $oauthDefaultFailure = '/auth/oauth2/failure'; -$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) { +function sendSessionAlert(Locale $locale, Document $user, Document $project, Document $session, Mail $queueForMails) +{ + $subject = $locale->getText("emails.sessionAlert.subject"); + $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? []; + + $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl'); + $message + ->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello")) + ->setParam('{{body}}', $locale->getText("emails.sessionAlert.body")) + ->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice")) + ->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress")) + ->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry")) + ->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer")) + ->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks")) + ->setParam('{{signature}}', $locale->getText("emails.sessionAlert.signature")); + + $body = $message->render(); + + $smtp = $project->getAttribute('smtp', []); + $smtpEnabled = $smtp['enabled'] ?? false; + + $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); + $senderName = System::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'] ?? ''); + + if (!empty($customTemplate)) { + if (!empty($customTemplate['senderEmail'])) { + $senderEmail = $customTemplate['senderEmail']; + } + if (!empty($customTemplate['senderName'])) { + $senderName = $customTemplate['senderName']; + } + if (!empty($customTemplate['replyTo'])) { + $replyTo = $customTemplate['replyTo']; + } + + $body = $customTemplate['message'] ?? ''; + $subject = $customTemplate['subject'] ?? $subject; + } + + $queueForMails + ->setSmtpReplyTo($replyTo) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName); + } + + $emailVariables = [ + 'direction' => $locale->getText('settings.direction'), + 'dateTime' => DateTime::format(new \DateTime(), 'Y-m-d H:i:s'), + 'user' => $user->getAttribute('name'), + 'project' => $project->getAttribute('name'), + 'device' => $session->getAttribute('clientName'), + 'ipAddress' => $session->getAttribute('ip'), + 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')), + ]; + + $email = $user->getAttribute('email'); + + $queueForMails + ->setSubject($subject) + ->setBody($body) + ->setVariables($emailVariables) + ->setRecipient($email) + ->trigger(); +}; + + +$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) { $roles = Authorization::getRoles(); $isPrivilegedUser = Auth::isPrivilegedUser($roles); $isAppUser = Auth::isAppUser($roles); @@ -138,6 +223,10 @@ $createSession = function (string $userId, string $secret, Request $request, Res throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); } + if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) { + sendSessionAlert($locale, $user, $project, $session, $queueForMails); + } + $queueForEvents ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()); @@ -719,8 +808,9 @@ App::post('/v1/account/sessions/email') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') + ->inject('queueForMails') ->inject('hooks') - ->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Hooks $hooks) { + ->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -813,6 +903,10 @@ App::post('/v1/account/sessions/email') ->setParam('sessionId', $session->getId()) ; + if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) { + sendSessionAlert($locale, $user, $project, $session, $queueForMails); + } + $response->dynamic($session, Response::MODEL_SESSION); }); @@ -981,6 +1075,7 @@ App::post('/v1/account/sessions/token') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') + ->inject('queueForMails') ->action($createSession); App::get('/v1/account/sessions/oauth2/:provider') @@ -2142,6 +2237,7 @@ App::put('/v1/account/sessions/magic-url') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') + ->inject('queueForMails') ->action($createSession); App::put('/v1/account/sessions/phone') @@ -2172,6 +2268,7 @@ App::put('/v1/account/sessions/phone') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') + ->inject('queueForMails') ->action($createSession); App::post('/v1/account/tokens/phone') diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 42dbe446dd..ff22337481 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -104,8 +104,10 @@ App::post('/v1/projects') 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, - 'personalDataCheck' => false + 'personalDataCheck' => false, + 'sessionAlerts' => false, ]; + foreach ($auth as $method) { $auths[$method['key'] ?? ''] = true; } @@ -362,7 +364,7 @@ App::patch('/v1/projects/:projectId') }); App::patch('/v1/projects/:projectId/team') - ->desc('Update Project Team') + ->desc('Update project team') ->groups(['api', 'projects']) ->label('scope', 'projects.write') ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) @@ -603,6 +605,37 @@ App::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); +App::patch('/v1/projects/:projectId/auth/session-alerts') + ->desc('Update project sessions emails') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'projects') + ->label('sdk.method', 'updateSessionAlerts') + ->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.') + ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.') + ->inject('response') + ->inject('dbForConsole') + ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForConsole) { + + $project = $dbForConsole->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $alerts; + + $dbForConsole->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + App::patch('/v1/projects/:projectId/auth/limit') ->desc('Update project users limit') ->groups(['api', 'projects']) diff --git a/app/init.php b/app/init.php index c6fe6e2409..fe28407724 100644 --- a/app/init.php +++ b/app/init.php @@ -1323,6 +1323,7 @@ App::setResource('console', function () { 'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds + 'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled' ], 'authWhitelistEmails' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [], 'authWhitelistIPs' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null)) : [], diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 62e889e1ee..fb88db4f39 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -75,6 +75,7 @@ $image = $this->getParam('image', ''); - _APP_LOCALE - _APP_CONSOLE_WHITELIST_ROOT - _APP_CONSOLE_WHITELIST_EMAILS + - _APP_CONSOLE_SESSION_ALERTS - _APP_CONSOLE_WHITELIST_IPS - _APP_CONSOLE_HOSTNAMES - _APP_SYSTEM_EMAIL_NAME diff --git a/docker-compose.yml b/docker-compose.yml index b68584d685..16480b0761 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,7 @@ services: - _APP_LOCALE - _APP_CONSOLE_WHITELIST_ROOT - _APP_CONSOLE_WHITELIST_EMAILS + - _APP_CONSOLE_SESSION_ALERTS - _APP_CONSOLE_WHITELIST_IPS - _APP_CONSOLE_HOSTNAMES - _APP_SYSTEM_EMAIL_NAME diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 6bab4401d7..da97ba2c19 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -138,6 +138,12 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authSessionAlerts', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to send session alert emails to users.', + 'default' => false, + 'example' => true, + ]) ->addRule('oAuthProviders', [ 'type' => Response::MODEL_AUTH_PROVIDER, 'description' => 'List of Auth Providers.', @@ -321,6 +327,7 @@ class Project extends Model $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); + $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); foreach ($auth as $index => $method) { $key = $method['key']; diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index b1f7c85cd9..321b1110fd 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -1190,6 +1190,64 @@ class AccountCustomClientTest extends Scope return $data; } + /** + * @depends testCreateAccountSession + */ + public function testSessionAlert($data): void + { + $email = uniqid() . 'session-alert@appwrite.io'; + $password = 'password123'; + $name = 'Session Alert Tester'; + + // Enable session alerts + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'alerts' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Create a new account + $response = $this->client->call(Client::METHOD_POST, '/account', 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(201, $response['headers']['status-code']); + + // Create a session for the new account + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Check the alert email + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($email, $lastEmail['to'][0]['address']); + $this->assertStringContainsString('New session alert', $lastEmail['subject']); + $this->assertStringContainsString($response['body']['ip'], $lastEmail['text']); // IP Address + $this->assertStringContainsString('Unknown', $lastEmail['text']); // Country + $this->assertStringContainsString($response['body']['clientName'], $lastEmail['text']); // Client name + } + /** * @depends testCreateAccountSession */