Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5ba842a
LG-12294: Send single aggregated email notification for new device si…
aduth Apr 5, 2024
b64a278
Handle incrementing email_sent based on result
aduth Apr 5, 2024
ad643c2
Sync sign_in_new_device_at to event creation
aduth Apr 5, 2024
a5074da
Update UserEventCreator specs
aduth Apr 5, 2024
f5be57a
Only send new device notification for new device
aduth Apr 5, 2024
233a2ec
Add specs for new device notification upon MFA
aduth Apr 5, 2024
940c15e
Create event for lapsed sign in notification window
aduth Apr 8, 2024
ef95a14
Maintain plaintext disavowal token for calling new device alert
aduth Apr 8, 2024
0cdd5a0
Optimize to avoid device query on new device notification
aduth Apr 8, 2024
97a688f
Update specs for send_alert call
aduth Apr 8, 2024
cdaf48d
Update specs for UserEventCreator calls to AlertUserAboutNewDevice
aduth Apr 8, 2024
43fa4cf
Update AlertUserAboutNewDevice specs to pass
aduth Apr 8, 2024
e82c599
Add AlertUserAboutNewDevice specs
aduth Apr 8, 2024
4f1589d
Normalize YAML
aduth Apr 8, 2024
7b565d0
Eagerly load device
aduth Apr 8, 2024
a6912c7
Compare time ignoring Ruby vs. Postgres microseconds precision
aduth Apr 9, 2024
ac33b34
Add feature specs for disavowing sign-in
aduth Apr 9, 2024
f21288d
Send new device notification for PIV/CAC sign-in
aduth Apr 9, 2024
4940491
Fix Ruby Postgres precision microseconds
aduth Apr 9, 2024
7545984
Limit device creation to sign-in notification spec
aduth Apr 9, 2024
9c07f9d
Update personal key sign-in test to reflect email sent
aduth Apr 9, 2024
9c9c912
Add strings for notification timeframe expired
aduth Apr 9, 2024
a679446
Add failing regression spec for alert on existing device
aduth Apr 9, 2024
b262f74
Check new device when assigning sign_in_new_device_at
aduth Apr 9, 2024
faae091
Check new session before sending alert
aduth Apr 10, 2024
b9ff0a8
Send alert unless session value explicitly false
aduth Apr 10, 2024
27e8f72
Add missing period for failed times message
aduth Apr 11, 2024
6259d16
Use singular "failed to authenticate" when no MFA events
aduth Apr 11, 2024
0aa2d49
Add coverage for disavow second email after delayed MFA
aduth Apr 11, 2024
c3b5bde
Assert expectations prior to completing MFA
aduth Apr 11, 2024
8191b61
Normalize YAML
aduth Apr 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions app/controllers/concerns/two_factor_authenticatable_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ def auth_methods_session
@auth_methods_session ||= AuthMethodsSession.new(user_session:)
end

def handle_valid_verification_for_authentication_context(auth_method:)
mark_user_session_authenticated(auth_method:, authentication_type: :valid_2fa)
disavowal_event, disavowal_token = create_user_event_with_disavowal(:sign_in_after_2fa)

if IdentityConfig.store.feature_new_device_alert_aggregation_enabled &&
user_session[:new_device] != false
if current_user.sign_in_new_device_at.blank?
current_user.update(sign_in_new_device_at: disavowal_event.created_at)
end

UserAlerts::AlertUserAboutNewDevice.send_alert(
user: current_user,
disavowal_event:,
disavowal_token:,
)
end

reset_second_factor_attempts_count
end

private

def authenticate_user
Expand Down Expand Up @@ -163,13 +183,6 @@ def handle_valid_verification_for_confirmation_context(auth_method:)
reset_second_factor_attempts_count
end

def handle_valid_verification_for_authentication_context(auth_method:)
mark_user_session_authenticated(auth_method:, authentication_type: :valid_2fa)
create_user_event(:sign_in_after_2fa)

reset_second_factor_attempts_count
end

def reset_second_factor_attempts_count
UpdateUser.new(user: current_user, attributes: { second_factor_attempts_count: 0 }).call
end
Expand Down
1 change: 1 addition & 0 deletions app/controllers/users/piv_cac_login_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def process_valid_submission
presented: true,
)

user_session[:new_device] = current_user.new_device?(cookie_uuid: cookies[:device])
handle_valid_verification_for_authentication_context(
auth_method: TwoFactorAuthenticatable::AuthMethod::PIV_CAC,
)
Expand Down
1 change: 1 addition & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Event < ApplicationRecord
phone_added: 21,
password_invalidated: 22,
sign_in_unsuccessful_2fa: 23,
sign_in_notification_timeframe_expired: 24,
}

validates :event_type, presence: true
Expand Down
9 changes: 5 additions & 4 deletions app/services/create_new_device_alert.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def perform(now)
sql_query_for_users_with_new_device,
tvalue: now - IdentityConfig.store.new_device_alert_delay_in_minutes.minutes,
).each do |user|
emails_sent += 1 if clear_new_device_and_send_email(user)
emails_sent += 1 if expire_sign_in_notification_timeframe_and_send_alert(user)
end

emails_sent
Expand All @@ -24,9 +24,10 @@ def sql_query_for_users_with_new_device
SQL
end

def clear_new_device_and_send_email(user)
UserAlerts::AlertUserAboutNewDevice.send_alert(user)
def expire_sign_in_notification_timeframe_and_send_alert(user)
disavowal_event, disavowal_token = UserEventCreator.new(current_user: user).
create_out_of_band_user_event_with_disavowal(:sign_in_notification_timeframe_expired)

true
UserAlerts::AlertUserAboutNewDevice.send_alert(user:, disavowal_event:, disavowal_token:)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this method returns true and satisfies the emails_sent increment 👍

end
end
44 changes: 29 additions & 15 deletions app/services/user_alerts/alert_user_about_new_device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

module UserAlerts
class AlertUserAboutNewDevice
def self.call(user, device, disavowal_token)
def self.call(event:, device:, disavowal_token:)
if IdentityConfig.store.feature_new_device_alert_aggregation_enabled
user.sign_in_new_device_at ||= Time.zone.now
user.save
event.user.sign_in_new_device_at ||= event.created_at
event.user.save
Comment on lines +7 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the other code path accounts for a user signing in using their PIV ("Sign in with your government employee ID" on the Sign In page). In that flow, they never submit their email and password, so we don't get that sign_in_new_device_at assigned.

else
device_decorator = DeviceDecorator.new(device)
login_location = device_decorator.last_sign_in_location_and_ip
device_name = device_decorator.nice_name

user.confirmed_email_addresses.each do |email_address|
UserMailer.with(user: user, email_address: email_address).new_device_sign_in(
event.user.confirmed_email_addresses.each do |email_address|
UserMailer.with(user: event.user, email_address: email_address).new_device_sign_in(
date: device.last_used_at.in_time_zone('Eastern Time (US & Canada)').
strftime('%B %-d, %Y %H:%M Eastern Time'),
location: login_location,
Expand All @@ -23,17 +23,31 @@ def self.call(user, device, disavowal_token)
end
end

def self.send_alert(user)
user.update(sign_in_new_device_at: nil)
# Stub out for possible email in follow-up work
# disavowal_token = SecureRandom.urlsafe_base64(32)
def self.send_alert(user:, disavowal_event:, disavowal_token:)
return false unless user.sign_in_new_device_at

events = user.events.where(
created_at: user.sign_in_new_device_at..,
event_type: [
'sign_in_before_2fa',
'sign_in_unsuccessful_2fa',
'sign_in_after_2fa',
],
).order(:created_at).includes(:device)

user.confirmed_email_addresses.each do |email_address|
mailer = UserMailer.with(user:, email_address:)
mail = case disavowal_event.event_type
when 'sign_in_notification_timeframe_expired'
mailer.new_device_sign_in_before_2fa(events:, disavowal_token:)
when 'sign_in_after_2fa'
mailer.new_device_sign_in_after_2fa(events:, disavowal_token:)
end
mail.deliver_now_or_later
end

# user.confirmed_email_addresses.each do |email_address|
# UserMailer.with(user: user, email_address: email_address).new_device_sign_in(
# events: events,
# disavowal_token: disavowal_token,
# ).deliver_now_or_later
# end
user.update(sign_in_new_device_at: nil)
true
end
end
end
14 changes: 4 additions & 10 deletions app/services/user_event_creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,10 @@ def create_event_for_new_device(event_type:, user:, disavowal_token:)
if user.fully_registered? && user.has_devices? && disavowal_token.nil?
device, event, disavowal_token = Device.transaction do
device = create_device_for_user(user)
event, disavowal_token = create_user_event_with_disavowal(
event_type, user, device
)
event, disavowal_token = create_user_event_with_disavowal(event_type, user, device)
[device, event, disavowal_token]
end
send_new_device_notification(
user: user,
device: device,
disavowal_token: disavowal_token,
)
send_new_device_notification(event:, device:, disavowal_token:)
[event, disavowal_token]
else
Device.transaction do
Expand Down Expand Up @@ -123,8 +117,8 @@ def assign_device_cookie(device_cookie)
cookies.permanent[:device] = device_cookie unless device_cookie == cookies[:device]
end

def send_new_device_notification(user:, device:, disavowal_token:)
UserAlerts::AlertUserAboutNewDevice.call(user, device, disavowal_token)
def send_new_device_notification(event:, device:, disavowal_token:)
UserAlerts::AlertUserAboutNewDevice.call(event:, device:, disavowal_token:)
end

# @return [Array(Event, String)] an (event, disavowal_token) tuple
Expand Down
1 change: 1 addition & 0 deletions config/locales/event_types/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ en:
piv_cac_enabled: PIV/CAC card associated
sign_in_after_2fa: Signed in with second factor
sign_in_before_2fa: Signed in with password
sign_in_notification_timeframe_expired: Expired notification timeframe for sign-in from new device
sign_in_unsuccessful_2fa: Failed to authenticate
webauthn_key_added: Hardware security key added
webauthn_key_removed: Hardware security key removed
2 changes: 2 additions & 0 deletions config/locales/event_types/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ es:
piv_cac_enabled: Tarjeta PIV/CAC asociada
sign_in_after_2fa: Inicia sesión con segundo factor
sign_in_before_2fa: Inicia sesión con contraseña
sign_in_notification_timeframe_expired: Plazo de notificación expirado para el
inicio de sesión desde un nuevo dispositivo
sign_in_unsuccessful_2fa: Error al autenticar
webauthn_key_added: Clave de seguridad de hardware añadido
webauthn_key_removed: Clave de seguridad de hardware eliminada
2 changes: 2 additions & 0 deletions config/locales/event_types/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ fr:
piv_cac_enabled: Carte PIV/CAC associée
sign_in_after_2fa: Signé avec deuxième facteur
sign_in_before_2fa: Connecté avec mot de passe
sign_in_notification_timeframe_expired: Délai de notification pour la connexion
à partir d’un nouveau dispositif expiré
sign_in_unsuccessful_2fa: Échec de l’authentification
webauthn_key_added: Clé de sécurité ajoutée
webauthn_key_removed: Clé de sécurité retirée
4 changes: 3 additions & 1 deletion config/locales/user_mailer/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ en:
one: Your %{app_name} email and password were used to sign in from a new device
but <strong>failed to authenticate</strong>.
other: Your %{app_name} email and password were used to sign in from a new
device but <strong>failed to authenticate %{count} times</strong>
device but <strong>failed to authenticate %{count} times</strong>.
zero: Your %{app_name} email and password were used to sign in from a new device
but <strong>failed to authenticate</strong>.
info_p2: If you recognize this activity, you don’t need to do anything.
info_p3_html: Two-factor authentication protects your account from unauthorized
access. If this wasn’t you, %{reset_password_link_html} immediately.
Expand Down
5 changes: 4 additions & 1 deletion config/locales/user_mailer/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ es:
error</strong>.
other: Su correo electrónico y su contraseña de %{app_name} se usaron para
ingresar desde un nuevo dispositivo, pero <strong>error al autenticar
%{count} veces</strong>
%{count} veces</strong>.
zero: Su correo electrónico y su contraseña de %{app_name} se usaron para
ingresar desde un nuevo dispositivo, pero <strong>la autenticación dio
error</strong>.
info_p2: Si reconoce esta actividad, no tiene que hacer nada.
info_p3_html: La autenticación de dos factores protege su cuenta de accesos no
autorizados. Si no fue usted, %{reset_password_link_html}
Expand Down
3 changes: 3 additions & 0 deletions config/locales/user_mailer/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ fr:
other: Votre adresse électronique et votre mot de passe %{app_name} ont été
utilisés pour vous connecter à partir d’un nouvel appareil, mais
<strong>l’authentification a échoué %{count} reprises</strong>.
zero: Votre adresse électronique et votre mot de passe %{app_name} ont été
utilisés pour vous connecter à partir d’un nouvel appareil, mais
<strong>l’authentification a échoué</strong>.
info_p2: Si vous reconnaissez cette activité, vous n’avez rien à faire.
info_p3_html: L’authentification à deux facteurs protège votre compte contre
tout accès non autorisé. Si ce n’est pas vous,
Expand Down
147 changes: 147 additions & 0 deletions spec/controllers/concerns/two_factor_authenticatable_methods_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
require 'rails_helper'

RSpec.describe TwoFactorAuthenticatableMethods, type: :controller do
controller ApplicationController do
include TwoFactorAuthenticatableMethods
end

describe '#handle_valid_verification_for_authentication_context' do
let(:user) { create(:user) }
let(:auth_method) { TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE }

subject(:result) do
controller.handle_valid_verification_for_authentication_context(auth_method:)
end

before do
stub_sign_in_before_2fa(user)
end

it 'tracks authentication event' do
stub_analytics

result

expect(@analytics).to have_logged_event(
'User marked authenticated',
authentication_type: :valid_2fa,
)
end

it 'authenticates user session auth methods' do
expect(controller.auth_methods_session).to receive(:authenticate!).with(auth_method)

result
end

it 'creates a new user event with disavowal' do
expect { result }.to change { user.reload.events.count }.from(0).to(1)
expect(user.events.last.event_type).to eq('sign_in_after_2fa')
expect(user.events.last.disavowal_token_fingerprint).to be_present
end

context 'when authenticating without new device sign in' do
let(:user) { create(:user) }

context 'when alert aggregation feature is disabled' do
before do
allow(IdentityConfig.store).to receive(:feature_new_device_alert_aggregation_enabled).
and_return(false)
end

it 'does not send an alert' do
expect(UserAlerts::AlertUserAboutNewDevice).to_not receive(:send_alert)

result
end
end

context 'when alert aggregation feature is enabled' do
before do
allow(IdentityConfig.store).to receive(:feature_new_device_alert_aggregation_enabled).
and_return(true)
end

context 'with an existing device' do
before do
controller.user_session[:new_device] = false
end

it 'does not send an alert' do
expect(UserAlerts::AlertUserAboutNewDevice).to_not receive(:send_alert)

result
end
end

context 'with a new device' do
before do
controller.user_session[:new_device] = true
end

it 'sends the new device alert using 2fa event date' do
expect(UserAlerts::AlertUserAboutNewDevice).to receive(:send_alert) do |**args|
expect(user.reload.sign_in_new_device_at.change(usec: 0)).to eq(
args[:disavowal_event].created_at.change(usec: 0),
)
expect(args[:user]).to eq(user)
expect(args[:disavowal_event]).to be_kind_of(Event)
expect(args[:disavowal_token]).to be_kind_of(String)
end

result
end
end
end
end

context 'when authenticating with new device sign in' do
let(:user) { create(:user, sign_in_new_device_at: Time.zone.now) }

context 'when alert aggregation feature is disabled' do
before do
allow(IdentityConfig.store).to receive(:feature_new_device_alert_aggregation_enabled).
and_return(false)
end

it 'does not send an alert' do
expect(UserAlerts::AlertUserAboutNewDevice).to_not receive(:send_alert)

result
end
end

context 'when alert aggregation feature is enabled' do
before do
allow(IdentityConfig.store).to receive(:feature_new_device_alert_aggregation_enabled).
and_return(true)
end

context 'with an existing device' do
before do
controller.user_session[:new_device] = false
end

it 'does not send an alert' do
expect(UserAlerts::AlertUserAboutNewDevice).to_not receive(:send_alert)

result
end
end

context 'with a new device' do
before do
controller.user_session[:new_device] = true
end

it 'sends the new device alert' do
expect(UserAlerts::AlertUserAboutNewDevice).to receive(:send_alert).
with(user:, disavowal_event: kind_of(Event), disavowal_token: kind_of(String))

result
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
expect(@irs_attempts_api_tracker).to receive(:track_event).
with(:mfa_login_backup_code, success: true)

expect(controller).to receive(:handle_valid_verification_for_authentication_context).
with(auth_method: TwoFactorAuthenticatable::AuthMethod::BACKUP_CODE).
and_call_original

post :create, params: payload

expect(subject.user_session[:auth_events]).to eq(
Expand Down
Loading