diff --git a/.reek.yml b/.reek.yml index 9f3f1f0b064..4fdebd00edf 100644 --- a/.reek.yml +++ b/.reek.yml @@ -82,6 +82,7 @@ detectors: - Analytics TooManyInstanceVariables: exclude: + - BaseFlow - OpenidConnectAuthorizeForm - OpenidConnectRedirector - Idv::VendorResult diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb new file mode 100644 index 00000000000..c2da610a29e --- /dev/null +++ b/app/controllers/idv/doc_auth_controller.rb @@ -0,0 +1,15 @@ +module Idv + class DocAuthController < ApplicationController + include IdvSession # remove if we retire the non docauth LOA3 flow + include Flow::FlowStateMachine + + FSM_SETTINGS = { + step_url: :idv_doc_auth_step_url, + final_url: :idv_review_url, + flow: Idv::Flows::DocAuthFlow, + analytics_id: Analytics::DOC_AUTH, + }.freeze + + before_action :confirm_two_factor_authenticated + end +end diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index 6e90c85ebe0..9093459931b 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -11,6 +11,8 @@ def index redirect_to idv_activated_url elsif idv_attempter.exceeded? redirect_to idv_fail_url + elsif doc_auth_enabled_and_exclusive? + redirect_to idv_doc_auth_url else analytics.track_event(Analytics::IDV_INTRO_VISIT) redirect_to idv_jurisdiction_url @@ -42,4 +44,11 @@ def profile_needs_reactivation? def active_profile? current_user.active_profile.present? end + + def doc_auth_enabled_and_exclusive? + # exclusive mode replaces the existing LOA3 flow with the doc auth flow + # non-exclusive mode allows both flows to co-exist + # in non-exclusive mode you enter the /verify/doc_auth path in the browser + FeatureManagement.doc_auth_enabled? && FeatureManagement.doc_auth_exclusive? + end end diff --git a/app/forms/idv/image_upload_form.rb b/app/forms/idv/image_upload_form.rb new file mode 100644 index 00000000000..74bd6f87b11 --- /dev/null +++ b/app/forms/idv/image_upload_form.rb @@ -0,0 +1,38 @@ +module Idv + class ImageUploadForm + include ActiveModel::Model + + validates :image, presence: true + + ATTRIBUTES = [:image].freeze + + attr_accessor :image + + def self.model_name + ActiveModel::Name.new(self, nil, 'Image') + end + + def initialize(user) + @user = user + end + + def submit(params) + consume_params(params) + + FormResponse.new(success: valid?, errors: errors.messages) + end + + private + + def consume_params(params) + params.each do |key, value| + raise_invalid_image_parameter_error(key) unless ATTRIBUTES.include?(key.to_sym) + send("#{key}=", value) + end + end + + def raise_invalid_image_parameter_error(key) + raise ArgumentError, "#{key} is an invalid image attribute" + end + end +end diff --git a/app/forms/idv/ssn_form.rb b/app/forms/idv/ssn_form.rb new file mode 100644 index 00000000000..524df7bd74b --- /dev/null +++ b/app/forms/idv/ssn_form.rb @@ -0,0 +1,37 @@ +module Idv + class SsnForm + include ActiveModel::Model + include FormSsnValidator + + ATTRIBUTES = [:ssn].freeze + + attr_accessor :ssn + + def self.model_name + ActiveModel::Name.new(self, nil, 'Ssn') + end + + def initialize(user) + @user = user + end + + def submit(params) + consume_params(params) + + FormResponse.new(success: valid?, errors: errors.messages) + end + + private + + def consume_params(params) + params.each do |key, value| + raise_invalid_ssn_parameter_error(key) unless ATTRIBUTES.include?(key.to_sym) + send("#{key}=", value) + end + end + + def raise_invalid_ssn_parameter_error(key) + raise ArgumentError, "#{key} is an invalid ssn attribute" + end + end +end diff --git a/app/javascript/packs/doc-auth.js b/app/javascript/packs/doc-auth.js new file mode 100644 index 00000000000..556677ac53c --- /dev/null +++ b/app/javascript/packs/doc-auth.js @@ -0,0 +1,56 @@ +function docAuth() { + const player = document.getElementById('player'); + const canvas = document.getElementById('canvas'); + const context = canvas.getContext('2d'); + const captureButton = document.getElementById('capture'); + const input = document.getElementById('_doc_auth_image'); + + const constraints = { + video: true, + }; + + const state = { + video: true, + }; + + function captureImage() { + // Draw the video frame to the canvas. + context.drawImage(player, 0, 0, player.width, player.height); + input.value = canvas.toDataURL('image/png', 1.0); + player.style.display = 'none'; + canvas.style.display = 'inline-block'; + captureButton.innerHTML = 'X'; + player.srcObject.getVideoTracks().forEach(track => track.stop()); + player.srcObject = null; + } + + function startVideo() { + // Attach the video stream to the video element and autoplay. + navigator.mediaDevices.getUserMedia(constraints) + .then((stream) => { + player.srcObject = stream; + }); + } + + function resetImage() { + startVideo(); + canvas.style.display = 'none'; + player.style.display = 'inline-block'; + captureButton.innerHTML = 'Capture'; + input.value = ''; + context.clearRect(0, 0, canvas.width, canvas.height); + } + + captureButton.addEventListener('click', () => { + if (state.video) { + captureImage(); + } else { + resetImage(); + } + state.video = !state.video; + }); + + startVideo(); +} + +document.addEventListener('DOMContentLoaded', docAuth); diff --git a/app/models/doc_auth.rb b/app/models/doc_auth.rb new file mode 100644 index 00000000000..0236e1463b1 --- /dev/null +++ b/app/models/doc_auth.rb @@ -0,0 +1,4 @@ +class DocAuth < ApplicationRecord + belongs_to :user, inverse_of: :doc_auth + validates :user_id, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 23c68a60306..fd7a09f4723 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -46,6 +46,7 @@ class User < ApplicationRecord has_many :phone_configurations, dependent: :destroy, inverse_of: :user has_one :email_address, dependent: :destroy, inverse_of: :user has_many :webauthn_configurations, dependent: :destroy, inverse_of: :user + has_one :doc_auth, dependent: :destroy, inverse_of: :user validates :x509_dn_uuid, uniqueness: true, allow_nil: true diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 7bd725a87bb..8983a469d6e 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -58,6 +58,7 @@ def browser ACCOUNT_DELETION = 'Account Deletion Requested'.freeze ACCOUNT_RESET_VISIT = 'Account deletion and reset visited'.freeze ACCOUNT_VISIT = 'Account Page Visited'.freeze + DOC_AUTH = 'Doc Auth'.freeze # visited or submitted is appended EMAIL_AND_PASSWORD_AUTH = 'Email and Password Authentication'.freeze EMAIL_CHANGE_REQUEST = 'Email Change Request'.freeze IDV_BASIC_INFO_VISIT = 'IdV: basic info visited'.freeze diff --git a/app/services/flow/base_flow.rb b/app/services/flow/base_flow.rb new file mode 100644 index 00000000000..c74187c0e57 --- /dev/null +++ b/app/services/flow/base_flow.rb @@ -0,0 +1,43 @@ +module Flow + class BaseFlow + attr_accessor :flow_session + attr_reader :steps, :actions, :current_user, :params + + def initialize(steps, actions, session, current_user) + @current_user = current_user + @steps = steps.with_indifferent_access + @actions = actions.with_indifferent_access + @params = nil + @flow_session = session + end + + def next_step + step, _klass = steps.detect do |_step, klass| + !@flow_session[klass.to_s] + end + step + end + + def handle(step, params) + @flow_session[:error_message] = nil + handler = steps[step] || actions[step] + return failure("Unhandled step #{step}") unless handler + @params = params + wrap_send(handler) + end + + private + + def wrap_send(handler) + obj = handler.new(self) + value = obj.base_call + form_response(obj, value) + end + + def form_response(obj, value) + response = value.class == FormResponse ? value : FormResponse.new(success: true, errors: {}) + obj.mark_step_complete if response.success? + response + end + end +end diff --git a/app/services/flow/base_step.rb b/app/services/flow/base_step.rb new file mode 100644 index 00000000000..7d835a3f744 --- /dev/null +++ b/app/services/flow/base_step.rb @@ -0,0 +1,45 @@ +module Flow + class BaseStep + def initialize(context, name) + @context = context + @form_response = nil + @name = name + end + + def base_call + form_response = form_submit + return form_response unless form_response.success? + call + end + + def mark_step_complete(step = nil) + klass = step.nil? ? self.class : steps[step] + flow_session[klass.to_s] = true + end + + private + + def form_submit + FormResponse.new(success: true, errors: {}) + end + + def failure(message) + flow_session[:error_message] = message + FormResponse.new(success: false, errors: { message: message }) + end + + def flow_params + params[@name] + end + + def permit(*args) + params.require(@name).permit(*args) + end + + def reset + @context.flow_session = {} + end + + delegate :flow_session, :current_user, :params, :steps, to: :@context + end +end diff --git a/app/services/flow/flow_state_machine.rb b/app/services/flow/flow_state_machine.rb new file mode 100644 index 00000000000..4c2e89eda9e --- /dev/null +++ b/app/services/flow/flow_state_machine.rb @@ -0,0 +1,90 @@ +module Flow + module FlowStateMachine + extend ActiveSupport::Concern + + included do + before_action :fsm_initialize + before_action :ensure_correct_step, only: :show + end + + attr_accessor :flow + + def index + redirect_to_step(flow.next_step) + end + + def show + step = params[:step] + analytics.track_event(analytics_visited, step: step) if @analytics_id + render_step(step, flow.flow_session) + end + + def update + step = params[:step] + result = flow.handle(step, params) + analytics.track_event(analytics_submitted, result.to_h.merge(step: step)) if @analytics_id + render_update(step, result) + end + + private + + def fsm_initialize + klass = self.class + @name = klass.name.underscore.gsub('_controller', '') + klass::FSM_SETTINGS.each { |key, value| instance_variable_set("@#{key}", value) } + user_session[@name] ||= {} + @flow = @flow.new(user_session, current_user, @name) + end + + def render_update(step, result) + flow_finish and return unless flow.next_step + move_to_next_step and return if result.success? + flow_session = flow.flow_session + flow_session[:error_message] = result.errors.values.join(' ') + render_step(step, flow_session) + end + + def move_to_next_step + user_session[@name] = flow.flow_session + redirect_to_step(flow.next_step) + end + + def render_step(step, flow_session) + render template: "#{@name}/#{step}", locals: { flow_session: flow_session } + end + + def ensure_correct_step + next_step = flow.next_step + redirect_to_step(next_step) if next_step.to_s != params[:step] + end + + def flow_finish + redirect_to send(@final_url) + end + + def redirect_to_step(step) + redirect_to send(@step_url, step: step) + end + + def analytics_submitted + @analytics_id + ' submitted' + end + + def analytics_visited + @analytics_id + ' visited' + end + end +end + +# sample usage: +# +# class FooController +# include Flow::FlowStateMachine +# +# FSM_SETTINGS = { +# step_url: :foo_step_url, +# final_url: :after_foo_url, +# flow: FooFlow, +# analytics_id: Analytics::FOO, +# }.freeze +# end diff --git a/app/services/idv/actions/reset_action.rb b/app/services/idv/actions/reset_action.rb new file mode 100644 index 00000000000..c91bc7a9662 --- /dev/null +++ b/app/services/idv/actions/reset_action.rb @@ -0,0 +1,9 @@ +module Idv + module Actions + class ResetAction < Idv::Steps::DocAuthBaseStep + def call + reset + end + end + end +end diff --git a/app/services/idv/acuant/assure_id.rb b/app/services/idv/acuant/assure_id.rb new file mode 100644 index 00000000000..f91134f044a --- /dev/null +++ b/app/services/idv/acuant/assure_id.rb @@ -0,0 +1,107 @@ +module Idv + module Acuant + class AssureId + include Idv::Acuant::Http + + base_uri Figaro.env.acuant_assure_id_url + + FRONT = 0 + BACK = 1 + + attr_accessor :instance_id + + def initialize(cfg = default_cfg) + @subscription_id = cfg.fetch(:subscription_id) + @authentication_params = cfg.slice(:username, :password) + @instance_id = nil + end + + def create_document + url = '/AssureIDService/Document/Instance' + + options = default_options.merge( + headers: content_type_json, + body: image_params + ) + + status, @instance_id = post(url, options) { |body| body.delete('"') } + [status, @instance_id] + end + + def post_front_image(image) + post_image(image, FRONT) + end + + def post_back_image(image) + post_image(image, BACK) + end + + def results + url = "/AssureIDService/Document/#{instance_id}" + + options = default_options.merge( + headers: accept_json + ) + + get(url, options, &JSON.method(:parse)) + end + + def face_image + url = "/AssureIDService/Document/#{instance_id}/Field/Image?key=Photo" + + get(url, default_options) + end + + private + + def post_image(image, side) + url = "/AssureIDService/Document/#{instance_id}/Image?side=#{side}&light=0" + + options = default_options.merge( + headers: accept_json, + body: image + ) + + post(url, options) + end + + def image_params + { + AuthenticationSensitivity: 0, # normal + ClassificationMode: 0, # automatic + Device: device_params, + ImageCroppingExpectedSize: '1', # id + ImageCroppingMode: '1', # automatic + ManualDocumentType: nil, + ProcessMode: 0, # default + SubscriptionId: @subscription_id, + }.to_json + end + + def device_params + { + HasContactlessChipReader: false, + HasMagneticStripeReader: false, + SerialNumber: 'xxx', + Type: { + Manufacturer: 'Login.gov', + Model: 'Doc Auth 1.0', + SensorType: '3' # mobile + }, + } + end + + def default_cfg + { + subscription_id: env.acuant_assure_id_subscription_id, + username: env.acuant_assure_id_username, + password: env.acuant_assure_id_password, + } + end + + def default_options + { basic_auth: @authentication_params } + end + end + end +end diff --git a/app/services/idv/acuant/facial_match.rb b/app/services/idv/acuant/facial_match.rb new file mode 100644 index 00000000000..4171a76fdf7 --- /dev/null +++ b/app/services/idv/acuant/facial_match.rb @@ -0,0 +1,38 @@ +module Idv + module Acuant + class FacialMatch + include Idv::Acuant::Http + + base_uri Figaro.env.acuant_facial_match_url + + def initialize(cfg = default_cfg) + @license_key = Base64.encode64(cfg.fetch(:license_key)) + end + + def call(id_image, self_image) + url = '/FacialMatch' + + options = { + headers: headers, + body: { idFaceImage: id_image, selfieImage: self_image }, + } + + post(url, options, &JSON.method(:parse)) + end + + private + + def default_cfg + { license_key: env.acuant_facial_match_license_key } + end + + def headers + accept_json.merge(license_key_auth) + end + + def license_key_auth + { 'Authorization' => "LicenseKey #{@license_key}" } + end + end + end +end diff --git a/app/services/idv/acuant/http.rb b/app/services/idv/acuant/http.rb new file mode 100644 index 00000000000..b23a6d101dc --- /dev/null +++ b/app/services/idv/acuant/http.rb @@ -0,0 +1,48 @@ +module Idv + module Acuant + module Http + extend ActiveSupport::Concern + + included do + include HTTParty + end + + def get(url, options, &block) + handle_response(self.class.get(url, options), block) + end + + def post(url, options, &block) + handle_response(self.class.post(url, options), block) + end + + private + + def handle_response(response, block) + return [false, response.message] unless success?(response) + handle_success(response, block) + end + + def handle_success(response, block) + body = response.body + data = block ? block.call(body) : body + [true, data] + end + + def success?(response) + response.code.between?(200, 299) + end + + def accept_json + { 'Accept' => 'application/json' } + end + + def content_type_json + { 'Content-Type' => 'application/json' } + end + + def env + Figaro.env + end + end + end +end diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb new file mode 100644 index 00000000000..731b785327c --- /dev/null +++ b/app/services/idv/flows/doc_auth_flow.rb @@ -0,0 +1,29 @@ +module Idv + module Flows + class DocAuthFlow < Flow::BaseFlow + STEPS = { + ssn: Idv::Steps::SsnStep, + front_image: Idv::Steps::FrontImageStep, + back_image: Idv::Steps::BackImageStep, + doc_failed: Idv::Steps::DocFailedStep, + doc_success: Idv::Steps::DocSuccessStep, + self_image: Idv::Steps::SelfImageStep, + }.freeze + + ACTIONS = { + reset: Idv::Actions::ResetAction, + }.freeze + + attr_reader :idv_session # this is needed to support (and satisfy) the current LOA3 flow + + def initialize(session, current_user, name) + @idv_session = self.class.session_idv(session) + super(STEPS, ACTIONS, session[name], current_user) + end + + def self.session_idv(session) + session[:idv] ||= { params: {}, step_attempts: { phone: 0 } } + end + end + end +end diff --git a/app/services/idv/steps/back_image_step.rb b/app/services/idv/steps/back_image_step.rb new file mode 100644 index 00000000000..920ad6478dc --- /dev/null +++ b/app/services/idv/steps/back_image_step.rb @@ -0,0 +1,74 @@ +module Idv + module Steps + class BackImageStep < DocAuthBaseStep + def call + good, data = assure_id.post_back_image(image.read) + return failure(data) unless good + + failure_data, data = verify_back_image + return failure_data if failure_data + + extract_pii_from_doc_and_perform_resolution(data) + end + + private + + def form_submit + Idv::ImageUploadForm.new(current_user).submit(permit(:image)) + end + + def extract_pii_from_doc_and_perform_resolution(data) + pii_from_doc = Idv::Utils::PiiFromDoc.new(data). + call(flow_session[:ssn], current_user.phone_configurations.first.phone) + result = perform_resolution(pii_from_doc) + if result.success? + step_successful(pii_from_doc) + else + flow_session[:matcher_pii_from_doc] = pii_from_doc + end + end + + def step_successful(pii_from_doc) + mark_step_complete(:doc_failed) # skip doc failed + save_legacy_state(pii_from_doc) + end + + def save_legacy_state(pii_from_doc) + skip_legacy_steps + idv_session['params'] = pii_from_doc + idv_session['applicant'] = pii_from_doc + idv_session['applicant']['uuid'] = current_user.uuid + end + + def skip_legacy_steps + idv_session['profile_confirmation'] = true + idv_session['vendor_phone_confirmation'] = true + idv_session['user_phone_confirmation'] = true + idv_session['address_verification_mechanism'] = 'phone' + idv_session['resolution_successful'] = 'phone' + end + + def perform_resolution(pii_from_doc) + idv_result = Idv::Agent.new(pii_from_doc).proof(:resolution) + FormResponse.new( + success: idv_result[:success], errors: idv_result[:errors] + ) + end + + def verify_back_image + back_image_verified, data = assure_id.results + return failure(data) unless back_image_verified + + return [nil, data] if data['Result'] == 1 + + failure_alerts(data) + end + + def failure_alerts(data) + failure(data['Alerts']. + reject { |res| res['Result'] == 2 }. + map { |act| act['Actions'] }) + end + end + end +end diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb new file mode 100644 index 00000000000..6228c43ae88 --- /dev/null +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -0,0 +1,24 @@ +module Idv + module Steps + class DocAuthBaseStep < Flow::BaseStep + def initialize(context) + @assure_id = nil + super(context, :doc_auth) + end + + private + + def image + flow_params[:image] + end + + def assure_id + @assure_id ||= Idv::Acuant::AssureId.new + @assure_id.instance_id = flow_session[:instance_id] + @assure_id + end + + delegate :idv_session, to: :@context + end + end +end diff --git a/app/services/idv/steps/doc_failed_step.rb b/app/services/idv/steps/doc_failed_step.rb new file mode 100644 index 00000000000..b5c35a4d42e --- /dev/null +++ b/app/services/idv/steps/doc_failed_step.rb @@ -0,0 +1,6 @@ +module Idv + module Steps + class DocFailedStep < DocAuthBaseStep + end + end +end diff --git a/app/services/idv/steps/doc_success_step.rb b/app/services/idv/steps/doc_success_step.rb new file mode 100644 index 00000000000..d7c5f6949cd --- /dev/null +++ b/app/services/idv/steps/doc_success_step.rb @@ -0,0 +1,7 @@ +module Idv + module Steps + class DocSuccessStep < DocAuthBaseStep + def call; end + end + end +end diff --git a/app/services/idv/steps/front_image_step.rb b/app/services/idv/steps/front_image_step.rb new file mode 100644 index 00000000000..987eb693ade --- /dev/null +++ b/app/services/idv/steps/front_image_step.rb @@ -0,0 +1,24 @@ +module Idv + module Steps + class FrontImageStep < DocAuthBaseStep + def call + success, instance_id_or_message = assure_id.create_document + return failure(instance_id_or_message) unless success + + flow_session[:instance_id] = instance_id_or_message + upload_front_image + end + + private + + def form_submit + Idv::ImageUploadForm.new(current_user).submit(permit(:image)) + end + + def upload_front_image + success, message = assure_id.post_front_image(image.read) + return failure(message) unless success + end + end + end +end diff --git a/app/services/idv/steps/self_image_step.rb b/app/services/idv/steps/self_image_step.rb new file mode 100644 index 00000000000..f0f74ccb523 --- /dev/null +++ b/app/services/idv/steps/self_image_step.rb @@ -0,0 +1,48 @@ +module Idv + module Steps + class SelfImageStep < DocAuthBaseStep + def call + success, data = verify_image(image) + return failure(data) unless success + + return failure(I18n.t('doc_auth.errors.selfie')) unless data['FacialMatch'] + + step_successful(data) + end + + private + + def form_submit + Idv::ImageUploadForm.new(current_user).submit(permit(:image)) + end + + def step_successful(data) + save_doc_auth + flow_session[:image_verification_data] = data + end + + def save_doc_auth + doc_auth.license_confirmed_at = Time.zone.now + doc_auth.save + end + + def verify_image(self_image) + face_image_verified, data = assure_id.face_image + return failure(data) unless face_image_verified + + decoded_self_image = Base64.decode64(self_image.sub('data:image/png;base64,', '')) + Idv::Utils::ImagesToTmpFiles.new(data, decoded_self_image).call do |tmp_images| + facial_match.call(*tmp_images) + end + end + + def facial_match + @facial_match ||= Idv::Acuant::FacialMatch.new + end + + def doc_auth + @doc_auth ||= ::DocAuth.find_or_create_by(user_id: current_user.id) + end + end + end +end diff --git a/app/services/idv/steps/ssn_step.rb b/app/services/idv/steps/ssn_step.rb new file mode 100644 index 00000000000..a3ba3d34afa --- /dev/null +++ b/app/services/idv/steps/ssn_step.rb @@ -0,0 +1,15 @@ +module Idv + module Steps + class SsnStep < DocAuthBaseStep + def call + flow_session[:ssn] = flow_params[:ssn] + end + + private + + def form_submit + Idv::SsnForm.new(current_user).submit(permit(:ssn)) + end + end + end +end diff --git a/app/services/idv/utils/images_to_tmp_files.rb b/app/services/idv/utils/images_to_tmp_files.rb new file mode 100644 index 00000000000..e5f7fec5a04 --- /dev/null +++ b/app/services/idv/utils/images_to_tmp_files.rb @@ -0,0 +1,36 @@ +module Idv + module Utils + class ImagesToTmpFiles + def initialize(*images) + @images = images + end + + def call + tmp_files = images_to_tmp_files + yield tmp_files + ensure + tmp_files.each { |tmp| delete_file(tmp) } + end + + private + + def images_to_tmp_files + @images.map do |image| + Tempfile.open('foo', encoding: 'ascii-8bit').tap do |tmp| + write_file(tmp, image) + end + end + end + + def write_file(tmp, image) + tmp.write(image) + tmp.rewind + end + + def delete_file(tmp) + tmp.close + tmp.unlink + end + end + end +end diff --git a/app/services/idv/utils/pii_from_doc.rb b/app/services/idv/utils/pii_from_doc.rb new file mode 100644 index 00000000000..cfd59b32577 --- /dev/null +++ b/app/services/idv/utils/pii_from_doc.rb @@ -0,0 +1,43 @@ +module Idv + module Utils + class PiiFromDoc + VALUE = { + 'First Name' => :first_name, + 'Middle Name' => :middle_name, + 'Surname' => :last_name, + 'Address Line 1' => :address1, + 'Address City' => :city, + 'Address State' => :state, + 'Address Postal Code' => :zipcode, + 'Birth Date' => :dob, + }.freeze + + def initialize(id_data_fields) + @name_to_value = {} + id_data_fields['Fields'].each do |field| + @name_to_value[field['Name']] = field['Value'] + end + end + + def call(ssn, phone) + VALUE.each do |key, value| + hash[value] = @name_to_value[key] + end + hash[:dob] = convert_date(hash[:dob]) + hash[:ssn] = ssn + hash[:phone] = phone + hash + end + + private + + def hash + @hash ||= {} + end + + def convert_date(date) + Date.strptime((date[6..-3].to_f / 1000).to_s, '%s').strftime('%m/%d/%Y') + end + end + end +end diff --git a/app/validators/idv/form_ssn_validator.rb b/app/validators/idv/form_ssn_validator.rb new file mode 100644 index 00000000000..60ae58b1a84 --- /dev/null +++ b/app/validators/idv/form_ssn_validator.rb @@ -0,0 +1,41 @@ +module Idv + module FormSsnValidator + extend ActiveSupport::Concern + + included do + validates :ssn, presence: true + validate :ssn_is_unique + validates_format_of :ssn, + with: /\A\d{3}-?\d{2}-?\d{4}\z/, + message: I18n.t('idv.errors.pattern_mismatch.ssn'), + allow_blank: false + end + + def duplicate_ssn? + return true if any_matching_ssn_signatures?(ssn_signature) || + ssn_is_duplicate_with_old_key? + false + end + + private + + def ssn_signature(key = Pii::Fingerprinter.current_key) + Pii::Fingerprinter.fingerprint(ssn, key) if ssn + end + + def ssn_is_unique + errors.add :ssn, I18n.t('idv.errors.duplicate_ssn') if duplicate_ssn? + end + + def ssn_is_duplicate_with_old_key? + signatures = KeyRotator::Utils.old_keys(:hmac_fingerprinter_key_queue).map do |key| + ssn_signature(key) + end + any_matching_ssn_signatures?(signatures) + end + + def any_matching_ssn_signatures?(signatures) + Profile.where.not(user_id: @user.id).where(ssn_signature: signatures).any? + end + end +end diff --git a/app/views/idv/doc_auth/_start_over_or_cancel.html.slim b/app/views/idv/doc_auth/_start_over_or_cancel.html.slim new file mode 100644 index 00000000000..f662b97c00b --- /dev/null +++ b/app/views/idv/doc_auth/_start_over_or_cancel.html.slim @@ -0,0 +1,4 @@ +br += button_to(t('doc_auth.buttons.start_over'), idv_doc_auth_step_path(:reset), method: :put, + class: 'btn btn-link', form_class: 'inline-block') += render 'shared/cancel', link: idv_cancel_path diff --git a/app/views/idv/doc_auth/back_image.html.slim b/app/views/idv/doc_auth/back_image.html.slim new file mode 100644 index 00000000000..94c8b061798 --- /dev/null +++ b/app/views/idv/doc_auth/back_image.html.slim @@ -0,0 +1,17 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3 = t('doc_auth.headings.upload_back') + += simple_form_for(:doc_auth, url: idv_doc_auth_step_path(step: :back_image), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + .clearfix.mxn1 + .sm-col.sm-col-9.px1 + = f.input :image, label: false, as: 'file', required: true + + p = flow_session[:error_message] + + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' diff --git a/app/views/idv/doc_auth/doc_failed.html.slim b/app/views/idv/doc_auth/doc_failed.html.slim new file mode 100644 index 00000000000..3ec7a90aefa --- /dev/null +++ b/app/views/idv/doc_auth/doc_failed.html.slim @@ -0,0 +1,22 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3.red = t('doc_auth.errors.state_id_fail') + += form_for('', url: idv_doc_auth_step_path(step: :doc_failed), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + + - f.simple_fields_for :doc_auth do + + h4 = t('doc_auth.forms.state_id_info') + ul + li = "#{t('doc_auth.forms.first_name')}: #{flow_session[:matcher_pii_from_doc][:first_name]}" + li = "#{t('doc_auth.forms.last_name')}: #{flow_session[:matcher_pii_from_doc][:last_name]}" + li = "#{t('doc_auth.forms.dob')}: #{flow_session[:matcher_pii_from_doc][:dob]}" + h4 = t('doc_auth.forms.entered_info') + ul + li = "#{t('doc_auth.forms.ssn')}: #{flow_session[:ssn]}" + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' diff --git a/app/views/idv/doc_auth/doc_success.html.slim b/app/views/idv/doc_auth/doc_success.html.slim new file mode 100644 index 00000000000..7b69e5f8eb8 --- /dev/null +++ b/app/views/idv/doc_auth/doc_success.html.slim @@ -0,0 +1,20 @@ +-title t('doc_auth.titles.doc_auth') + += image_tag(asset_url('state-id-confirm@3x.png'), + alt: t('idv.titles.session.success'), width: 210) + +h1.h3.mb2.mt3.my0 = t('doc_auth.forms.doc_success') + +.col-2 + hr.mt3.mb3.bw4.border-green.rounded + +h2.h3.mb6.my0 = t('doc_auth.forms.selfie_next') + += form_for('', url: idv_doc_auth_step_path(step: :doc_success), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + - f.simple_fields_for :doc_auth do + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + +.mt2.pt1.border-top + = link_to t('links.cancel'), idv_cancel_path, class: 'h5' diff --git a/app/views/idv/doc_auth/front_image.html.slim b/app/views/idv/doc_auth/front_image.html.slim new file mode 100644 index 00000000000..dc6e0280ad6 --- /dev/null +++ b/app/views/idv/doc_auth/front_image.html.slim @@ -0,0 +1,17 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3 = t('doc_auth.headings.upload_front') + += simple_form_for(:doc_auth, url: idv_doc_auth_step_path(step: :front_image), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + .clearfix.mxn1 + .sm-col.sm-col-8.px1 + = f.input :image, label: false, as: 'file', required: true + + p = flow_session[:error_message] + + .mt0 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-6' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' diff --git a/app/views/idv/doc_auth/self_image.html.slim b/app/views/idv/doc_auth/self_image.html.slim new file mode 100644 index 00000000000..c5bb83337d9 --- /dev/null +++ b/app/views/idv/doc_auth/self_image.html.slim @@ -0,0 +1,24 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3 = t('doc_auth.headings.selfie') + +video id='player' controls=true autoplay=true width=460 height=345 +canvas id='canvas' width=460 height=345 style='display: none;' +button id='capture' = t('doc_auth.buttons.capture') + += form_for('', url: idv_doc_auth_step_path(step: :self_image), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + + - f.simple_fields_for :doc_auth do |ff| + + = ff.input :image, label: false, as: :hidden, required: true + + p = flow_session[:error_message] + + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' + +== javascript_pack_tag 'doc-auth' diff --git a/app/views/idv/doc_auth/ssn.html.slim b/app/views/idv/doc_auth/ssn.html.slim new file mode 100644 index 00000000000..8708f8be676 --- /dev/null +++ b/app/views/idv/doc_auth/ssn.html.slim @@ -0,0 +1,26 @@ +-title t('doc_auth.titles.doc_auth') + += image_tag(asset_url('state-id-none@3x.png'), + alt: t('idv.titles.jurisdiction'), width: 210) + +h1.h3 = t('doc_auth.headings.ssn') + += simple_form_for(:doc_auth, url: idv_doc_auth_step_path(step: :ssn), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + .clearfix.mxn1 + .sm-col.sm-col-6.px1 + / using :tel for mobile numeric keypad + = f.input :ssn, as: :tel, + label: t('idv.form.ssn_label_html'), required: true, + pattern: '^\d{3}-?\d{2}-?\d{4}$', + input_html: { class: 'ssn', value: '' } + + p = flow_session[:error_message] + p = link_to t('idv.messages.jurisdiction.no_id'), idv_jurisdiction_failure_path(:no_id) + + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + +.mt2.pt1.border-top + = link_to t('links.cancel'), idv_cancel_path, class: 'h5' diff --git a/config/application.yml.example b/config/application.yml.example index 3fd57d05c32..205c0139115 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -78,6 +78,12 @@ development: aamva_public_key: '123abc' aamva_private_key: '123abc' aamva_verification_url: 'https://example.org:12345/verification/url' + acuant_assure_id_subscription_id: '' + acuant_assure_id_username: '' + acuant_assure_id_password: '' + acuant_assure_id_url: '' + acuant_facial_match_license_key: '' + acuant_facial_match_url: '' account_reset_auth_token: 'abc123' account_reset_enabled: 'true' account_reset_token_valid_for_days: '1' @@ -109,6 +115,8 @@ development: database_timeout: '5000' database_username: '' disallow_all_web_crawlers: 'true' + doc_auth_enabled: 'false' + doc_auth_exclusive: 'false' domain_name: 'localhost:3000' enable_identity_verification: 'true' enable_rate_limiting: 'false' @@ -204,6 +212,12 @@ production: account_reset_enabled: 'true' account_reset_token_valid_for_days: '1' account_reset_wait_period_days: '1' + acuant_assure_id_subscription_id: '' + acuant_assure_id_username: '' + acuant_assure_id_password: '' + acuant_assure_id_url: '' + acuant_facial_match_license_key: '' + acuant_facial_match_url: '' async_job_refresh_interval_seconds: '5' async_job_refresh_max_wait_seconds: '15' attribute_cost: '4000$8$4$' # SCrypt::Engine.calibrate(max_time: 0.5) @@ -223,6 +237,8 @@ production: dashboard_api_token: database_statement_timeout: '2500' disallow_all_web_crawlers: 'false' + doc_auth_enabled: 'false' + doc_auth_exclusive: 'false' domain_name: 'login.gov' enable_identity_verification: 'false' enable_rate_limiting: 'true' @@ -312,6 +328,12 @@ test: account_reset_enabled: 'true' account_reset_token_valid_for_days: '1' account_reset_wait_period_days: '1' + acuant_assure_id_subscription_id: '' + acuant_assure_id_username: '' + acuant_assure_id_password: '' + acuant_assure_id_url: 'https://example.com' + acuant_facial_match_license_key: '' + acuant_facial_match_url: 'https://example.com' async_job_refresh_interval_seconds: '1' async_job_refresh_max_wait_seconds: '15' attribute_cost: '800$8$1$' # SCrypt::Engine.calibrate(max_time: 0.01) @@ -339,6 +361,8 @@ test: database_username: '' dashboard_api_token: '123ABC' disallow_all_web_crawlers: 'true' + doc_auth_enabled: 'true' + doc_auth_exclusive: 'false' enable_identity_verification: 'true' enable_rate_limiting: 'true' enable_test_routes: 'true' diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml new file mode 100644 index 00000000000..f042646b799 --- /dev/null +++ b/config/locales/doc_auth/en.yml @@ -0,0 +1,27 @@ +--- +en: + doc_auth: + buttons: + capture: Capture + start_over: Start over + errors: + selfie: Sorry, we are unable to match your picture, please try again. + state_id_fail: Sorry. Information from your uploaded state-issued ID does not + match information for your social security number. + forms: + dob: Date of Birth + doc_success: We've verified your social security number and state-issued ID. + entered_info: 'Information you entered:' + first_name: First Name + last_name: Last Name + selfie_next: Next, we'll need to take your picture. + ssn: Social Security Number + state_id_info: 'Information from your uploaded state-issued ID:' + headings: + selfie: Take a selfie! + ssn: To verify your identity, you'll need your social security number and state-issued + ID. + upload_back: Please upload a photo of the back of your state-issued ID. + upload_front: Please upload a photo of the front of your state-issued ID. + titles: + doc_auth: Document Authentication diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml new file mode 100644 index 00000000000..e25000ca40d --- /dev/null +++ b/config/locales/doc_auth/es.yml @@ -0,0 +1,29 @@ +--- +es: + doc_auth: + buttons: + capture: Capturar + start_over: Comenzar de nuevo + errors: + selfie: Lo sentimos, no podemos hacer coincidir su imagen, intente de nuevo. + state_id_fail: Lo siento. La información de su ID emitida por el estado no coincide + con la información de su número de seguro social. + forms: + dob: Fecha de nacimiento + doc_success: Verificamos su número de seguro social y su identificación emitida + por el estado. + entered_info: 'Información que ingresó:' + first_name: Nombre de pila + last_name: Apellido + selfie_next: A continuación, necesitaremos tomar su foto. + ssn: Número de seguridad social + state_id_info: 'Información de su ID emitida por el estado:' + headings: + selfie: "¡Toma una selfie!" + ssn: Para verificar su identidad, necesitará su número de seguro social y su + identificación emitida por el estado. + upload_back: Cargue una foto del dorso de su identificación emitida por el estado. + upload_front: Cargue una foto del frente de su identificación emitida por el + estado. + titles: + doc_auth: Autenticación de documentos diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml new file mode 100644 index 00000000000..dae08a2b117 --- /dev/null +++ b/config/locales/doc_auth/fr.yml @@ -0,0 +1,31 @@ +--- +fr: + doc_auth: + buttons: + capture: Capturer + start_over: Recommencer + errors: + selfie: Désolé, nous ne pouvons pas correspondre à votre photo, veuillez réessayer. + state_id_fail: Pardon. Les informations de votre identifiant émis par l'état + téléchargé ne correspondent pas aux informations de votre numéro de sécurité + sociale. + forms: + dob: Date de naissance + doc_success: Nous avons vérifié votre numéro de sécurité sociale et votre identifiant + délivré par l'État. + entered_info: 'Informations que vous avez entrées:' + first_name: Prénom + last_name: Nom de famille + selfie_next: Ensuite, nous devrons prendre votre photo. + ssn: Numéro de sécurité sociale + state_id_info: 'Information from your uploaded state-issued ID:' + headings: + selfie: Prendre un selfie! + ssn: Pour vérifier votre identité, vous aurez besoin de votre numéro de sécurité + sociale et de votre identifiant délivré par l'État. + upload_back: S'il vous plaît télécharger une photo du dos de votre ID émis par + l'état. + upload_front: Veuillez télécharger une photo du recto de votre identifiant émis + par l'État. + titles: + doc_auth: Authentification de document diff --git a/config/routes.rb b/config/routes.rb index 585ed48ead9..a7f8b5a3d6c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -202,6 +202,11 @@ get '/jurisdiction/failure/:reason' => 'jurisdiction#failure', as: :jurisdiction_failure get '/cancel/' => 'cancellations#new', as: :cancel delete '/cancel' => 'cancellations#destroy' + if FeatureManagement.doc_auth_enabled? + get '/doc_auth' => 'doc_auth#index' + get '/doc_auth/:step' => 'doc_auth#show', as: :doc_auth_step + put '/doc_auth/:step' => 'doc_auth#update' + end end end diff --git a/db/migrate/20180805121236_create_doc_auths.rb b/db/migrate/20180805121236_create_doc_auths.rb new file mode 100644 index 00000000000..bb50932e186 --- /dev/null +++ b/db/migrate/20180805121236_create_doc_auths.rb @@ -0,0 +1,12 @@ +class CreateDocAuths < ActiveRecord::Migration[5.1] + def change + create_table :doc_auths do |t| + t.references :user, null: false + t.datetime :attempted_at + t.integer :attempts, default: 0 + t.datetime :license_confirmed_at + t.datetime :selfie_confirmed_at + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index a77ff741e02..86a603738fa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -55,6 +55,17 @@ t.index ["user_id"], name: "index_authorizations_on_user_id" end + create_table "doc_auths", force: :cascade do |t| + t.bigint "user_id", null: false + t.datetime "attempted_at" + t.integer "attempts", default: 0 + t.datetime "license_confirmed_at" + t.datetime "selfie_confirmed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_doc_auths_on_user_id" + end + create_table "email_addresses", force: :cascade do |t| t.bigint "user_id" t.string "confirmation_token", limit: 255 diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 89b7ee2cd9d..03720126f11 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -105,4 +105,12 @@ def self.account_reset_enabled? def self.webauthn_enabled? Figaro.env.webauthn_enabled == 'true' end + + def self.doc_auth_enabled? + Figaro.env.doc_auth_enabled == 'true' + end + + def self.doc_auth_exclusive? + Figaro.env.doc_auth_exclusive == 'true' + end end diff --git a/spec/controllers/idv/doc_auth_controller_spec.rb b/spec/controllers/idv/doc_auth_controller_spec.rb new file mode 100644 index 00000000000..c9fd4a2cf35 --- /dev/null +++ b/spec/controllers/idv/doc_auth_controller_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +describe Idv::DocAuthController do + include DocAuthHelper + + describe 'before_actions' do + it 'includes corrects before_actions' do + expect(subject).to have_actions(:before, + :confirm_two_factor_authenticated, + :fsm_initialize, + :ensure_correct_step) + end + end + + before do + enable_doc_auth + stub_sign_in + stub_analytics + allow(@analytics).to receive(:track_event) + end + + describe '#index' do + it 'redirects to the first step' do + get :index + + expect(response).to redirect_to idv_doc_auth_step_url(step: :ssn) + end + end + + describe '#show' do + it 'renders the front_image template' do + get :show, params: { step: 'ssn' } + + expect(response).to render_template :ssn + end + + it 'renders the front_image template' do + mock_next_step(:front_image) + get :show, params: { step: 'front_image' } + + expect(response).to render_template :front_image + end + + it 'renders the back_image template' do + mock_next_step(:back_image) + get :show, params: { step: 'back_image' } + + expect(response).to render_template :back_image + end + + it 'renders the self image template' do + mock_next_step(:self_image) + get :show, params: { step: 'self_image' } + + expect(response).to render_template :self_image + end + + it 'redirect to the right step' do + mock_next_step(:front_image) + get :show, params: { step: 'back_image' } + + expect(response).to redirect_to idv_doc_auth_step_url(:front_image) + end + + it 'renders a 404 with a non existent step' do + get :show, params: { step: 'foo' } + + expect(response).to_not be_not_found + end + + it 'tracks analytics' do + result = { step: 'ssn' } + + get :show, params: { step: 'ssn' } + + expect(@analytics).to have_received(:track_event).with( + Analytics::DOC_AUTH + ' visited', result + ) + end + end + + describe '#update' do + it 'renders the front_image template' do + end + + it 'tracks analytics' do + result = { success: true, errors: {}, step: 'ssn' } + + put :update, params: { step: 'ssn', doc_auth: { step: 'ssn', ssn: '111-11-1111' } } + + expect(@analytics).to have_received(:track_event).with( + Analytics::DOC_AUTH + ' submitted', result + ) + end + end + + def mock_next_step(step) + allow_any_instance_of(Idv::Flows::DocAuthFlow).to receive(:next_step).and_return(step) + end +end diff --git a/spec/controllers/idv_controller_spec.rb b/spec/controllers/idv_controller_spec.rb index ec974cfab0a..a4c1231288c 100644 --- a/spec/controllers/idv_controller_spec.rb +++ b/spec/controllers/idv_controller_spec.rb @@ -2,6 +2,10 @@ describe IdvController do describe '#index' do + before do + allow(FeatureManagement).to receive(:doc_auth_enabled?).and_return(false) + end + it 'tracks page visit' do stub_sign_in stub_analytics @@ -44,6 +48,16 @@ expect(response).to redirect_to reactivate_account_url end + + it 'redirects to doc auth if doc auth is enabled and exclusive' do + stub_sign_in + allow(FeatureManagement).to receive(:doc_auth_enabled?).and_return(true) + allow(FeatureManagement).to receive(:doc_auth_exclusive?).and_return(true) + + get :index + + expect(response).to redirect_to idv_doc_auth_path + end end describe '#activated' do diff --git a/spec/features/idv/actions/reset_action_spec.rb b/spec/features/idv/actions/reset_action_spec.rb new file mode 100644 index 00000000000..1b94670458b --- /dev/null +++ b/spec/features/idv/actions/reset_action_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +feature 'doc auth reset action' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_front_image_step + end + + it 'resets doc auth to the first step' do + expect(page).to have_current_path(idv_doc_auth_front_image_step) + + click_on t('doc_auth.buttons.start_over') + + expect(page).to have_current_path(idv_doc_auth_ssn_step) + end +end diff --git a/spec/features/idv/doc_auth/back_image_step_spec.rb b/spec/features/idv/doc_auth/back_image_step_spec.rb new file mode 100644 index 00000000000..31a221ccb1b --- /dev/null +++ b/spec/features/idv/doc_auth/back_image_step_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +feature 'doc auth back image step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_back_image_step + mock_assure_id_ok + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_back_image_step) + expect(page).to have_content(t('doc_auth.headings.upload_back')) + end + + it 'proceeds to the next page with valid info' do + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_doc_success_step) + end + + it 'does not proceed to the next page if resolution fails' do + allow_any_instance_of(Idv::Agent).to receive(:proof). + and_return(success: false, errors: {}) + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_doc_failed_step) + end + + it 'does not proceed to the next page with invalid info' do + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:post_back_image). + and_return([false, '']) + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_back_image_step) + end + + it 'does not proceed to the next page with result=2' do + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:results). + and_return([true, assure_id_results_with_result_2]) + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_back_image_step) + end +end diff --git a/spec/features/idv/doc_auth/doc_failed_step_spec.rb b/spec/features/idv/doc_auth/doc_failed_step_spec.rb new file mode 100644 index 00000000000..52294fb8254 --- /dev/null +++ b/spec/features/idv/doc_auth/doc_failed_step_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +feature 'doc auth fail step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_doc_failed_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_doc_failed_step) + expect(page).to have_content(t('doc_auth.errors.state_id_fail')) + end +end diff --git a/spec/features/idv/doc_auth/doc_success_step_spec.rb b/spec/features/idv/doc_auth/doc_success_step_spec.rb new file mode 100644 index 00000000000..7ae336cca41 --- /dev/null +++ b/spec/features/idv/doc_auth/doc_success_step_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +feature 'doc auth success step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_doc_success_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_doc_success_step) + expect(page).to have_content(t('doc_auth.forms.doc_success')) + end + + it 'proceeds to the next page with valid info' do + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_self_image_step) + end +end diff --git a/spec/features/idv/doc_auth/front_image_step_spec.rb b/spec/features/idv/doc_auth/front_image_step_spec.rb new file mode 100644 index 00000000000..29eb2e4c064 --- /dev/null +++ b/spec/features/idv/doc_auth/front_image_step_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +feature 'doc auth front image step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_front_image_step + mock_assure_id_ok + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_front_image_step) + expect(page).to have_content(t('doc_auth.headings.upload_front')) + end + + it 'proceeds to the next page with valid info' do + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_back_image_step) + end + + it 'does not proceed to the next page with invalid info' do + mock_assure_id_fail + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_front_image_step) + end +end diff --git a/spec/features/idv/doc_auth/self_image_step_spec.rb b/spec/features/idv/doc_auth/self_image_step_spec.rb new file mode 100644 index 00000000000..b3b42981153 --- /dev/null +++ b/spec/features/idv/doc_auth/self_image_step_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +feature 'doc auth self image step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_self_image_step + mock_assure_id_ok + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_self_image_step) + expect(page).to have_content(t('doc_auth.headings.selfie')) + end + + it 'proceeds to the next page with valid info' do + first('input#_doc_auth_image', visible: false).set('data:image/png;base64,abc') + click_idv_continue + + expect(page).to have_current_path(idv_review_url) + end + + it 'does not proceed to the next page with invalid info' do + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:face_image).and_return([false, '']) + + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_self_image_step) + end + + it 'creates a doc auth record' do + first('input#_doc_auth_image', visible: false).set('data:image/png;base64,abc') + click_idv_continue + + expect(DocAuth.count).to eq(1) + expect(DocAuth.all[0].license_confirmed_at).to be_present + end +end diff --git a/spec/features/idv/doc_auth/ssn_step_spec.rb b/spec/features/idv/doc_auth/ssn_step_spec.rb new file mode 100644 index 00000000000..7c969d9fff6 --- /dev/null +++ b/spec/features/idv/doc_auth/ssn_step_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +feature 'doc auth ssn step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_ssn_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_ssn_step) + expect(page).to have_content(t('doc_auth.headings.ssn')) + end + + it 'proceeds to the next page with valid info' do + fill_out_ssn_form_ok + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_front_image_step) + end + + it 'does not proceed to the next page with invalid info' do + fill_out_ssn_form_fail + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_ssn_step) + end + + # it 'prevents a duplicate ssn' do + # end +end diff --git a/spec/forms/idv/image_upload_form_spec.rb b/spec/forms/idv/image_upload_form_spec.rb new file mode 100644 index 00000000000..81517b996b5 --- /dev/null +++ b/spec/forms/idv/image_upload_form_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +describe Idv::ImageUploadForm do + let(:user) { create(:user) } + let(:subject) { Idv::ImageUploadForm.new(user) } + let(:image_data) { 'abc' } + + describe '#submit' do + context 'when the form is valid' do + it 'returns a successful form response' do + result = subject.submit(image: image_data) + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(true) + expect(result.errors).to be_empty + end + end + + context 'when the form has invalid attributes' do + it 'raises an error' do + expect { subject.submit(image: image_data, foo: 1) }. + to raise_error(ArgumentError, 'foo is an invalid image attribute') + end + end + end + + describe 'presence validations' do + it 'is invalid when required attribute is not present' do + subject.submit(image: nil) + + expect(subject).to_not be_valid + end + end +end diff --git a/spec/forms/idv/ssn_form_spec.rb b/spec/forms/idv/ssn_form_spec.rb new file mode 100644 index 00000000000..2736cb9841b --- /dev/null +++ b/spec/forms/idv/ssn_form_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +describe Idv::SsnForm do + let(:user) { create(:user) } + let(:subject) { Idv::SsnForm.new(user) } + let(:ssn) { '111-11-1111' } + + describe '#submit' do + context 'when the form is valid' do + it 'returns a successful form response' do + result = subject.submit(ssn: '111111111') + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(true) + expect(result.errors).to be_empty + end + end + + context 'when the form is invalid' do + it 'returns an unsuccessful form response' do + result = subject.submit(ssn: 'abc') + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(false) + expect(result.errors).to include(:ssn) + end + end + + context 'when the form has invalid attributes' do + it 'raises an error' do + expect { subject.submit(ssn: '111111111', foo: 1) }. + to raise_error(ArgumentError, 'foo is an invalid ssn attribute') + end + end + end + + describe 'presence validations' do + it 'is invalid when required attribute is not present' do + subject.submit(ssn: nil) + + expect(subject).to_not be_valid + end + end + + describe 'ssn uniqueness' do + context 'when ssn is already taken by another profile' do + it 'is invalid' do + diff_user = create(:user) + create(:profile, pii: { ssn: ssn }, user: diff_user) + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq false + expect(subject.errors[:ssn]).to eq [t('idv.errors.duplicate_ssn')] + end + + it 'recognizes fingerprint regardless of HMAC key age' do + diff_user = create(:user) + create(:profile, pii: { ssn: ssn }, user: diff_user) + rotate_hmac_key + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq false + expect(subject.errors[:ssn]).to eq [t('idv.errors.duplicate_ssn')] + end + end + + context 'when ssn is already taken by same profile' do + it 'is valid' do + create(:profile, pii: { ssn: ssn }, user: user) + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq true + end + + it 'recognizes fingerprint regardless of HMAC key age' do + create(:profile, pii: { ssn: ssn }, user: user) + rotate_hmac_key + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq true + end + end + end +end diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index cacd9db2985..e16b4589792 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -427,4 +427,32 @@ end end end + + describe '#doc_auth_enabled?' do + it 'returns true when Figaro setting is true' do + allow(Figaro.env).to receive(:doc_auth_enabled) { 'true' } + + expect(FeatureManagement.doc_auth_enabled?).to eq(true) + end + + it 'returns false when Figaro setting is false' do + allow(Figaro.env).to receive(:doc_auth_enabled) { 'false' } + + expect(FeatureManagement.doc_auth_enabled?).to eq(false) + end + end + + describe '#doc_auth_exclusive?' do + it 'returns true when Figaro setting is true' do + allow(Figaro.env).to receive(:doc_auth_exclusive) { 'true' } + + expect(FeatureManagement.doc_auth_exclusive?).to eq(true) + end + + it 'returns false when Figaro setting is false' do + allow(Figaro.env).to receive(:doc_auth_exclusive) { 'false' } + + expect(FeatureManagement.doc_auth_exclusive?).to eq(false) + end + end end diff --git a/spec/models/doc_auth_spec.rb b/spec/models/doc_auth_spec.rb new file mode 100644 index 00000000000..f8a018f2969 --- /dev/null +++ b/spec/models/doc_auth_spec.rb @@ -0,0 +1,8 @@ +require 'rails_helper' + +describe DocAuth do + describe 'Associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:user_id) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1caf6e2ed43..8ea4fee2ec3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -13,6 +13,7 @@ it { is_expected.to have_one(:account_reset_request) } it { is_expected.to have_many(:phone_configurations) } it { is_expected.to have_many(:webauthn_configurations) } + it { is_expected.to have_one(:doc_auth) } end it 'does not send an email when #create is called' do diff --git a/spec/services/idv/acuant/assure_id_spec.rb b/spec/services/idv/acuant/assure_id_spec.rb new file mode 100644 index 00000000000..f012491fb58 --- /dev/null +++ b/spec/services/idv/acuant/assure_id_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +describe Idv::Acuant::AssureId do + let(:subject) { Idv::Acuant::AssureId.new } + let(:instance_id) { '123' } + let(:accuant_result_2) { '{"Result":2,"Alerts":[{"Actions":"Check the document"}]}' } + let(:good_acuant_status) { [true, '{"Result":1}'] } + let(:bad_acuant_status) { [false, ''] } + let(:good_http_status) { { status: 200, body: '{"Result":1}' } } + let(:failure_alerts_status) { { status: 200, body: accuant_result_2 } } + let(:bad_http_status) { { status: 441, body: '' } } + let(:acuant_base_url) { 'https://example.com' } + let(:image_data) { 'abc' } + + describe '#create_document' do + let(:path) { '/AssureIDService/Document/Instance' } + + it 'returns a good status with an instance id' do + stub_request(:post, acuant_base_url + path).to_return(status: 200, body: instance_id) + + result = subject.create_document + + expect(result).to eq([true, instance_id]) + expect(subject.instance_id).to eq(instance_id) + end + + it 'returns a bad status' do + stub_request(:post, acuant_base_url + path).to_return(bad_http_status) + + result = subject.create_document + + expect(result).to eq(bad_acuant_status) + end + end + + describe '#post_front_image' do + let(:side) { Idv::Acuant::AssureId::FRONT } + let(:path) { "/AssureIDService/Document/#{subject.instance_id}/Image?side=#{side}&light=0" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:post, acuant_base_url + path).to_return(good_http_status) + + result = subject.post_front_image(image_data) + + expect(result).to eq(good_acuant_status) + end + + it 'returns a bad status' do + stub_request(:post, acuant_base_url + path).to_return(bad_http_status) + + result = subject.post_front_image(image_data) + + expect(result).to eq(bad_acuant_status) + end + end + + describe '#post_back_image' do + let(:side) { Idv::Acuant::AssureId::BACK } + let(:path) { "/AssureIDService/Document/#{subject.instance_id}/Image?side=#{side}&light=0" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:post, acuant_base_url + path).to_return(good_http_status) + + result = subject.post_back_image(image_data) + + expect(result).to eq(good_acuant_status) + end + + it 'returns a bad status' do + stub_request(:post, acuant_base_url + path).to_return(bad_http_status) + + result = subject.post_back_image(image_data) + + expect(result).to eq(bad_acuant_status) + end + end + + describe '#results' do + let(:path) { "/AssureIDService/Document/#{subject.instance_id}" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:get, acuant_base_url + path).to_return(status: 200, body: '{}') + + result = subject.results + + expect(result).to eq([true, {}]) + end + + it 'returns a bad status' do + stub_request(:get, acuant_base_url + path).to_return(bad_http_status) + + result = subject.results + + expect(result).to eq(bad_acuant_status) + end + + it 'returns failure alerts for accuant result=2' do + stub_request(:get, acuant_base_url + path).to_return(failure_alerts_status) + + result = subject.results + + expect(result).to eq([true, JSON.parse(accuant_result_2)]) + end + end + + describe '#face_image' do + let(:path) { "/AssureIDService/Document/#{subject.instance_id}/Field/Image?key=Photo" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:get, acuant_base_url + path).to_return(good_http_status) + + result = subject.face_image + + expect(result).to eq(good_acuant_status) + end + + it 'returns a bad status' do + stub_request(:get, acuant_base_url + path).to_return(bad_http_status) + + result = subject.face_image + + expect(result).to eq(bad_acuant_status) + end + end +end diff --git a/spec/services/idv/acuant/facial_match_spec.rb b/spec/services/idv/acuant/facial_match_spec.rb new file mode 100644 index 00000000000..137edbfaef8 --- /dev/null +++ b/spec/services/idv/acuant/facial_match_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +describe Idv::Acuant::FacialMatch do + let(:subject) { Idv::Acuant::FacialMatch.new } + let(:acuant_facial_match_url) { 'https://example.com' } + + describe '#call' do + let(:path) { '/FacialMatch' } + let(:id_image) { '123' } + let(:image) { 'abc' } + + it 'returns a good status' do + stub_request(:post, acuant_facial_match_url + path).to_return(status: 200, body: '{}') + + result = subject.call(id_image, image) + + expect(result).to eq([true, {}]) + end + + it 'returns a bad status' do + stub_request(:post, acuant_facial_match_url + path).to_return(status: 441, body: '') + + result = subject.call(id_image, image) + + expect(result).to eq([false, '']) + end + end +end diff --git a/spec/services/idv/flows/doc_auth_flow_spec.rb b/spec/services/idv/flows/doc_auth_flow_spec.rb new file mode 100644 index 00000000000..527e1269807 --- /dev/null +++ b/spec/services/idv/flows/doc_auth_flow_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +describe Idv::Flows::DocAuthFlow do + include DocAuthHelper + + let(:user) { create(:user) } + let(:new_session) { { doc_auth: {} } } + let(:name) { :doc_auth } + + describe '#next_step' do + it 'returns ssn as the first step' do + subject = Idv::Flows::DocAuthFlow.new(new_session, user, name) + result = subject.next_step + + expect(result).to eq('ssn') + end + + it 'returns front image after the ssn step' do + expect_next_step(:ssn, :front_image) + end + + it 'returns back image after the front image step' do + expect_next_step(:front_image, :back_image) + end + + it 'returns self_image after the doc success step' do + expect_next_step(:doc_success, :self_image) + end + + it 'returns self_image after the doc success step' do + expect_next_step(:self_image, nil) + end + end + + describe '#handle' do + it 'handles the next step and returns a form response object' do + subject = Idv::Flows::DocAuthFlow.new(new_session, user, name) + params = ActionController::Parameters.new(doc_auth: { ssn: '111111111' }) + expect_any_instance_of(Idv::Steps::SsnStep).to receive(:call).exactly(:once) + + result = subject.handle(:ssn, params) + expect(result.class).to eq(FormResponse) + expect(result.success?).to eq(true) + end + end + + def expect_next_step(step, next_step) + session = session_from_completed_flow_steps(step) + subject = Idv::Flows::DocAuthFlow.new(session, user, name) + result = subject.next_step + + expect(result.to_s).to eq(next_step.to_s) + end +end diff --git a/spec/services/idv/utils/images_to_tmp_files_spec.rb b/spec/services/idv/utils/images_to_tmp_files_spec.rb new file mode 100644 index 00000000000..5442cbdfe52 --- /dev/null +++ b/spec/services/idv/utils/images_to_tmp_files_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +describe Idv::Utils::ImagesToTmpFiles do + let(:images) { %w[abc def] } + let(:subject) { Idv::Utils::ImagesToTmpFiles.new(*images) } + + describe '#call' do + it 'creates temporary files for the images' do + save_paths = [] + subject.call do |tmp_fns| + save_paths << tmp_fns[0].path + save_paths << tmp_fns[1].path + expect(File.read(tmp_fns[0])).to eq('abc') + expect(File.read(tmp_fns[1])).to eq('def') + end + + expect(File.exist?(save_paths[0])).to eq(false) + expect(File.exist?(save_paths[1])).to eq(false) + end + end +end diff --git a/spec/services/idv/utils/pii_from_doc_spec.rb b/spec/services/idv/utils/pii_from_doc_spec.rb new file mode 100644 index 00000000000..48c07d0e901 --- /dev/null +++ b/spec/services/idv/utils/pii_from_doc_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +describe Idv::Utils::PiiFromDoc do + include DocAuthHelper + + let(:subject) { Idv::Utils::PiiFromDoc } + let(:ssn) { '123' } + let(:phone) { '456' } + + describe '#call' do + it 'correctly parses the pii data from acuant and returns a hash' do + results = subject.new(DocAuthHelper::ACUANT_RESULTS).call(ssn, phone) + + expect(results).to eq(DocAuthHelper::ACUANT_RESULTS_TO_PII) + end + end +end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb new file mode 100644 index 00000000000..b555c541e88 --- /dev/null +++ b/spec/support/features/doc_auth_helper.rb @@ -0,0 +1,142 @@ +module DocAuthHelper + ACUANT_RESULTS = { + 'Result' => 1, + 'Fields' => [ + { 'Name' => 'First Name', 'Value' => 'Jane' }, + { 'Name' => 'Middle Name', 'Value' => 'Ann' }, + { 'Name' => 'Surname', 'Value' => 'Doe' }, + { 'Name' => 'Address Line 1', 'Value' => '1 Street' }, + { 'Name' => 'Address City', 'Value' => 'New York' }, + { 'Name' => 'Address State', 'Value' => 'NY' }, + { 'Name' => 'Address Postal Code', 'Value' => '11364' }, + { 'Name' => 'Birth Date', 'Value' => '/Date(' + + (Date.strptime('10-05-1938', '%m-%d-%Y').strftime('%Q').to_i + 43_200_000).to_s + ')/' }, + ], + }.freeze + + ACUANT_RESULTS_TO_PII = + { + first_name: 'Jane', + middle_name: 'Ann', + last_name: 'Doe', + address1: '1 Street', + city: 'New York', + state: 'NY', + zipcode: '11364', + dob: '10/05/1938', + ssn: '123', + phone: '456', + }.freeze + + def session_from_completed_flow_steps(finished_step) + session = { doc_auth: {} } + Idv::Flows::DocAuthFlow::STEPS.each do |step, klass| + session[:doc_auth][klass.to_s] = true + return session if step == finished_step + end + session + end + + def fill_out_ssn_form_ok + fill_in 'doc_auth_ssn', with: '666-66-1234' + end + + def fill_out_ssn_form_fail + fill_in 'doc_auth_ssn', with: '' + end + + def idv_doc_auth_ssn_step + idv_doc_auth_step_path(step: :ssn) + end + + def idv_doc_auth_front_image_step + idv_doc_auth_step_path(step: :front_image) + end + + def idv_doc_auth_back_image_step + idv_doc_auth_step_path(step: :back_image) + end + + def idv_doc_auth_doc_success_step + idv_doc_auth_step_path(step: :doc_success) + end + + def idv_doc_auth_doc_failed_step + idv_doc_auth_step_path(step: :doc_failed) + end + + def idv_doc_auth_self_image_step + idv_doc_auth_step_path(step: :self_image) + end + + def complete_doc_auth_steps_before_ssn_step(user = user_with_2fa) + sign_in_and_2fa_user(user) + visit idv_doc_auth_ssn_step unless current_path == idv_doc_auth_ssn_step + end + + def complete_doc_auth_steps_before_front_image_step(user = user_with_2fa) + complete_doc_auth_steps_before_ssn_step(user) + fill_out_ssn_form_ok + click_idv_continue + end + + def complete_doc_auth_steps_before_back_image_step(user = user_with_2fa) + complete_doc_auth_steps_before_front_image_step(user) + mock_assure_id_ok + attach_image + click_idv_continue + end + + def complete_doc_auth_steps_before_doc_success_step(user = user_with_2fa) + complete_doc_auth_steps_before_back_image_step(user) + attach_image + click_idv_continue + end + + def complete_doc_auth_steps_before_doc_failed_step(user = user_with_2fa) + complete_doc_auth_steps_before_back_image_step(user) + attach_image + allow_any_instance_of(Idv::Agent).to receive(:proof). + and_return(success: false, errors: {}) + click_idv_continue + end + + def complete_doc_auth_steps_before_self_image_step(user = user_with_2fa) + complete_doc_auth_steps_before_doc_success_step(user) + click_idv_continue + end + + def mock_assure_id_ok + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:create_document). + and_return([true, '123']) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:post_front_image). + and_return([true, '']) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:post_back_image). + and_return([true, '']) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:results). + and_return([true, ACUANT_RESULTS]) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:face_image).and_return([true, '']) + allow_any_instance_of(Idv::Acuant::FacialMatch).to receive(:call). + and_return([true, { 'FacialMatch' => 1 }]) + end + + def mock_assure_id_fail + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:create_document). + and_return([false, '']) + end + + def enable_doc_auth + allow(FeatureManagement).to receive(:doc_auth_enabled?).and_return(true) + end + + def attach_image + attach_file 'doc_auth_image', 'app/assets/images/logo.png' + end + + def assure_id_results_with_result_2 + result = DocAuthHelper::ACUANT_RESULTS.dup + result['Result'] = 2 + result['Alerts'] = [{ 'Actions': 'Check the document' }] + result + end +end