Add notification email on invalid second authenticator (#28822)
This commit is contained in:
parent
18004bf227
commit
e2d9635074
8 changed files with 102 additions and 3 deletions
|
@ -181,6 +181,11 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
ip: request.remote_ip,
|
ip: request.remote_ip,
|
||||||
user_agent: request.user_agent
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Only send a notification email every hour at most
|
||||||
|
return if redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
|
||||||
|
|
||||||
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|
||||||
def second_factor_attempts_key(user)
|
def second_factor_attempts_key(user)
|
||||||
|
|
|
@ -191,6 +191,18 @@ class UserMailer < Devise::Mailer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def failed_2fa(user, remote_ip, user_agent, timestamp)
|
||||||
|
@resource = user
|
||||||
|
@remote_ip = remote_ip
|
||||||
|
@user_agent = user_agent
|
||||||
|
@detection = Browser.new(user_agent)
|
||||||
|
@timestamp = timestamp.to_time.utc
|
||||||
|
|
||||||
|
I18n.with_locale(locale) do
|
||||||
|
mail subject: default_i18n_subject
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def default_devise_subject
|
def default_devise_subject
|
||||||
|
|
24
app/views/user_mailer/failed_2fa.html.haml
Normal file
24
app/views/user_mailer/failed_2fa.html.haml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
= content_for :heading do
|
||||||
|
= render 'application/mailer/heading', heading_title: t('user_mailer.failed_2fa.title'), heading_subtitle: t('user_mailer.failed_2fa.explanation'), heading_image_url: frontend_asset_url('images/mailer-new/heading/login.png')
|
||||||
|
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||||
|
%tr
|
||||||
|
%td.email-body-padding-td
|
||||||
|
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||||
|
%tr
|
||||||
|
%td.email-inner-card-td.email-prose
|
||||||
|
%p= t 'user_mailer.failed_2fa.details'
|
||||||
|
%p
|
||||||
|
%strong #{t('sessions.ip')}:
|
||||||
|
= @remote_ip
|
||||||
|
%br/
|
||||||
|
%strong #{t('sessions.browser')}:
|
||||||
|
%span{ title: @user_agent }
|
||||||
|
= t 'sessions.description',
|
||||||
|
browser: t("sessions.browsers.#{@detection.id}", default: @detection.id.to_s),
|
||||||
|
platform: t("sessions.platforms.#{@detection.platform.id}", default: @detection.platform.id.to_s)
|
||||||
|
%br/
|
||||||
|
%strong #{t('sessions.date')}:
|
||||||
|
= l(@timestamp.in_time_zone(@resource.time_zone.presence), format: :with_time_zone)
|
||||||
|
= render 'application/mailer/button', text: t('settings.account_settings'), url: edit_user_registration_url
|
||||||
|
%p= t 'user_mailer.failed_2fa.further_actions_html',
|
||||||
|
action: link_to(t('user_mailer.suspicious_sign_in.change_password'), edit_user_registration_url)
|
15
app/views/user_mailer/failed_2fa.text.erb
Normal file
15
app/views/user_mailer/failed_2fa.text.erb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<%= t 'user_mailer.failed_2fa.title' %>
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
<%= t 'user_mailer.failed_2fa.explanation' %>
|
||||||
|
|
||||||
|
<%= t 'user_mailer.failed_2fa.details' %>
|
||||||
|
|
||||||
|
<%= t('sessions.ip') %>: <%= @remote_ip %>
|
||||||
|
<%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %>
|
||||||
|
<%= l(@timestamp.in_time_zone(@resource.time_zone.presence), format: :with_time_zone) %>
|
||||||
|
|
||||||
|
<%= t 'user_mailer.failed_2fa.further_actions_html', action: t('user_mailer.suspicious_sign_in.change_password') %>
|
||||||
|
|
||||||
|
=> <%= edit_user_registration_url %>
|
|
@ -1791,6 +1791,12 @@ en:
|
||||||
extra: It's now ready for download!
|
extra: It's now ready for download!
|
||||||
subject: Your archive is ready for download
|
subject: Your archive is ready for download
|
||||||
title: Archive takeout
|
title: Archive takeout
|
||||||
|
failed_2fa:
|
||||||
|
details: 'Here are details of the sign-in attempt:'
|
||||||
|
explanation: Someone has tried to sign in to your account but provided an invalid second authentication factor.
|
||||||
|
further_actions_html: If this wasn't you, we recommend that you %{action} immediately as it may be compromised.
|
||||||
|
subject: Second factor authentication failure
|
||||||
|
title: Failed second factor authentication
|
||||||
suspicious_sign_in:
|
suspicious_sign_in:
|
||||||
change_password: change your password
|
change_password: change your password
|
||||||
details: 'Here are details of the sign-in:'
|
details: 'Here are details of the sign-in:'
|
||||||
|
|
|
@ -265,21 +265,35 @@ RSpec.describe Auth::SessionsController do
|
||||||
context 'when repeatedly using an invalid TOTP code before using a valid code' do
|
context 'when repeatedly using an invalid TOTP code before using a valid code' do
|
||||||
before do
|
before do
|
||||||
stub_const('Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR', 2)
|
stub_const('Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR', 2)
|
||||||
|
|
||||||
|
# Travel to the beginning of an hour to avoid crossing rate-limit buckets
|
||||||
|
travel_to '2023-12-20T10:00:00Z'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not log the user in' do
|
it 'does not log the user in' do
|
||||||
# Travel to the beginning of an hour to avoid crossing rate-limit buckets
|
|
||||||
travel_to '2023-12-20T10:00:00Z'
|
|
||||||
|
|
||||||
Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do
|
Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do
|
||||||
post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
expect(controller.current_user).to be_nil
|
expect(controller.current_user).to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
|
|
||||||
expect(controller.current_user).to be_nil
|
expect(controller.current_user).to be_nil
|
||||||
expect(flash[:alert]).to match I18n.t('users.rate_limited')
|
expect(flash[:alert]).to match I18n.t('users.rate_limited')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sends a suspicious sign-in mail', :sidekiq_inline do
|
||||||
|
Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do
|
||||||
|
post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
|
expect(controller.current_user).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
|
|
||||||
|
expect(UserMailer.deliveries.size).to eq(1)
|
||||||
|
expect(UserMailer.deliveries.first.to.first).to eq(user.email)
|
||||||
|
expect(UserMailer.deliveries.first.subject).to eq(I18n.t('user_mailer.failed_2fa.subject'))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when using a valid OTP' do
|
context 'when using a valid OTP' do
|
||||||
|
|
|
@ -93,4 +93,9 @@ class UserMailerPreview < ActionMailer::Preview
|
||||||
def suspicious_sign_in
|
def suspicious_sign_in
|
||||||
UserMailer.suspicious_sign_in(User.first, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc)
|
UserMailer.suspicious_sign_in(User.first, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/failed_2fa
|
||||||
|
def failed_2fa
|
||||||
|
UserMailer.failed_2fa(User.first, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -135,6 +135,24 @@ describe UserMailer do
|
||||||
'user_mailer.suspicious_sign_in.subject'
|
'user_mailer.suspicious_sign_in.subject'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#failed_2fa' do
|
||||||
|
let(:ip) { '192.168.0.1' }
|
||||||
|
let(:agent) { 'NCSA_Mosaic/2.0 (Windows 3.1)' }
|
||||||
|
let(:timestamp) { Time.now.utc }
|
||||||
|
let(:mail) { described_class.failed_2fa(receiver, ip, agent, timestamp) }
|
||||||
|
|
||||||
|
it 'renders failed 2FA notification' do
|
||||||
|
receiver.update!(locale: nil)
|
||||||
|
|
||||||
|
expect(mail)
|
||||||
|
.to be_present
|
||||||
|
.and(have_body_text(I18n.t('user_mailer.failed_2fa.explanation')))
|
||||||
|
end
|
||||||
|
|
||||||
|
include_examples 'localized subject',
|
||||||
|
'user_mailer.failed_2fa.subject'
|
||||||
|
end
|
||||||
|
|
||||||
describe '#appeal_approved' do
|
describe '#appeal_approved' do
|
||||||
let(:appeal) { Fabricate(:appeal, account: receiver.account, approved_at: Time.now.utc) }
|
let(:appeal) { Fabricate(:appeal, account: receiver.account, approved_at: Time.now.utc) }
|
||||||
let(:mail) { described_class.appeal_approved(receiver, appeal) }
|
let(:mail) { described_class.appeal_approved(receiver, appeal) }
|
||||||
|
|
Loading…
Reference in a new issue