app/models/user.rb
# frozen_string_literal: true
class User < ApplicationRecord
include NonNullUuid
include ::NewRelic::Agent::MethodTracer
include ActionView::Helpers::DateHelper
devise(
:database_authenticatable,
:recoverable,
:registerable,
:timeoutable,
authentication_keys: [:email],
)
include EncryptableAttribute
# IMPORTANT this comes *after* devise() call.
include UserAccessKeyOverrides
include UserEncryptedAttributeOverrides
include DeprecatedUserAttributes
include UserOtpMethods
MAX_RECENT_EVENTS = 5
MAX_RECENT_DEVICES = 5
enum otp_delivery_preference: { sms: 0, voice: 1 }
# rubocop:disable Rails/HasManyOrHasOneDependent
# identities need to be orphaned to prevent UUID reuse
has_many :identities, class_name: 'ServiceProviderIdentity'
has_many :events # we are retaining events after delete
has_many :devices # we are retaining devices after delete
# rubocop:enable Rails/HasManyOrHasOneDependent
has_many :agency_identities, dependent: :destroy
has_many :profiles, dependent: :destroy
has_one :account_reset_request, dependent: :destroy
has_many :phone_configurations, dependent: :destroy, inverse_of: :user
has_many :email_addresses, dependent: :destroy, inverse_of: :user
has_many :webauthn_configurations, dependent: :destroy, inverse_of: :user
has_many :piv_cac_configurations, dependent: :destroy, inverse_of: :user
has_many :auth_app_configurations, dependent: :destroy, inverse_of: :user
has_many :backup_code_configurations, dependent: :destroy
has_many :document_capture_sessions, dependent: :destroy
has_one :registration_log, dependent: :destroy
has_one :proofing_component, dependent: :destroy
has_many :service_providers,
through: :identities,
source: :service_provider_record
has_many :sign_in_restrictions, dependent: :destroy
has_many :in_person_enrollments, dependent: :destroy
has_many :fraud_review_requests, dependent: :destroy
has_many :gpo_confirmation_codes, through: :profiles
has_one :pending_in_person_enrollment,
-> { where(status: :pending).order(created_at: :desc) },
class_name: 'InPersonEnrollment', foreign_key: :user_id, inverse_of: :user,
dependent: :destroy
has_one :establishing_in_person_enrollment,
-> { where(status: :establishing).order(created_at: :desc) },
class_name: 'InPersonEnrollment', foreign_key: :user_id, inverse_of: :user,
dependent: :destroy
attr_accessor :asserted_attributes, :email
def confirmed_email_addresses
email_addresses.where.not(confirmed_at: nil).order('last_sign_in_at DESC NULLS LAST')
end
def fully_registered?
!!registration_log&.registered_at
end
def confirmed?
email_addresses.where.not(confirmed_at: nil).any?
end
def has_fed_or_mil_email?
confirmed_email_addresses.any?(&:fed_or_mil_email?)
end
def accepted_rules_of_use_still_valid?
if self.accepted_terms_at.present?
self.accepted_terms_at > IdentityConfig.store.rules_of_use_updated_at &&
self.accepted_terms_at > IdentityConfig.store.rules_of_use_horizon_years.years.ago
end
end
def set_reset_password_token
super
end
def last_identity
identities.where.not(session_uuid: nil).order(last_authenticated_at: :desc).take ||
NullIdentity.new
end
def active_identities
identities.where('session_uuid IS NOT ?', nil).order(last_authenticated_at: :asc) || []
end
def active_profile?
active_profile.present?
end
def active_profile
return @active_profile if defined?(@active_profile) && @active_profile&.active
@active_profile = profiles.verified.find(&:active?)
end
def pending_profile?
pending_profile.present?
end
def gpo_verification_pending_profile?
gpo_verification_pending_profile.present?
end
def suspended?
suspended_at.to_s > reinstated_at.to_s
end
def reinstated?
reinstated_at.to_s > suspended_at.to_s
end
def suspend!
if suspended?
analytics.user_suspended(success: false, error_message: :user_already_suspended)
raise 'user_already_suspended'
end
OutOfBandSessionAccessor.new(unique_session_id).destroy if unique_session_id
update!(suspended_at: Time.zone.now, unique_session_id: nil)
analytics.user_suspended(success: true)
event = PushNotification::AccountDisabledEvent.new(user: self)
PushNotification::HttpPush.deliver(event)
email_addresses.map do |email_address|
SuspendedEmail.create_from_email_address!(email_address)
end
end
def reinstate!
if !suspended?
analytics.user_reinstated(success: false, error_message: :user_is_not_suspended)
raise 'user_is_not_suspended'
end
update!(reinstated_at: Time.zone.now)
analytics.user_reinstated(success: true)
event = PushNotification::AccountEnabledEvent.new(user: self)
PushNotification::HttpPush.deliver(event)
email_addresses.map do |email_address|
SuspendedEmail.find_with_email(email_address.email)&.destroy
end
send_email_to_all_addresses(:account_reinstated)
end
def pending_profile
return @pending_profile if defined?(@pending_profile) && !@pending_profile&.active
@pending_profile = begin
pending = profiles.in_person_verification_pending.or(
profiles.gpo_verification_pending,
).or(
profiles.fraud_review_pending,
).or(
profiles.fraud_rejection,
).order(created_at: :desc).first
if pending.blank?
nil
elsif pending.password_reset? || pending.encryption_error? || pending.verification_cancelled?
# Profiles that are cancelled for reasons that do not require further verification steps
# are not pending profiles
nil
elsif active_profile.present? && active_profile.activated_at > pending.created_at
# If there is an active profile that is older than this pending profile that means the user
# has proofed since this profile was created. That profile takes precedence and there is no
# pending profile
nil
else
pending
end
end
end
def gpo_verification_pending_profile
pending_profile if pending_profile&.gpo_verification_pending?
end
def fraud_review_pending?
fraud_review_pending_profile.present?
end
def fraud_rejection?
fraud_rejection_profile.present?
end
def fraud_review_pending_profile
pending_profile if pending_profile&.fraud_review_pending?
end
def fraud_rejection_profile
pending_profile if pending_profile&.fraud_rejection?
end
def in_person_pending_profile?
in_person_pending_profile.present?
end
def in_person_pending_profile
pending_profile if pending_profile&.in_person_verification_pending?
end
##
# Return the status of the current In Person Proofing Enrollment
# @return [String] enrollment status
def in_person_enrollment_status
pending_profile&.in_person_enrollment&.status
end
def ipp_enrollment_status_not_passed?
!in_person_enrollment_status.blank? &&
in_person_enrollment_status != 'passed'
end
def has_in_person_enrollment?
pending_in_person_enrollment.present? || establishing_in_person_enrollment.present?
end
# @return [Boolean] Whether the user has an establishing in person enrollment.
def has_establishing_in_person_enrollment?
establishing_in_person_enrollment.present?
end
# Trust `pending_profile` rather than enrollment associations
def has_establishing_in_person_enrollment_safe?
!!pending_profile&.in_person_enrollment&.establishing?
end
def personal_key_generated_at
encrypted_recovery_code_digest_generated_at ||
active_profile&.verified_at ||
profiles.verified.order(activated_at: :desc).first&.verified_at
end
def default_phone_configuration
phone_configurations.order('made_default_at DESC NULLS LAST, created_at').first
end
##
# @param [String] issuer
# @return [Boolean] Whether the user should receive a survey for completing in-person proofing
def should_receive_in_person_completion_survey?(issuer)
Idv::InPersonConfig.enabled_for_issuer?(issuer) &&
in_person_enrollments.
where(issuer: issuer, status: :passed).order(created_at: :desc).
pick(:follow_up_survey_sent) == false
end
##
# Record that the in-person proofing survey was sent
# @param [String] issuer
def mark_in_person_completion_survey_sent(issuer)
enrollment_id, follow_up_survey_sent = in_person_enrollments.
where(issuer: issuer, status: :passed).
order(created_at: :desc).
pick(:id, :follow_up_survey_sent)
if follow_up_survey_sent == false
# Enrollment record is present and survey was not previously sent
InPersonEnrollment.update(enrollment_id, follow_up_survey_sent: true)
end
nil
end
def increment_second_factor_attempts_count!
User.transaction do
sql = <<~SQL
UPDATE users
SET
second_factor_attempts_count = COALESCE(second_factor_attempts_count, 0) + 1,
updated_at = NOW(),
second_factor_locked_at = CASE
WHEN COALESCE(second_factor_attempts_count, 0) + 1 >= ?
THEN NOW()
ELSE NULL
END
WHERE id = ?
RETURNING second_factor_attempts_count, second_factor_locked_at;
SQL
query = User.sanitize_sql_array(
[sql,
IdentityConfig.store.login_otp_confirmation_max_attempts, self.id],
)
result = User.connection.execute(query).first
self.second_factor_attempts_count = result.fetch('second_factor_attempts_count')
self.second_factor_locked_at = result.fetch('second_factor_locked_at')
self.clear_attribute_changes([:second_factor_attempts_count, :second_factor_locked_at])
end
nil
end
MINIMUM_LIKELY_ENCRYPTED_DATA_LENGTH = 1000
def broken_personal_key?
window_start = IdentityConfig.store.broken_personal_key_window_start
window_finish = IdentityConfig.store.broken_personal_key_window_finish
last_personal_key_at = self.encrypted_recovery_code_digest_generated_at
if active_profile.present?
encrypted_pii_too_short =
active_profile.encrypted_pii_recovery.present? &&
active_profile.encrypted_pii_recovery.length < MINIMUM_LIKELY_ENCRYPTED_DATA_LENGTH
inside_broken_key_window =
(!last_personal_key_at || last_personal_key_at < window_finish) &&
(window_start..window_finish).cover?(active_profile.verified_at)
encrypted_pii_too_short || inside_broken_key_window
else
false
end
end
# To send emails asynchronously via ActiveJob.
def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_now_or_later
end
#
# Decoration methods
#
def email_language_preference_description
if I18n.locale_available?(email_language)
# i18n-tasks-use t('account.email_language.name.en')
# i18n-tasks-use t('account.email_language.name.es')
# i18n-tasks-use t('account.email_language.name.fr')
I18n.t("account.email_language.name.#{email_language}")
else
I18n.t('account.email_language.name.en')
end
end
def visible_email_addresses
email_addresses.filter do |email_address|
email_address.confirmed? || !email_address.confirmation_period_expired?
end
end
def lockout_time_expiration
second_factor_locked_at + lockout_period
end
def active_identity_for(service_provider)
active_identities.find_by(service_provider: service_provider.issuer)
end
def active_or_pending_profile
active_profile || pending_profile
end
def identity_not_verified?
!identity_verified?
end
def identity_verified?
active_profile.present?
end
def identity_verified_with_facial_match?
active_profile.present? && active_profile.facial_match?
end
# This user's most recently activated profile that has also been deactivated
# due to a password reset, or nil if there is no such profile
def password_reset_profile
profile = profiles.where.not(activated_at: nil).order(activated_at: :desc).first
profile if profile&.password_reset?
end
def qrcode(otp_secret_key)
options = {
issuer: APP_NAME,
otp_secret_key: otp_secret_key,
digits: TwoFactorAuthenticatable::OTP_LENGTH,
interval: IdentityConfig.store.totp_code_interval,
}
url = ROTP::TOTP.new(otp_secret_key, options).provisioning_uri(
EmailContext.new(self).last_sign_in_email_address.email,
)
qrcode = RQRCode::QRCode.new(url)
qrcode.as_png(size: 240).to_data_url
end
def locked_out?
second_factor_locked_at.present? && !lockout_period_expired?
end
def no_longer_locked_out?
second_factor_locked_at.present? && lockout_period_expired?
end
def recent_events
events = Event.where(user_id: id).order('created_at DESC').limit(MAX_RECENT_EVENTS).
map(&:decorate)
(events + identity_events).sort_by(&:happened_at).reverse
end
def identity_events
identities.includes(:service_provider_record).order('last_authenticated_at DESC')
end
def recent_devices
@recent_devices ||= devices.order(last_used_at: :desc).limit(MAX_RECENT_DEVICES).
map(&:decorate)
end
def has_devices?
!recent_devices.empty?
end
def authenticated_device?(cookie_uuid:)
return false if cookie_uuid.blank?
devices.joins(:events).exists?(
cookie_uuid:,
events: { event_type: [:account_created, :sign_in_after_2fa] },
)
end
# Returns the number of times the user has signed in, corresponding to the `sign_in_before_2fa`
# event.
#
# A `since` time argument is required, to optimize performance based on database indices for
# querying a user's events.
#
# @param [ActiveSupport::TimeWithZone] since Time window to query user's events
def sign_in_count(since:)
events.where(event_type: :sign_in_before_2fa).where(created_at: since..).count
end
def second_last_signed_in_at
events.where(event_type: 'sign_in_after_2fa').
order(created_at: :desc).limit(2).pluck(:created_at).second
end
def connected_apps
identities.not_deleted.order('created_at DESC')
end
def delete_account_bullet_key
if identity_verified?
I18n.t('users.delete.bullet_2_verified', app_name: APP_NAME)
else
I18n.t('users.delete.bullet_2_basic', app_name: APP_NAME)
end
end
# End moved from UserDecorator
# Devise automatically downcases and strips any attribute defined in
# config.case_insensitive_keys and config.strip_whitespace_keys via
# before_validation callbacks. Email is included by default, which means that
# every time the User model is saved, even if the email wasn't updated, a DB
# call will be made to downcase and strip the email.
# To avoid these unnecessary DB calls, we've set case_insensitive_keys and
# strip_whitespace_keys to empty arrays in config/initializers/devise.rb.
# In addition, we've overridden the downcase_keys and strip_whitespace
# methods below to do nothing.
#
# Note that we already downcase and strip emails, and only when necessary
# (i.e. when the email attribute is being created or updated, and when a user
# is entering an email address in a form). This is the proper way to handle
# this formatting, as opposed to via a model callback that performs this
# action regardless of whether or not it is needed. Search the codebase for
# ".downcase.strip" for examples.
def downcase_keys
# no-op
end
def strip_whitespace
# no-op
end
# In order to pass in the SP request_id to the confirmation instructions
# email, we need to define `send_custom_confirmation_instructions` because
# Devise's `send_confirmation_instructions` does not include arguments.
# We also need to override the Devise method to do nothing because this method
# is called automatically when a user is created due to a Devise callback.
# If we didn't disable it, the user would receive two confirmation emails.
def send_confirmation_instructions
# no-op
end
add_method_tracer :send_devise_notification, "Custom/#{name}/send_devise_notification"
def analytics
@analytics ||= Analytics.new(user: self, request: nil, session: {}, sp: nil)
end
def send_email_to_all_addresses(user_mailer_template)
confirmed_email_addresses.each do |email_address|
UserMailer.with(
user: self,
email_address: email_address,
).send(user_mailer_template).
deliver_now_or_later
end
end
def reload(...)
remove_instance_variable(:@pending_profile) if defined?(@pending_profile)
super(...)
end
private
def lockout_period
IdentityConfig.store.lockout_period_in_minutes.minutes
end
def lockout_period_expired?
lockout_time_expiration < Time.zone.now
end
end