mirror of https://github.com/zulip/zulip.git
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:
parent
9f60dd8387
commit
75d9630258
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue