Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .reek.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ detectors:
- Analytics
TooManyInstanceVariables:
exclude:
- BaseFlow
- OpenidConnectAuthorizeForm
- OpenidConnectRedirector
- Idv::VendorResult
Expand Down
15 changes: 15 additions & 0 deletions app/controllers/idv/doc_auth_controller.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions app/controllers/idv_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
38 changes: 38 additions & 0 deletions app/forms/idv/image_upload_form.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions app/forms/idv/ssn_form.rb
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions app/javascript/packs/doc-auth.js
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions app/models/doc_auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class DocAuth < ApplicationRecord
belongs_to :user, inverse_of: :doc_auth
validates :user_id, presence: true
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions app/services/analytics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/services/flow/base_flow.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions app/services/flow/base_step.rb
Original file line number Diff line number Diff line change
@@ -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
90 changes: 90 additions & 0 deletions app/services/flow/flow_state_machine.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions app/services/idv/actions/reset_action.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Idv
module Actions
class ResetAction < Idv::Steps::DocAuthBaseStep
def call
reset
end
end
end
end
Loading