mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	Add notifications on new logins to Zulip.
This adds helpful email notifications for users who just logged into a Zulip server, as a security protection against accounts being hacked. Text tweaked by tabbott. Fixes #2182.
This commit is contained in:
		
							
								
								
									
										34
									
								
								templates/zerver/emails/new_login/new_login_alert.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								templates/zerver/emails/new_login/new_login_alert.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
 | 
			
		||||
<html lang="en">
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
 | 
			
		||||
        <title>Zulip</title>
 | 
			
		||||
    </head>
 | 
			
		||||
    <body>
 | 
			
		||||
        <table width="80%" style="align:center; max-width:800px" align="center">
 | 
			
		||||
            <tr>
 | 
			
		||||
                <td>
 | 
			
		||||
                    <a href="{{ realm_uri }}"><img style="max-height:75px; height:75px;"  height="75px" alt="Zulip" title="{{ _('Zulip') }}" src="{{ realm_uri }}/static/images/landing-page/zulip-header.png" /></a>
 | 
			
		||||
                    <h3 style="font-family:Arial; font-size:30px; margin: 5px 0px; color:#555">New login to Zulip</h3>
 | 
			
		||||
                </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr>
 | 
			
		||||
                <td style="font-size:16px; font-family:Helvetica;">
 | 
			
		||||
                    <p><b>Hello, {{ user.full_name | title }}.</b></p>
 | 
			
		||||
                    <p>This is a notification that a new login to your Zulip account has just occured.</p>
 | 
			
		||||
                    <p><b>Login details:</b></p>
 | 
			
		||||
                    <blockquote>
 | 
			
		||||
                        <p>Server: {{ realm_uri }}</p>
 | 
			
		||||
                        <p>Account: {{ user.email }}</p>
 | 
			
		||||
                        <p>Time: {{ device_info.login_time }}</p>
 | 
			
		||||
                        <p>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' }}.</p>
 | 
			
		||||
                        <p>IP Address: {{ device_info.device_ip }}</p>
 | 
			
		||||
                    </blockquote>
 | 
			
		||||
                    <p><b>If you do not recoginize this login activity or think your account may have been compromised, contact Zulip Support at {{ zulip_support }}.</b></p>
 | 
			
		||||
                    <p>If you recognize this login activity, you can archive this notice.</p>
 | 
			
		||||
                    <p>Thanks,<br />Zulip Account Security</p>
 | 
			
		||||
                </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </body>
 | 
			
		||||
</html>
 | 
			
		||||
@@ -0,0 +1 @@
 | 
			
		||||
A new login to your Zulip account.
 | 
			
		||||
							
								
								
									
										19
									
								
								templates/zerver/emails/new_login/new_login_alert.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								templates/zerver/emails/new_login/new_login_alert.txt
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										94
									
								
								zerver/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								zerver/signals.py
									
									
									
									
									
										Normal file
									
								
							@@ -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)
 | 
			
		||||
							
								
								
									
										91
									
								
								zerver/tests/test_send_login_emails.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								zerver/tests/test_send_login_emails.py
									
									
									
									
									
										Normal file
									
								
							@@ -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])
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user