diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb new file mode 100644 index 00000000000..07fac54f6b7 --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -0,0 +1,21 @@ +module Api + class BaseController < ApplicationController + before_action :check_api_enabled + before_action :confirm_two_factor_authenticated_for_api + + respond_to :json + + def check_api_enabled + render_api_not_found unless IdentityConfig.store.idv_api_enabled + end + + def confirm_two_factor_authenticated_for_api + return if user_fully_authenticated? + render json: { error: 'user is not fully authenticated' }, status: :unauthorized + end + + def render_api_not_found + render json: { error: "The page you were looking for doesn't exist" }, status: :not_found + end + end +end diff --git a/app/controllers/api/verify/complete_controller.rb b/app/controllers/api/verify/complete_controller.rb new file mode 100644 index 00000000000..8e7e08b0b3e --- /dev/null +++ b/app/controllers/api/verify/complete_controller.rb @@ -0,0 +1,34 @@ +module Api + module Verify + class CompleteController < Api::BaseController + def create + result, personal_key = Api::ProfileCreationForm.new( + password: verify_params[:password], + jwt: verify_params[:details], + user_session: user_session, + service_provider: current_sp, + ).submit + + if result.success? + user = User.find_by(uuid: result.extra[:user_uuid]) + add_proofing_component(user) + render json: { personal_key: personal_key, + profile_pending: result.extra[:profile_pending] }, + status: :ok + else + render json: { error: result.errors }, status: :bad_request + end + end + + private + + def verify_params + params.permit(:password, :details) + end + + def add_proofing_component(user) + ProofingComponent.create_or_find_by(user: user).update(verified_at: Time.zone.now) + end + end + end +end diff --git a/app/decorators/api/user_bundle_decorator.rb b/app/decorators/api/user_bundle_decorator.rb new file mode 100644 index 00000000000..c1aaaace8e5 --- /dev/null +++ b/app/decorators/api/user_bundle_decorator.rb @@ -0,0 +1,49 @@ +module Api + class UserBundleError < StandardError; end + + class UserBundleDecorator + # Note, does not rescue JWT errors - responsibility of the user + def initialize(user_bundle:, public_key:) + payload, headers = JWT.decode( + user_bundle, + public_key, + true, + algorithm: 'RS256', + ) + @jwt_payload = payload + @jwt_headers = headers + + raise UserBundleError.new('pii is missing') unless jwt_payload['pii'] + raise UserBundleError.new('metadata is missing') unless jwt_payload['metadata'] + end + + def gpo_address_verification? + metadata[:address_verification_mechanism] == 'gpo' + end + + def pii + HashWithIndifferentAccess.new(jwt_payload['pii']) + end + + def user + return @user if defined?(@user) + @user = User.find_by(uuid: jwt_headers['sub']) + end + + def user_phone_confirmation? + metadata[:user_phone_confirmation] == true + end + + def vendor_phone_confirmation? + metadata[:vendor_phone_confirmation] == true + end + + private + + attr_reader :jwt_payload, :jwt_headers + + def metadata + HashWithIndifferentAccess.new(jwt_payload['metadata']) + end + end +end diff --git a/app/forms/api/profile_creation_form.rb b/app/forms/api/profile_creation_form.rb new file mode 100644 index 00000000000..9218fa049ba --- /dev/null +++ b/app/forms/api/profile_creation_form.rb @@ -0,0 +1,155 @@ +module Api + class ProfileCreationForm + include ActiveModel::Model + + validate :valid_jwt + validate :valid_user + validate :valid_password + + attr_reader :password, :user_bundle + attr_reader :user_session, :service_provider + attr_reader :profile + + def initialize(password:, jwt:, user_session:, service_provider: nil) + @password = password + @jwt = jwt + @user_session = user_session + @service_provider = service_provider + set_idv_session + end + + def submit + @form_valid = valid? + + if form_valid? + create_profile + cache_encrypted_pii + complete_session + end + + response = FormResponse.new( + success: form_valid?, + errors: errors.to_hash, + extra: extra_attributes, + ) + [response, personal_key] + end + + private + + attr_reader :jwt + + def create_profile + profile_maker = build_profile_maker + profile = profile_maker.save_profile + @profile = profile + session[:pii] = profile_maker.pii_attributes + session[:profile_id] = profile.id + session[:personal_key] = profile.personal_key + end + + def cache_encrypted_pii + cacher = Pii::Cacher.new(user, session) + cacher.save(password, profile) + end + + def complete_session + complete_profile if phone_confirmed? + create_gpo_entry if user_bundle.gpo_address_verification? + end + + def phone_confirmed? + user_bundle.vendor_phone_confirmation? && user_bundle.user_phone_confirmation? + end + + def complete_profile + user.pending_profile&.activate + move_pii_to_user_session + end + + def move_pii_to_user_session + return if session[:decrypted_pii].blank? + user_session[:decrypted_pii] = session.delete(:decrypted_pii) + end + + def create_gpo_entry + move_pii_to_user_session + confirmation_maker = GpoConfirmationMaker.new( + pii: Pii::Cacher.new(user, user_session).fetch, + service_provider: service_provider, + profile: profile, + ) + confirmation_maker.perform + end + + def build_profile_maker + Idv::ProfileMaker.new( + applicant: user_bundle.pii, + user: user, + user_password: password, + ) + end + + def user + user_bundle&.user + end + + def set_idv_session + return if session.present? + user_session[:idv] = {} + end + + def session + user_session.fetch(:idv, {}) + end + + def valid_jwt + @user_bundle = Api::UserBundleDecorator.new(user_bundle: jwt, public_key: public_key) + rescue JWT::DecodeError => err + errors.add(:jwt, "decode error: #{err.message}", type: :invalid) + rescue ::Api::UserBundleError => err + errors.add(:jwt, "malformed user bundle: #{err.message}", type: :invalid) + end + + def valid_user + return if user + errors.add(:user, 'user not found', type: :invalid) + end + + def valid_password + return if user&.valid_password?(password) + errors.add(:password, 'invalid password', type: :invalid) + end + + def form_valid? + @form_valid + end + + def extra_attributes + if user.present? + @extra_attributes ||= { + profile_pending: user.pending_profile?, + user_uuid: user.uuid, + } + else + @extra_attributes = {} + end + end + + def personal_key + @personal_key ||= profile&.personal_key || profile&.encrypt_recovery_pii(pii) + end + + def public_key + key = OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_public_key)) + + if Identity::Hostdata.in_datacenter? + env = Identity::Hostdata.env + prod_env = env == 'prod' || env == 'staging' || env == 'dm' + raise 'key size too small' if prod_env && key.n.num_bits < 2048 + end + + key + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 0e3d4dc1ea6..f196c01f87f 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -97,6 +97,8 @@ idv_api_enabled: false idv_attempt_window_in_hours: 6 idv_max_attempts: 5 idv_min_age_years: 13 +idv_public_key: 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBS3p4d25rbUxqeGx1NmhsRlQ2d2JreUlweHNtYkMyaApjYW5TMGhuWm1DRGIrTEhaME5zQTdHWURpZkMxQlRBMHRuRFo0Zm9HNTRmYjNzYk9ubGpGWXVNQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=' +idv_private_key: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBS3p4d25rbUxqeGx1NmhsRlQ2d2JreUlweHNtYkMyaGNhblMwaG5abUNEYitMSFowTnNBCjdHWURpZkMxQlRBMHRuRFo0Zm9HNTRmYjNzYk9ubGpGWXVNQ0F3RUFBUUpCQUp6TUMvOSs2RWlHQzkrZTFlWWkKVzc0ejN4MjBkanZndFlhOHh4UDh2ZnA3TjdKQXMvaGNUbjVLOCtDM2swaXUyR2RNb21qSlp2ckxwT0IyTWh4RQo3QkVDSVFEVERhbVRCMHhKSlVpV0ljNk15Y0dFa2J4SEZ3eEtURVNCaHhzREFISDZEUUloQU5IR2NwVUs5dmVSCkdrZlZTOS9MSVNZQlk2YzRUZk1NUFJZU21KVHFNRVN2QWlBZFdiY05aV1JzZjZ6YWhCVVBhemRvVWtRV3R0UFUKdVVxRm9ONVd5b2NQT1FJZ1FrUjlaK1haMUtVcTl5eERWc1FWaWFzQXJ3K1RXRWN5ZU9tUTkrSHZNNU1DSUcrMQpxVldqNW9PL0FBSU1QbXZVZmp5L0JnMnhEQVRiOEp6alFrQ3dLSnNwCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==' idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 in_person_proofing_enabled: true diff --git a/config/routes.rb b/config/routes.rb index 63eefcef040..44feb832dca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -333,6 +333,10 @@ ].each { |step_path| get step_path => 'verify#show' } end + namespace :api do + post '/verify/complete' => 'verify/complete#create' + end + get '/account/verify' => 'idv/gpo_verify#index', as: :idv_gpo_verify post '/account/verify' => 'idv/gpo_verify#create' if FeatureManagement.enable_gpo_verification? diff --git a/lib/identity_config.rb b/lib/identity_config.rb index e6476e6f6f9..9295fe8b01f 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -171,6 +171,8 @@ def self.build_store(config_map) config.add(:idv_attempt_window_in_hours, type: :integer) config.add(:idv_max_attempts, type: :integer) config.add(:idv_min_age_years, type: :integer) + config.add(:idv_private_key, type: :string) + config.add(:idv_public_key, type: :string) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) config.add(:idv_send_link_max_attempts, type: :integer) config.add(:in_person_proofing_enabled, type: :boolean) diff --git a/spec/controllers/api/verify/complete_controller_spec.rb b/spec/controllers/api/verify/complete_controller_spec.rb new file mode 100644 index 00000000000..22a92627eaf --- /dev/null +++ b/spec/controllers/api/verify/complete_controller_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +describe Api::Verify::CompleteController do + include PersonalKeyValidator + include SamlAuthHelper + + def stub_idv_session + stub_sign_in(user) + end + + let(:password) { 'iambatman' } + let(:user) { create(:user, :signed_up, password: password) } + let(:applicant) do + { first_name: 'Bruce', + last_name: 'Wayne', + address1: '123 Mansion St', + address2: 'Ste 456', + city: 'Gotham City', + state: 'NY', + zipcode: '10015' } + end + + let(:pii) do + { first_name: 'Bruce', + last_name: 'Wayne', + ssn: '900-90-1234' } + end + + let(:profile) { subject.idv_session.profile } + let(:key) { OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_private_key)) } + let(:jwt) { JWT.encode({ pii: pii, metadata: {} }, key, 'RS256', sub: user.uuid) } + + before do + allow(IdentityConfig.store).to receive(:idv_api_enabled).and_return(true) + end + + describe 'before_actions' do + it 'includes before_actions from Api::BaseController' do + expect(subject).to have_actions( + :before, + :confirm_two_factor_authenticated_for_api, + ) + end + end + + describe '#create' do + context 'when the user is not signed in and submits the password' do + it 'does not create a profile or return a key' do + post :create, params: { password: 'iambatman', details: jwt } + expect(JSON.parse(response.body)['personal_key']).to be_nil + expect(response.status).to eq 401 + expect(JSON.parse(response.body)['error']).to eq 'user is not fully authenticated' + end + end + + context 'when the user is signed in and submits the password' do + before do + stub_idv_session + end + + it 'creates a profile and returns a key' do + post :create, params: { password: 'iambatman', details: jwt } + expect(JSON.parse(response.body)['personal_key']).not_to be_nil + expect(response.status).to eq 200 + end + + it 'does not create a profile and return a key when it has the wrong password' do + post :create, params: { password: 'iamnotbatman', details: jwt } + expect(JSON.parse(response.body)['personal_key']).to be_nil + expect(response.status).to eq 400 + end + end + + context 'when the idv api is not enabled' do + before do + allow(IdentityConfig.store).to receive(:idv_api_enabled).and_return(false) + end + + it 'responds with not found' do + post :create, params: { password: 'iambatman', details: jwt } + expect(response.status).to eq 404 + expect(JSON.parse(response.body)['error']). + to eq "The page you were looking for doesn't exist" + end + end + end +end diff --git a/spec/forms/api/profile_creation_form_spec.rb b/spec/forms/api/profile_creation_form_spec.rb new file mode 100644 index 00000000000..3f1611d0483 --- /dev/null +++ b/spec/forms/api/profile_creation_form_spec.rb @@ -0,0 +1,222 @@ +require 'rails_helper' + +RSpec.describe Api::ProfileCreationForm do + let(:password) { 'salty pickles' } + let(:entered_password) { password } + let(:user) { create(:user, password: password) } + let(:uuid) { user.uuid } + let(:pii) do + { first_name: 'Ada', last_name: 'Lovelace', ssn: '900-90-0900' } + end + let(:metadata) { {} } + let(:key) { OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_private_key)) } + let(:bundle) do + JWT.encode({ pii: pii, metadata: metadata }, key, 'RS256', sub: uuid.to_s) + end + let(:user_session) { {} } + + subject do + Api::ProfileCreationForm.new( + password: entered_password, + jwt: bundle, + user_session: user_session, + ) + end + + describe '#submit' do + context 'with the correct password' do + it 'returns a successful response with the personal_key in the extra hash' do + response, personal_key = subject.submit + + expect(response.success?).to be true + expect(personal_key).to be_present + end + + it 'creates and saves the user profile' do + expect(user.profiles.count).to eq 0 + + subject.submit + + expect(user.profiles.count).to eq 1 + end + + it 'saves the user pii encrypted with their password in the profile' do + subject.submit + profile = user.profiles.first + decrypted_pii = profile.decrypt_pii(password) + + expect(decrypted_pii[:first_name]).to eq 'Ada' + end + + it 'saves the user pii encrypted with their personal_key in the profile' do + _response, key = subject.submit + profile = user.profiles.first + personal_key = PersonalKeyGenerator.new(user).normalize(key) + decrypted_recovery_pii = profile.recover_pii(personal_key) + + expect(decrypted_recovery_pii[:first_name]).to eq 'Ada' + end + + context 'with the user having verified their phone' do + let(:metadata) do + { + vendor_phone_confirmation: true, + user_phone_confirmation: true, + } + end + + it 'activates the user profile' do + subject.submit + profile = user.profiles.first + + expect(profile.active?).to be true + end + + it 'moves the pii to the user_session' do + subject.submit + stored_pii = JSON.parse(user_session[:decrypted_pii]) + + expect(stored_pii['first_name']).to eq 'Ada' + end + end + + context 'with the user having verified their address via GPO letter' do + let(:metadata) do + { + address_verification_mechanism: 'gpo', + } + end + + it 'does not activate the user profile' do + subject.submit + profile = user.profiles.first + + expect(profile.active?).to be false + end + + it 'moves the pii to the user_session' do + subject.submit + stored_pii = JSON.parse(user_session[:decrypted_pii]) + + expect(stored_pii['first_name']).to eq 'Ada' + end + + it 'creates a GPO confirmation code' do + subject.submit + profile = user.profiles.first + gpo_otp = GpoConfirmation.last.entry[:otp] + + expect(profile.gpo_confirmation_codes.first_with_otp(gpo_otp)).not_to be_nil + end + end + end + + context 'with an incorrect password' do + let(:entered_password) { 'wild guess' } + + it 'returns an unsuccessful response with an error about the password' do + response, personal_key = subject.submit + + expect(response.success?).to be false + expect(personal_key).to be_nil + expect(response.errors[:password]).to eq ['invalid password'] + end + end + + context 'with a non-existent user' do + let(:uuid) { SecureRandom.uuid } + + it 'returns an unsuccessful response with an error about the user' do + response, personal_key = subject.submit + + expect(response.success?).to be false + expect(personal_key).to be_nil + expect(response.errors[:user]).to eq ['user not found'] + end + end + + context 'with an expired JWT' do + let(:bundle) { JWT.encode(pii.merge(exp: 1.day.ago.to_i), key, 'RS256', sub: uuid.to_s) } + + it 'returns an unsuccessful response with an error about the jwt' do + response, personal_key = subject.submit + + expect(response.success?).to be false + expect(personal_key).to be_nil + expect(response.errors[:jwt]).to eq ['decode error: Signature has expired'] + end + end + end + + describe '#valid?' do + context 'with the correct password' do + it 'is a valid form' do + expect(subject.valid?).to be true + end + end + + context 'with an incorrect password' do + let(:entered_password) { 'wild guess' } + + it 'is an invalid form' do + expect(subject.valid?).to be false + end + end + + context 'with a non-existent user' do + let(:uuid) { SecureRandom.uuid } + + it 'is an invalid form' do + expect(subject.valid?).to be false + end + end + + context 'with an expired JWT' do + let(:bundle) do + JWT.encode( + { pii: pii, metadata: metadata, exp: 1.day.ago.to_i }, + key, + 'RS256', + sub: uuid, + ) + end + + it 'is an invalid form' do + expect(subject.valid?).to be false + expect(subject.errors.to_a.join(' ')).to match(%r{decode error}) + end + end + + context 'with a JWT missing pii' do + let(:bundle) do + JWT.encode( + { metadata: metadata }, + key, + 'RS256', + sub: uuid, + ) + end + + it 'is an invalid form' do + expect(subject.valid?).to be false + expect(subject.errors.to_a.join(' ')).to match(%r{pii is missing}) + end + end + + context 'with a JWT missing metadata' do + let(:bundle) do + JWT.encode( + { pii: pii }, + key, + 'RS256', + sub: uuid, + ) + end + + it 'is an invalid form' do + expect(subject.valid?).to be false + expect(subject.errors.to_a.join(' ')).to match(%r{metadata is missing}) + end + end + end +end