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:
parent
7fe3491bc0
commit
6a3767cde7
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 doesn’t exist or is private."
|
||||
popular_topics: "Popular"
|
||||
|
|
|
@ -334,7 +334,7 @@ login:
|
|||
verbose_sso_logging: false
|
||||
verbose_auth_token_logging:
|
||||
hidden: true
|
||||
default: false
|
||||
default: true
|
||||
sso_url:
|
||||
default: ''
|
||||
regex: '^https?:\/\/.+[^\/]$'
|
||||
|
|
|
@ -37,6 +37,7 @@ class DiscourseIpInfo
|
|||
def get(ip)
|
||||
return {} unless @mmdb
|
||||
|
||||
ip = ip.to_s
|
||||
@cache[ip] ||= lookup(ip)
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
|
@ -1,4 +1,5 @@
|
|||
require 'rails_helper'
|
||||
require 'discourse_ip_info'
|
||||
|
||||
describe UserAuthToken do
|
||||
|
||||
|
|
Loading…
Reference in New Issue