Add notification email on invalid second authenticator (#28822)
This commit is contained in:
		
							parent
							
								
									18004bf227
								
							
						
					
					
						commit
						e2d9635074
					
				| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user