diff --git a/templates/zerver/emails/new_login/new_login_alert.html b/templates/zerver/emails/new_login/new_login_alert.html new file mode 100644 index 0000000000..7f350a6c5c --- /dev/null +++ b/templates/zerver/emails/new_login/new_login_alert.html @@ -0,0 +1,34 @@ + + + + + Zulip + + + + + + + + + +
+ Zulip +

New login to Zulip

+
+

Hello, {{ user.full_name | title }}.

+

This is a notification that a new login to your Zulip account has just occured.

+

Login details:

+
+

Server: {{ realm_uri }}

+

Account: {{ user.email }}

+

Time: {{ device_info.login_time }}

+

Device: {{ device_info.device_browser if device_info.device_browser else 'An unknown browser' }} on {{ device_info.device_os if device_info.device_os else 'an unknown operating system' }}.

+

IP Address: {{ device_info.device_ip }}

+
+

If you do not recoginize this login activity or think your account may have been compromised, contact Zulip Support at {{ zulip_support }}.

+

If you recognize this login activity, you can archive this notice.

+

Thanks,
Zulip Account Security

+
+ + diff --git a/templates/zerver/emails/new_login/new_login_alert.subject b/templates/zerver/emails/new_login/new_login_alert.subject new file mode 100644 index 0000000000..71465a5af8 --- /dev/null +++ b/templates/zerver/emails/new_login/new_login_alert.subject @@ -0,0 +1 @@ +A new login to your Zulip account. diff --git a/templates/zerver/emails/new_login/new_login_alert.txt b/templates/zerver/emails/new_login/new_login_alert.txt new file mode 100644 index 0000000000..db494f8490 --- /dev/null +++ b/templates/zerver/emails/new_login/new_login_alert.txt @@ -0,0 +1,19 @@ +{# Informs user of a new login to their account #} + +Hello, {{ user.full_name | title }}. + +This is a notification that a new login to your Zulip account has just occured. + +Login details: +Server: {{ realm_uri }} +Account: {{ user.email }} +Time: {{ device_info.login_time }} +Device: {{ device_info.device_browser if device_info.device_browser else 'an unknown browser' }} on {{ device_info.device_os if device_info.device_os else 'an unknown operating system' }}. +IP Address: {{ device_info.device_ip }} + +If you do not recoginize this login activity, or think your account may have been compromised, contact Zulip Support at {{ zulip_support }}. + +If you recognize this login activity, you can archive this notice. + +Thanks, +Zulip Account Security diff --git a/zerver/apps.py b/zerver/apps.py index d8c692d91e..0df8a29344 100644 --- a/zerver/apps.py +++ b/zerver/apps.py @@ -8,6 +8,7 @@ from typing import Any, Dict import logging + def flush_cache(sender, **kwargs): # type: (AppConfig, **Any) -> None logging.info("Clearing memcached cache after migrations") @@ -19,5 +20,7 @@ class ZerverConfig(AppConfig): def ready(self): # type: () -> None + import zerver.signals + if settings.POST_MIGRATION_CACHE_FLUSHING: post_migrate.connect(flush_cache, sender=self) diff --git a/zerver/middleware.py b/zerver/middleware.py index a0bc4d553a..c23924f92b 100644 --- a/zerver/middleware.py +++ b/zerver/middleware.py @@ -412,3 +412,24 @@ class SessionHostDomainMiddleware(SessionMiddleware): secure=settings.SESSION_COOKIE_SECURE or None, httponly=settings.SESSION_COOKIE_HTTPONLY or None) return response + +class SetRemoteAddrFromForwardedFor(object): + """ + Middleware that sets REMOTE_ADDR based on the HTTP_X_FORWARDED_FOR. + + This middleware replicates Django's former SetRemoteAddrFromForwardedFor middleware. + Because Zulip sits behind a NGINX reverse proxy, if the HTTP_X_FORWARDED_FOR + is set in the request, then it has properly been set by NGINX. + Therefore HTTP_X_FORWARDED_FOR's value is trusted. + """ + def process_request(self, request): + # type: (HttpRequest) -> None + try: + real_ip = request.META['HTTP_X_FORWARDED_FOR'] + except KeyError: + return None + else: + # HTTP_X_FORWARDED_FOR can be a comma-separated list of IPs. + # For NGINX reverse proxy servers, the client's IP will be the first one. + real_ip = real_ip.split(",")[0].strip() + request.META['REMOTE_ADDR'] = real_ip diff --git a/zerver/signals.py b/zerver/signals.py new file mode 100644 index 0000000000..88b3cf30d9 --- /dev/null +++ b/zerver/signals.py @@ -0,0 +1,94 @@ +from __future__ import absolute_import + +from django.dispatch import receiver +from django.contrib.auth.signals import user_logged_in +from django.core.mail import send_mail +from django.conf import settings +from django.template import loader +from django.utils import timezone +from typing import Any, Dict, Optional + +from zerver.context_processors import common_context +from zerver.models import UserProfile + + +def get_device_browser(user_agent): + # type: (str) -> Optional[str] + user_agent = user_agent.lower() + if "chrome" in user_agent and "chromium" not in user_agent: + return 'Chrome' + elif "firefox" in user_agent and "seamonkey" not in user_agent and "chrome" not in user_agent: + return "Firefox" + elif "chromium" in user_agent: + return "Chromium" + elif "safari" in user_agent and "chrome" not in user_agent and "chromium" not in user_agent: + return "Safari" + elif "opera" in user_agent: + return "Opera" + elif "msie" in user_agent or "trident" in user_agent: + return "Internet Explorer" + elif "edge" in user_agent: + return "Edge" + else: + return None + + +def get_device_os(user_agent): + # type: (str) -> Optional[str] + user_agent = user_agent.lower() + if "windows" in user_agent: + return "Windows" + elif "macintosh" in user_agent: + return "MacOS" + elif "linux" in user_agent and "android" not in user_agent: + return "Linux" + elif "android" in user_agent: + return "Android" + elif "like mac os x" in user_agent: + return "iOS" + else: + return None + + +@receiver(user_logged_in, dispatch_uid="only_on_login") +def email_on_new_login(sender, user, request, **kwargs): + # type: (Any, UserProfile, Any, Any) -> None + + if not settings.SEND_LOGIN_EMAILS: + return + + if request: + # Login emails are for returning users, not new registrations. + # Determine if login request was from new registration. + path = request.META.get('PATH_INFO', None) + + if path: + if path == "/accounts/register/": + return + + login_time = timezone.now().strftime('%A, %B %d, %Y at %I:%M%p ') + \ + timezone.get_current_timezone_name() + user_agent = request.META.get('HTTP_USER_AGENT', "").lower() + device_browser = get_device_browser(user_agent) + device_os = get_device_os(user_agent) + device_ip = request.META.get('REMOTE_ADDR') or "Uknown IP address" + device_info = {"device_browser": device_browser, + "device_os": device_os, + "device_ip": device_ip, + "login_time": login_time + } + + context = common_context(user) + context['device_info'] = device_info + context['zulip_support'] = settings.ZULIP_ADMINISTRATOR + context['user'] = user + + text_template = 'zerver/emails/new_login/new_login_alert.txt' + html_template = 'zerver/emails/new_login/new_login_alert.html' + text_content = loader.render_to_string(text_template, context) + html_content = loader.render_to_string(html_template, context) + + sender = settings.NOREPLY_EMAIL_ADDRESS + recipients = [user.email] + subject = loader.render_to_string('zerver/emails/new_login/new_login_alert.subject').strip() + send_mail(subject, text_content, sender, recipients, html_message=html_content) diff --git a/zerver/tests/test_send_login_emails.py b/zerver/tests/test_send_login_emails.py new file mode 100644 index 0000000000..7b4e9d7ffe --- /dev/null +++ b/zerver/tests/test_send_login_emails.py @@ -0,0 +1,91 @@ +from django.conf import settings +from django.core import mail +from django.contrib.auth.signals import user_logged_in +from zerver.lib.test_classes import ZulipTestCase +from zerver.signals import get_device_browser, get_device_os + +class SendLoginEmailTest(ZulipTestCase): + """ + Uses django's user_logged_in signal to send emails on new login. + + The receiver handler for this signal is always registered in production, + development and testing, but emails are only sent based on SEND_LOGIN_EMAILS setting. + + SEND_LOGIN_EMAILS is set to true in default settings. + It is turned off during testing. + """ + + def test_send_login_emails_if_send_login_email_setting_is_true(self): + # type: () -> None + with self.settings(SEND_LOGIN_EMAILS=True): + self.assertTrue(settings.SEND_LOGIN_EMAILS) + self.login("hamlet@zulip.com") + + # email is sent and correct subject + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'A new login to your Zulip account.') + + def test_dont_send_login_emails_if_send_login_emails_is_false(self): + # type: () -> None + self.assertFalse(settings.SEND_LOGIN_EMAILS) + self.login("hamlet@zulip.com") + + self.assertEqual(len(mail.outbox), 0) + + def test_dont_send_login_emails_for_new_user_registration_logins(self): + # type: () -> None + with self.settings(SEND_LOGIN_EMAILS=True): + self.register("test@zulip.com", "test") + + for email in mail.outbox: + self.assertNotEqual(email.subject, 'A new login to your Zulip account.') + + def test_without_path_info_dont_send_login_emails_for_new_user_registration_logins(self): + # type: () -> None + with self.settings(SEND_LOGIN_EMAILS=True): + self.client_post('/accounts/home/', {'email': "orange@zulip.com"}) + self.submit_reg_form_for_user("orange@zulip.com", "orange", PATH_INFO='') + + for email in mail.outbox: + self.assertNotEqual(email.subject, 'A new login to your Zulip account.') + +class TestBrowserAndOsUserAgentStrings(ZulipTestCase): + + def setUp(self): + # type: () -> None + self.user_agents = [ + ('mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/54.0.2840.59 Safari/537.36', 'Chrome', 'Linux',), + ('mozilla/5.0 (windows nt 6.1; win64; x64) applewebkit/537.36 (khtml, like gecko) ' + + 'chrome/56.0.2924.87 safari/537.36', 'Chrome', 'Windows',), + ('mozilla/5.0 (windows nt 6.1; wow64; rv:51.0) ' + + 'gecko/20100101 firefox/51.0', 'Firefox', 'Windows',), + ('mozilla/5.0 (windows nt 6.1; wow64; trident/7.0; rv:11.0) ' + + 'like gecko', 'Internet Explorer', 'Windows'), + ('Mozilla/5.0 (Android; Mobile; rv:27.0) ' + + 'Gecko/27.0 Firefox/27.0', 'Firefox', 'Android'), + ('Mozilla/5.0 (iPad; CPU OS 6_1_3 like Mac OS X) ' + + 'AppleWebKit/536.26 (KHTML, like Gecko) ' + + 'Version/6.0 Mobile/10B329 Safari/8536.25', 'Safari', 'iOS'), + ('Mozilla/5.0 (iPhone; CPU iPhone OS 6_1_4 like Mac OS X) ' + + 'AppleWebKit/536.26 (KHTML, like Gecko) Mobile/10B350', None, 'iOS'), + ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/56.0.2924.87 Safari/537.36', 'Chrome', 'MacOS'), + ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) ' + + 'AppleWebKit/602.3.12 (KHTML, like Gecko) ' + + 'Version/10.0.2 Safari/602.3.12', 'Safari', 'MacOS'), + ('', None, None), + ] + + def test_get_browser_on_new_login(self): + # type: () -> None + for user_agent in self.user_agents: + device_browser = get_device_browser(user_agent[0]) + self.assertEqual(device_browser, user_agent[1]) + + def test_get_os_on_new_login(self): + # type: () -> None + for user_agent in self.user_agents: + device_os = get_device_os(user_agent[0]) + self.assertEqual(device_os, user_agent[2]) diff --git a/zerver/tests/test_templates.py b/zerver/tests/test_templates.py index a8d044c1ea..bf015627de 100644 --- a/zerver/tests/test_templates.py +++ b/zerver/tests/test_templates.py @@ -177,6 +177,12 @@ class TemplateTestCase(ZulipTestCase): messages=[dict(header='Header')], new_streams=dict(html=''), data=dict(title='Title'), + device_info={"device_browser": "Chrome", + "device_os": "Windows", + "device_ip": "127.0.0.1", + "login_time": "9:33am NewYork, NewYork", + }, + zulip_support="zulip-admin@example.com", ) context.update(kwargs) diff --git a/zproject/settings.py b/zproject/settings.py index 6b3f64324f..548b8c7200 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -152,6 +152,7 @@ DEFAULT_SETTINGS = {'TWITTER_CONSUMER_KEY': '', 'CAMO_URI': '', 'ENABLE_FEEDBACK': PRODUCTION, 'SEND_MISSED_MESSAGE_EMAILS_AS_USER': False, + 'SEND_LOGIN_EMAILS': True, 'SERVER_EMAIL': None, 'FEEDBACK_EMAIL': None, 'FEEDBACK_STREAM': None, @@ -314,8 +315,10 @@ TEMPLATES = [ ] MIDDLEWARE_CLASSES = ( - # Our logging middleware should be the first middleware item. + # With the exception of it's dependencies, + # our logging middleware should be the top middleware item. 'zerver.middleware.TagRequests', + 'zerver.middleware.SetRemoteAddrFromForwardedFor', 'zerver.middleware.LogRequests', 'zerver.middleware.JsonErrorHandler', 'zerver.middleware.RateLimitMiddleware', diff --git a/zproject/test_settings.py b/zproject/test_settings.py index 24008bd114..450b04b1b5 100644 --- a/zproject/test_settings.py +++ b/zproject/test_settings.py @@ -128,3 +128,7 @@ INLINE_URL_EMBED_PREVIEW = False HOME_NOT_LOGGED_IN = '/login' LOGIN_URL = '/accounts/login' + +# By default will not send emails when login occurs. +# Explicity set this to True within tests that must have this on. +SEND_LOGIN_EMAILS = False