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:
hollywoodno 2017-01-30 14:19:38 -08:00 committed by Tim Abbott
parent 9f60dd8387
commit 75d9630258
10 changed files with 277 additions and 1 deletions

View 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>

View File

@ -0,0 +1 @@
A new login to your Zulip account.

View 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

View File

@ -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)

View File

@ -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
View 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)

View 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])

View File

@ -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)

View File

@ -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',

View File

@ -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