mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 20:53:42 +08:00
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.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",
|
||||||
|
|
17
app/jobs/regular/suspicious_login.rb
Normal file
17
app/jobs/regular/suspicious_login.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 doesn’t exist or is private."
|
title: "Oops! That page doesn’t exist or is private."
|
||||||
popular_topics: "Popular"
|
popular_topics: "Popular"
|
||||||
|
|
|
@ -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?:\/\/.+[^\/]$'
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
33
spec/jobs/suspicious_login_spec.rb
Normal file
33
spec/jobs/suspicious_login_spec.rb
Normal 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
|
|
@ -1,4 +1,5 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require 'discourse_ip_info'
|
||||||
|
|
||||||
describe UserAuthToken do
|
describe UserAuthToken do
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user