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.set_password",
"user_notifications.signup", "user_notifications.signup",
"user_notifications.signup_after_approval", "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",
"user_notifications.user_invited_to_private_message_pm_group", "user_notifications.user_invited_to_private_message_pm_group",
"user_notifications.user_invited_to_topic", "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[:email_token] = email_token if email_token.present?
email_args[:new_email] = user.email if type.to_s == "notify_old_email" 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) if EmailLog.reached_max_emails?(user, type.to_s)
return skip_message(SkippedEmailLog.reason_types[:exceeded_emails_limit]) return skip_message(SkippedEmailLog.reason_types[:exceeded_emails_limit])
end end

View File

@ -2,6 +2,8 @@ require_dependency 'markdown_linker'
require_dependency 'email/message_builder' require_dependency 'email/message_builder'
require_dependency 'age_words' require_dependency 'age_words'
require_dependency 'rtl' require_dependency 'rtl'
require_dependency 'discourse_ip_info'
require_dependency 'browser_detection'
class UserNotifications < ActionMailer::Base class UserNotifications < ActionMailer::Base
include UserNotificationsHelper include UserNotificationsHelper
@ -31,6 +33,25 @@ class UserNotifications < ActionMailer::Base
new_user_tips: tips) new_user_tips: tips)
end 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 = {}) def notify_old_email(user, opts = {})
build_email(user.email, build_email(user.email,
template: "user_notifications.notify_old_email", template: "user_notifications.notify_old_email",

View File

@ -30,6 +30,35 @@ class UserAuthToken < ActiveRecord::Base
end end
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) def self.generate!(info)
token = SecureRandom.hex(16) token = SecureRandom.hex(16)
hashed_token = hash_token(token) hashed_token = hash_token(token)
@ -51,6 +80,11 @@ class UserAuthToken < ActiveRecord::Base
path: info[:path], path: info[:path],
auth_token: hashed_token) 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 user_auth_token
end 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. 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: page_not_found:
title: "Oops! That page doesnt exist or is private." title: "Oops! That page doesnt exist or is private."
popular_topics: "Popular" popular_topics: "Popular"

View File

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

View File

@ -37,6 +37,7 @@ class DiscourseIpInfo
def get(ip) def get(ip)
return {} unless @mmdb return {} unless @mmdb
ip = ip.to_s
@cache[ip] ||= lookup(ip) @cache[ip] ||= lookup(ip)
end 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 'rails_helper'
require 'discourse_ip_info'
describe UserAuthToken do describe UserAuthToken do