18F/identity-idp

View on GitHub
app/controllers/application_controller.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
98%
# frozen_string_literal: true

class ApplicationController < ActionController::Base
  include VerifyProfileConcern
  include BackupCodeReminderConcern
  include LocaleHelper
  include VerifySpAttributesConcern
  include SecondMfaReminderConcern
  include TwoFactorAuthenticatableMethods

  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception

  rescue_from ActionController::Redirecting::UnsafeRedirectError, with: :unsafe_redirect_error
  rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_auth_token
  rescue_from ActionController::UnknownFormat, with: :render_not_found
  rescue_from ActionView::MissingTemplate, with: :render_not_acceptable
  [
    ActiveRecord::ConnectionTimeoutError,
    PG::ConnectionBad, # raised when a Postgres connection times out
    Rack::Timeout::RequestTimeoutException,
    Redis::BaseConnectionError,
  ].each do |error|
    rescue_from error, with: :render_timeout
  end

  helper_method :decorated_sp_session, :user_fully_authenticated?

  prepend_before_action :add_new_relic_trace_attributes
  prepend_before_action :session_expires_at
  prepend_before_action :set_locale
  before_action :disable_caching
  before_action :cache_issuer_in_cookie

  def session_expires_at
    return if @skip_session_expiration || @skip_session_load
    session[:session_started_at] = Time.zone.now if session[:session_started_at].nil?
    redirect_with_flash_if_timeout
  end

  # for lograge
  def append_info_to_payload(payload)
    return if Lograge.lograge_config.ignore_actions&.include?(
      "#{Lograge.controller_field(payload)}##{payload[:action]}",
    )

    payload[:user_id] = analytics_user.uuid unless @skip_session_load

    payload[:git_sha] = IdentityConfig::GIT_SHA
    if IdentityConfig::GIT_TAG.present?
      payload[:git_tag] = IdentityConfig::GIT_TAG
    else
      payload[:git_branch] = IdentityConfig::GIT_BRANCH
    end

    payload
  end

  attr_writer :analytics

  def analytics
    return @analytics if @analytics
    @analytics =
      Analytics.new(
        user: analytics_user,
        request: request,
        sp: current_sp&.issuer,
        session: session,
        ahoy: ahoy,
      )
  end

  def analytics_user
    current_user || AnonymousUser.new
  end

  def user_event_creator
    @user_event_creator ||= UserEventCreator.new(request: request, current_user: current_user)
  end
  delegate :create_user_event, :create_user_event_with_disavowal, to: :user_event_creator
  delegate :remember_device_default, to: :decorated_sp_session

  def decorated_sp_session
    @decorated_sp_session ||= ServiceProviderSessionCreator.new(
      sp: current_sp,
      view_context: view_context,
      sp_session: sp_session,
      service_provider_request: service_provider_request,
    ).create_session
  end

  def default_url_options
    { locale: locale_url_param, host: IdentityConfig.store.domain_name }
  end

  def sign_out(*args)
    request.cookie_jar.delete('ahoy_visit')
    super
  end

  def resolved_authn_context_result
    return @resolved_authn_context_result if defined?(@resolved_authn_context_result)

    service_provider = sp_from_sp_session
    if service_provider.nil?
      @resolved_authn_context_result = Vot::Parser::Result.no_sp_result
    else
      @resolved_authn_context_result = AuthnContextResolver.new(
        user: current_user,
        service_provider: service_provider,
        vtr: sp_session[:vtr],
        acr_values: sp_session[:acr_values],
      ).resolve
    end
  end

  def context
    user_session[:context] || UserSessionContext::AUTHENTICATION_CONTEXT
  end

  def current_sp
    @current_sp ||= sp_from_sp_session || sp_from_request_id
  end

  private

  # These attributes show up in New Relic traces for all requests.
  # https://docs.newrelic.com/docs/agents/manage-apm-agents/agent-data/collect-custom-attributes
  def add_new_relic_trace_attributes
    ::NewRelic::Agent.add_custom_attributes(amzn_trace_id: amzn_trace_id)
  end

  def amzn_trace_id
    request.headers['X-Amzn-Trace-Id']
  end

  def disable_caching
    response.headers[Rack::CACHE_CONTROL] = 'no-store'
    response.headers['pragma'] = 'no-cache'
  end

  def cache_issuer_in_cookie
    return if @skip_session_load
    cookies[:sp_issuer] = if current_sp.nil?
                            nil
                          else
                            {
                              value: current_sp.issuer,
                              expires: IdentityConfig.store.session_timeout_in_minutes.minutes,
                            }
                          end
  end

  def redirect_with_flash_if_timeout
    return unless params[:timeout]

    if params[:timeout] == 'session'
      analytics.session_timed_out
      flash[:info] = t(
        'notices.session_timedout',
        app_name: APP_NAME,
        minutes: IdentityConfig.store.session_timeout_in_minutes,
      )
    elsif current_user.blank?
      flash[:info] = t(
        'notices.session_cleared',
        minutes: IdentityConfig.store.session_timeout_in_minutes,
      )
    end

    begin
      redirect_to url_for(permitted_timeout_params)
    rescue ActionController::UrlGenerationError # Binary data in parameters throw on redirect
      head :bad_request
    end
  end

  def permitted_timeout_params
    params.permit(:request_id)
  end

  def sp_from_sp_session
    ServiceProvider.find_by(issuer: sp_session[:issuer]) if sp_session[:issuer].present?
  end

  def sp_from_request_id
    if service_provider_request.issuer.present?
      ServiceProvider.find_by(issuer: service_provider_request.issuer)
    end
  end

  def sp_from_request_issuer_logout
    return if action_name != 'logout'
    if saml_request&.service_provider&.identifier.present?
      ServiceProvider.find_by(issuer: saml_request.service_provider.identifier)
    end
  end

  def service_provider_request
    @service_provider_request ||= ServiceProviderRequestProxy.from_uuid(params[:request_id])
  end

  def fix_broken_personal_key_url
    flash[:info] = t('account.personal_key.needs_new')

    pii_unlocked = Pii::Cacher.new(current_user, user_session).exists_in_session?

    if pii_unlocked
      cacher = Pii::Cacher.new(current_user, user_session)
      profile = current_user.active_profile
      user_session[:personal_key] = profile.encrypt_recovery_pii(cacher.fetch(profile.id))
      profile.save!

      analytics.broken_personal_key_regenerated

      manage_personal_key_url
    else
      user_session[:needs_new_personal_key] = true

      capture_password_url
    end
  end

  def after_sign_in_path_for(_user)
    return rules_of_use_path if !current_user.accepted_rules_of_use_still_valid?
    return user_please_call_url if current_user.suspended?
    return user_password_compromised_url if session[:redirect_to_password_compromised].present?
    return authentication_methods_setup_url if user_needs_sp_auth_method_setup?
    return login_add_piv_cac_prompt_url if session[:needs_to_setup_piv_cac_after_sign_in].present?
    return fix_broken_personal_key_url if current_user.broken_personal_key?
    return user_session.delete(:stored_location) if user_session.key?(:stored_location)
    return reactivate_account_url if user_needs_to_reactivate_account?
    return login_piv_cac_recommended_path if user_recommended_for_piv_cac?
    return second_mfa_reminder_url if user_needs_second_mfa_reminder?
    return sp_session_request_url_with_updated_params if sp_session.key?(:request_url)
    signed_in_url
  end

  def signed_in_url
    return idv_verify_by_mail_enter_code_url if current_user.gpo_verification_pending_profile?
    return backup_code_reminder_url if user_needs_backup_code_reminder?
    account_path
  end

  def after_mfa_setup_path
    if needs_completion_screen_reason
      sign_up_completed_url
    elsif user_needs_to_reactivate_account?
      reactivate_account_url
    else
      session[:account_redirect_path] || after_sign_in_path_for(current_user)
    end
  end

  def user_needs_to_reactivate_account?
    return false if current_user.password_reset_profile.blank?
    return false if pending_profile_newer_than_password_reset_profile?
    resolved_authn_context_result.identity_proofing?
  end

  def user_recommended_for_piv_cac?
    current_user.piv_cac_recommended_dismissed_at.nil? && current_user.has_gov_or_mil_email? &&
      !user_already_has_piv?
  end

  def user_already_has_piv?
    MfaContext.new(current_user).piv_cac_configurations.present?
  end

  def pending_profile_newer_than_password_reset_profile?
    return false if current_user.pending_profile.blank?
    return false if current_user.password_reset_profile.blank?
    current_user.pending_profile.created_at >
      current_user.password_reset_profile.updated_at
  end

  def invalid_auth_token(_exception)
    controller_info = "#{controller_path}##{action_name}"
    analytics.invalid_authenticity_token(
      controller: controller_info,
      user_signed_in: user_signed_in?,
    )
    flash[:error] = t('errors.general')
    redirect_back fallback_location: new_user_session_url, allow_other_host: false
  end

  def unsafe_redirect_error(_exception)
    controller_info = "#{controller_path}##{action_name}"
    analytics.unsafe_redirect_error(
      controller: controller_info,
      user_signed_in: user_signed_in?,
      referer: request.referer,
    )

    flash[:error] = t('errors.general')
    redirect_to new_user_session_url
  end

  def user_fully_authenticated?
    user_signed_in? &&
      session['warden.user.user.session'] &&
      !session['warden.user.user.session'][TwoFactorAuthenticatable::NEED_AUTHENTICATION] &&
      two_factor_enabled?
  end

  def confirm_two_factor_authenticated
    authenticate_user!(force: true)

    if !two_factor_enabled?
      return prompt_to_setup_mfa
    elsif !user_fully_authenticated?
      return prompt_to_verify_mfa
    elsif service_provider_mfa_policy.user_needs_sp_auth_method_setup?
      return prompt_to_setup_mfa
    elsif service_provider_mfa_policy.user_needs_sp_auth_method_verification?
      return prompt_to_verify_sp_required_mfa
    end

    enforce_total_session_duration_timeout

    true
  end

  def enforce_total_session_duration_timeout
    return sign_out_with_timeout_error if session_total_duration_expired?
    ensure_user_session_has_created_at
  end

  def sign_out_with_timeout_error
    analytics.session_total_duration_timeout
    sign_out
    flash[:info] = t('devise.failure.timeout')
    redirect_to root_url
  end

  def ensure_user_session_has_created_at
    return if user_session.nil? || user_session[:created_at].present?
    user_session[:created_at] = Time.zone.now
  end

  def session_total_duration_expired?
    session_created_at = user_session&.dig(:created_at)
    return if session_created_at.blank?
    session_created_at = Time.zone.parse(session_created_at.to_s)
    timeout_in_minutes = IdentityConfig.store.session_total_duration_timeout_in_minutes.minutes
    (session_created_at + timeout_in_minutes) < Time.zone.now
  end

  def prompt_to_setup_mfa
    redirect_to authentication_methods_setup_url
  end

  def prompt_to_verify_mfa
    redirect_to user_two_factor_authentication_url
  end

  def prompt_to_verify_sp_required_mfa
    redirect_to sp_required_mfa_verification_url
  end

  def sp_required_mfa_verification_url
    return login_two_factor_piv_cac_url if service_provider_mfa_policy.piv_cac_required?

    if TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled? && !mobile?
      login_two_factor_piv_cac_url
    elsif TwoFactorAuthentication::WebauthnPolicy.new(current_user).platform_enabled?
      login_two_factor_webauthn_url(platform: true)
    elsif TwoFactorAuthentication::WebauthnPolicy.new(current_user).enabled?
      login_two_factor_webauthn_url
    else
      login_two_factor_piv_cac_url
    end
  end

  def two_factor_enabled?
    MfaPolicy.new(current_user).two_factor_enabled?
  end

  # Prevent the session from being written back to the session store at the end of the request.
  def skip_session_commit
    request.session_options[:skip] = true
  end

  def skip_session_expiration
    @skip_session_expiration = true
  end

  def skip_session_load
    skip_session_commit
    @skip_session_load = true
  end

  def set_locale
    I18n.locale = LocaleChooser.new(params[:locale], request).locale
  end

  def pii_requested_but_locked?
    if resolved_authn_context_result.identity_proofing? || resolved_authn_context_result.ialmax?
      current_user.identity_verified? &&
        !Pii::Cacher.new(current_user, user_session).exists_in_session?
    end
  end

  def mfa_policy
    @mfa_policy ||= MfaPolicy.new(current_user)
  end

  def service_provider_mfa_policy
    @service_provider_mfa_policy ||= ServiceProviderMfaPolicy.new(
      user: current_user,
      auth_methods_session:,
      resolved_authn_context_result:,
    )
  end
  delegate :user_needs_sp_auth_method_setup?, to: :service_provider_mfa_policy

  def sp_session
    session.fetch(:sp, {})
  end

  # Retrieves the current service provider session hash's logged request URL, if present
  # Conditionally sets the final_auth_request service provider session attribute
  # when applicable (the original SP request is SAML)
  def sp_session_request_url_with_updated_params
    return unless sp_session[:request_url].present?
    request_url = URI(sp_session[:request_url])
    url = if request_url.path.match?('saml')
            sp_session[:final_auth_request] = true
            complete_saml_url
          else
            sp_session[:request_url]
          end

    # If the user has changed the locale, we should preserve that as well
    if url && locale_url_param && UriService.params(url)[:locale] != locale_url_param
      UriService.add_params(url, locale: locale_url_param)
    else
      url
    end
  end

  def render_not_found
    respond_to do |format|
      format.json do
        render json: { error: "The page you were looking for doesn't exist" }, status: :not_found
      end
      format.any do
        render template: 'pages/page_not_found', layout: false, status: :not_found, formats: :html
      end
    end
  end

  def render_not_acceptable
    render template: 'pages/not_acceptable', layout: false, status: :not_acceptable, formats: :html
  end

  def render_timeout(exception)
    analytics.response_timed_out(**analytics_exception_info(exception))
    if exception.instance_of?(Rack::Timeout::RequestTimeoutException)
      NewRelic::Agent.notice_error(exception)
    end
    render template: 'pages/page_took_too_long',
           layout: false, status: :service_unavailable, formats: :html
  end

  def render_full_width(template, **opts)
    render template, **opts, layout: 'application'
  end

  def analytics_exception_info(exception)
    {
      backtrace: Rails.backtrace_cleaner.send(:filter, exception.backtrace),
      exception_message: exception.to_s,
      exception_class: exception.class.name,
    }
  end

  def mobile?
    BrowserCache.parse(request.user_agent).mobile?
  end

  def user_is_banned?
    return false unless user_signed_in?
    BannedUserResolver.new(current_user).banned_for_sp?(issuer: current_sp&.issuer)
  end

  def handle_banned_user
    return unless user_is_banned?
    analytics.banned_user_redirect
    sign_out
    redirect_to banned_user_url
  end
end