FEATURE: Warn users via email about suspicious logins. (#6520)

* FEATURE: Warn users via email about suspicious logins.

* DEV: Move suspicious login check to a job.
This commit is contained in:
Bianca Nenciu 2018-10-25 12:45:31 +03:00 committed by Régis Hanol
parent 7fe3491bc0
commit 6a3767cde7
10 changed files with 132 additions and 1 deletions

View File

@ -51,6 +51,7 @@ class Admin::EmailTemplatesController < Admin::AdminController
"user_notifications.set_password",
"user_notifications.signup",
"user_notifications.signup_after_approval",
"user_notifications.suspicious_login",
"user_notifications.user_invited_to_private_message_pm",
"user_notifications.user_invited_to_private_message_pm_group",
"user_notifications.user_invited_to_topic",

View File

@ -0,0 +1,17 @@
module Jobs
class SuspiciousLogin < Jobs::Base
def execute(args)
if UserAuthToken.is_suspicious(args[:user_id], args[:client_ip])
Jobs.enqueue(:critical_user_email,
type: :suspicious_login,
user_id: args[:user_id],
client_ip: args[:client_ip],
user_agent: args[:user_agent])
end
end
end
end

View File

@ -146,6 +146,11 @@ module Jobs
email_args[:email_token] = email_token if email_token.present?
email_args[:new_email] = user.email if type.to_s == "notify_old_email"
if args[:client_ip] && args[:user_agent]
email_args[:client_ip] = args[:client_ip]
email_args[:user_agent] = args[:user_agent]
end
if EmailLog.reached_max_emails?(user, type.to_s)
return skip_message(SkippedEmailLog.reason_types[:exceeded_emails_limit])
end

View File

@ -2,6 +2,8 @@ require_dependency 'markdown_linker'
require_dependency 'email/message_builder'
require_dependency 'age_words'
require_dependency 'rtl'
require_dependency 'discourse_ip_info'
require_dependency 'browser_detection'
class UserNotifications < ActionMailer::Base
include UserNotificationsHelper
@ -31,6 +33,25 @@ class UserNotifications < ActionMailer::Base
new_user_tips: tips)
end
def suspicious_login(user, opts = {})
ipinfo = DiscourseIpInfo.get(opts[:client_ip])
location = [ipinfo[:city], ipinfo[:region], ipinfo[:country]].reject(&:blank?).join(", ")
browser = BrowserDetection.browser(opts[:user_agent])
device = BrowserDetection.device(opts[:user_agent])
os = BrowserDetection.os(opts[:user_agent])
build_email(
user.email,
template: "user_notifications.suspicious_login",
locale: user_locale(user),
client_ip: opts[:client_ip],
location: location.present? ? location : I18n.t('staff_action_logs.unknown'),
browser: I18n.t("user_auth_tokens.browser.#{browser}"),
device: I18n.t("user_auth_tokens.device.#{device}"),
os: I18n.t("user_auth_tokens.os.#{os}")
)
end
def notify_old_email(user, opts = {})
build_email(user.email,
template: "user_notifications.notify_old_email",

View File

@ -30,6 +30,35 @@ class UserAuthToken < ActiveRecord::Base
end
end
# Returns the login location as it will be used by the the system to detect
# suspicious login.
#
# This should not be very specific because small variations in location
# (i.e. changes of network, small trips, etc) will be detected as suspicious
# logins.
#
# On the other hand, if this is too broad it will not report any suspicious
# logins at all.
#
# For example, let's choose the country as the only component in login
# locations. In general, this should be a pretty good choce with the
# exception that for users from huge countries it might not be specific
# enoguh. For US users where the real user and the malicious one could
# happen to live both in USA, this will not detect any suspicious activity.
def self.login_location(ip)
DiscourseIpInfo.get(ip)[:country]
end
def self.is_suspicious(user_id, user_ip)
ips = UserAuthTokenLog.where(user_id: user_id).pluck(:client_ip)
ips.delete_at(ips.index(user_ip) || ips.length) # delete one occurance (current)
ips.uniq!
return false if ips.empty? # first login is never suspicious
user_location = login_location(user_ip)
ips.none? { |ip| user_location == login_location(ip) }
end
def self.generate!(info)
token = SecureRandom.hex(16)
hashed_token = hash_token(token)
@ -51,6 +80,11 @@ class UserAuthToken < ActiveRecord::Base
path: info[:path],
auth_token: hashed_token)
Jobs.enqueue(:suspicious_login,
user_id: info[:user_id],
client_ip: info[:client_ip],
user_agent: info[:user_agent])
user_auth_token
end

View File

@ -3225,6 +3225,24 @@ en:
If the above link is not clickable, try copying and pasting it into the address bar of your web browser.
suspicious_login:
title: "Suspicious Login Activity Detected"
subject_template: "Suspicious Login Activity Detected"
text_body_template: |
Hello,
Suspicious login activity has been detected on your account. Check the details below for more information about this login.
- IP: %{client_ip}
- Location: %{location}
- Browser: %{browser}
- Device: %{device}
- Operating System: %{os}
If you do not recognize this information, please reset your password to prevent future attacks on your account.
To strengthen your account's security, please enable second-factor authentication.
page_not_found:
title: "Oops! That page doesnt exist or is private."
popular_topics: "Popular"

View File

@ -334,7 +334,7 @@ login:
verbose_sso_logging: false
verbose_auth_token_logging:
hidden: true
default: false
default: true
sso_url:
default: ''
regex: '^https?:\/\/.+[^\/]$'

View File

@ -37,6 +37,7 @@ class DiscourseIpInfo
def get(ip)
return {} unless @mmdb
ip = ip.to_s
@cache[ip] ||= lookup(ip)
end

View File

@ -0,0 +1,33 @@
require 'rails_helper'
describe Jobs::SuspiciousLogin do
let(:user) { Fabricate(:user) }
before do
UserAuthToken.stubs(:login_location).with("1.1.1.1").returns("Location 1")
UserAuthToken.stubs(:login_location).with("1.1.1.2").returns("Location 1")
UserAuthToken.stubs(:login_location).with("1.1.2.1").returns("Location 2")
end
it "will not send an email on first login" do
Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :suspicious_login)).never
described_class.new.execute(user_id: user.id, client_ip: "1.1.1.1")
end
it "will not send an email when user log in from a known location" do
UserAuthTokenLog.create!(action: "generate", user_id: user.id, client_ip: "1.1.1.1")
Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :suspicious_login)).never
described_class.new.execute(user_id: user.id, client_ip: "1.1.1.1")
described_class.new.execute(user_id: user.id, client_ip: "1.1.1.2")
end
it "will send an email when user logs in from a new location" do
UserAuthTokenLog.create!(action: "generate", user_id: user.id, client_ip: "1.1.1.1")
Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :suspicious_login))
described_class.new.execute(user_id: user.id, client_ip: "1.1.2.1")
end
end

View File

@ -1,4 +1,5 @@
require 'rails_helper'
require 'discourse_ip_info'
describe UserAuthToken do