diff --git a/.reek b/.reek index 8499befe46e..747f8d7e29c 100644 --- a/.reek +++ b/.reek @@ -14,6 +14,7 @@ DuplicateMethodCall: - WorkerHealthChecker#status - FileEncryptor#encrypt - UserFlowExporter#self.massage_assets + - BasicAuthUrl#build FeatureEnvy: exclude: - ActiveJob::Logging::LogSubscriber#json_for diff --git a/.slim-lint.yml b/.slim-lint.yml index 81636c752f4..611aae3da75 100644 --- a/.slim-lint.yml +++ b/.slim-lint.yml @@ -7,5 +7,8 @@ linters: exclude: - 'app/views/pages/help.html.slim' - 'app/views/pages/privacy_policy.html.slim' + TagCase: + exclude: + - 'app/views/voice/otp/show.xml.slim' RuboCop: enabled: true diff --git a/app/controllers/voice/otp_controller.rb b/app/controllers/voice/otp_controller.rb new file mode 100644 index 00000000000..65a5d9b9436 --- /dev/null +++ b/app/controllers/voice/otp_controller.rb @@ -0,0 +1,56 @@ +module Voice + class OtpController < ApplicationController + NUMBER_OF_TIMES_USER_CAN_REPEAT_CODE = 5 + + skip_before_action :verify_authenticity_token + + def show + if code.blank? + render nothing: true, status: :bad_request + return + end + + @message = message + @action_url = action_url + end + + protected + + def encrypted_code + params[:encrypted_code].to_s + end + + def code + return unless encrypted_code.present? + + cipher.decrypt(encrypted_code) + end + + def message + t('voice.otp.message', code: code_with_pauses) + end + + def code_with_pauses + code.scan(/\d/).join(', ') + end + + def repeat_count + (params[:repeat_count].presence || NUMBER_OF_TIMES_USER_CAN_REPEAT_CODE).to_i + end + + def action_url + return if repeat_count <= 1 + + BasicAuthUrl.build( + voice_otp_url( + encrypted_code: encrypted_code, + repeat_count: repeat_count - 1 + ) + ) + end + + def cipher + Gibberish::AES.new(Figaro.env.attribute_encryption_key) + end + end +end diff --git a/app/jobs/voice_otp_sender_job.rb b/app/jobs/voice_otp_sender_job.rb index d16399d6c32..eb451232802 100644 --- a/app/jobs/voice_otp_sender_job.rb +++ b/app/jobs/voice_otp_sender_job.rb @@ -1,4 +1,7 @@ class VoiceOtpSenderJob < ApplicationJob + include Rails.application.routes.url_helpers + include LocaleHelper + queue_as :voice def perform(code:, phone:, otp_created_at:) @@ -13,45 +16,19 @@ def otp_valid?(otp_created_at) end def send_otp(twilio_service, code, phone) - code_with_pauses = code.scan(/\d/).join(', ') twilio_service.place_call( to: phone, - url: twimlet_url(code_with_pauses), - record: Figaro.env.twilio_record_voice == 'true' - ) - end - - def twimlet_url(code) # rubocop:disable Metrics/MethodLength - repeat = message_repeat(code) - - twimlet_menu( - repeat, - 1 => twimlet_menu( - repeat, - 1 => twimlet_menu( - repeat, - 1 => twimlet_menu(repeat, 1 => twimlet_message(message_final(code))) + url: BasicAuthUrl.build( + voice_otp_url( + encrypted_code: cipher.encrypt(code), + locale: locale_url_param ) - ) + ), + record: Figaro.env.twilio_record_voice == 'true' ) end - def message_repeat(code) - I18n.t('jobs.voice_otp_sender_job.message_repeat', code: code) - end - - def message_final(code) - I18n.t('jobs.voice_otp_sender_job.message_final', code: code) - end - - def twimlet_message(message) - 'https://twimlets.com/message?' + { Message: { 0 => message } }.to_query - end - - def twimlet_menu(message, options) - 'https://twimlets.com/menu?' + { - Message: message, - Options: options.to_h, - }.to_query + def cipher + Gibberish::AES.new(Figaro.env.attribute_encryption_key) end end diff --git a/app/services/basic_auth_url.rb b/app/services/basic_auth_url.rb new file mode 100644 index 00000000000..d43426afee3 --- /dev/null +++ b/app/services/basic_auth_url.rb @@ -0,0 +1,10 @@ +module BasicAuthUrl + module_function + + def build(url, user: Figaro.env.basic_auth_user_name, password: Figaro.env.basic_auth_password) + URI.parse(url).tap do |uri| + uri.user = user + uri.password = password + end.to_s + end +end diff --git a/app/views/voice/otp/show.xml.slim b/app/views/voice/otp/show.xml.slim new file mode 100644 index 00000000000..c008ec7b453 --- /dev/null +++ b/app/views/voice/otp/show.xml.slim @@ -0,0 +1,8 @@ +doctype xml +Response + Say language="#{I18n.locale}" + = @message + - if @action_url + Gather numDigits="1" action=@action_url + Say = t('voice.otp.repeat_instructions') + Hangup / diff --git a/config/application.yml.example b/config/application.yml.example index 2da906e0e33..5e06640a34b 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -60,6 +60,8 @@ development: available_locales: 'en es fr' aws_kms_key_id: 'alias/login-dot-gov-development-keymaker' aws_region: 'us-east-1' + basic_auth_user_name: 'user' + basic_auth_password: 'secret' dashboard_api_token: 'test_token' dashboard_url: 'http://localhost:3001/api/service_providers' database_host: '' @@ -125,6 +127,8 @@ production: available_locales: 'en es fr' aws_kms_key_id: aws_region: + basic_auth_user_name: + basic_auth_password: disable_email_sending: 'false' dashboard_api_token: domain_name: 'login.gov' @@ -186,6 +190,8 @@ test: available_locales: 'en es fr' aws_kms_key_id: 'alias/login-dot-gov-test-keymaker' aws_region: 'us-east-1' + basic_auth_user_name: 'user' + basic_auth_password: 'secret' domain_name: 'www.example.com' database_host: '' database_name: '' diff --git a/config/locales/jobs/en.yml b/config/locales/jobs/en.yml index fc312f77076..9938a11af65 100644 --- a/config/locales/jobs/en.yml +++ b/config/locales/jobs/en.yml @@ -3,8 +3,3 @@ en: jobs: sms_otp_sender_job: message: "%{code} is your %{app} one-time security code." - voice_otp_sender_job: - message_final: Hello! Your login.gov one time security code is, %{code}, again, - your security code is, %{code}, goodbye! - message_repeat: Hello! Your login.gov one time security code is, %{code}, again, - your security code is, %{code}. Press 1 to repeat your code. diff --git a/config/locales/jobs/es.yml b/config/locales/jobs/es.yml index 2f321118986..a6329d59ae1 100644 --- a/config/locales/jobs/es.yml +++ b/config/locales/jobs/es.yml @@ -3,9 +3,3 @@ es: jobs: sms_otp_sender_job: message: "%{code} es su %{app} código de seguridad de sólo un uso." - voice_otp_sender_job: - message_final: "¡Hola! Su código de seguridad de login.gov para uso único es, - %{code}, nuevamente, su código de seguridad es, % {code}, ¡adiós!" - message_repeat: "¡Hola! Su código de seguridad de login.gov para uso único es - %{code}, nuevamente, su código de seguridad es % {code}. Presione 1 para repetir - su código." diff --git a/config/locales/jobs/fr.yml b/config/locales/jobs/fr.yml index bb76630ae82..e3842d4808e 100644 --- a/config/locales/jobs/fr.yml +++ b/config/locales/jobs/fr.yml @@ -3,9 +3,3 @@ fr: jobs: sms_otp_sender_job: message: "%{code} est votre %{app} code de sécurité à utilisation unique." - voice_otp_sender_job: - message_final: Bonjour! Votre code de sécurité à utilisation unique de login.gov - est, %{code}, de nouveau, votre code de sécurité est, %{code}, au revoir! - message_repeat: Bonjour! Votre code de sécurité à utilisation unique de login.gov - est, %{code}, de nouveau, votre code de sécurité est, %{code}. Appuyez sur 1 - pour répéter votre code. diff --git a/config/locales/voice/en.yml b/config/locales/voice/en.yml new file mode 100644 index 00000000000..1672f893747 --- /dev/null +++ b/config/locales/voice/en.yml @@ -0,0 +1,7 @@ +--- +en: + voice: + otp: + message: Hello! Your login.gov one time passcode is, %{code}, again, your passcode + is, %{code}. + repeat_instructions: Press 1 to repeat this message. diff --git a/config/locales/voice/es.yml b/config/locales/voice/es.yml new file mode 100644 index 00000000000..5a251461957 --- /dev/null +++ b/config/locales/voice/es.yml @@ -0,0 +1,7 @@ +--- +es: + voice: + otp: + message: "¡Hola! Su código de acceso de login.gov es, %{code}, nuevamente, su + código de acceso es %{code}." + repeat_instructions: Presione 1 para repetir este mensaje. diff --git a/config/locales/voice/fr.yml b/config/locales/voice/fr.yml new file mode 100644 index 00000000000..91322bc05bd --- /dev/null +++ b/config/locales/voice/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + voice: + otp: + message: Bonjour! Votre code de sécurité à utilisation unique de login.gov est, + %{code}, de nouveau, votre code de sécurité est, %{code}, au revoir! + repeat_instructions: Appuyez sur 1 pour répéter votre code. diff --git a/config/routes.rb b/config/routes.rb index 8db34d24ba9..44b8b4464c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,10 @@ match '/api/saml/auth' => 'saml_idp#auth', via: %i[get post] post '/api/service_provider' => 'service_provider#update' + match '/api/voice/otp' => 'voice/otp#show', + via: [:get, :post], + as: :voice_otp, + defaults: { format: :xml } get '/openid_connect/authorize' => 'openid_connect/authorization#index' get '/openid_connect/logout' => 'openid_connect/logout#index' diff --git a/spec/controllers/voice/otp_controller_spec.rb b/spec/controllers/voice/otp_controller_spec.rb new file mode 100644 index 00000000000..419f7c78c50 --- /dev/null +++ b/spec/controllers/voice/otp_controller_spec.rb @@ -0,0 +1,125 @@ +require 'rails_helper' + +RSpec.describe Voice::OtpController do + describe '#show' do + subject(:action) do + get :show, + params: { encrypted_code: encrypted_code, repeat_count: repeat_count, locale: locale }, + format: :xml + end + let(:locale) { nil } + let(:repeat_count) { nil } + let(:cipher) { Gibberish::AES.new(Figaro.env.attribute_encryption_key) } + + context 'with a blank encrypted_code in the URL' do + let(:encrypted_code) { '' } + + it 'renders a blank 400' do + action + + expect(response).to be_bad_request + expect(response.body).to be_empty + end + end + + context 'with an encrypted_code in the URL' do + render_views + + let(:code) { '1234' } + let(:encrypted_code) { cipher.encrypt(code) } + + it 'tells Twilio to the code with pauses in between' do + action + + doc = Nokogiri::XML(response.body) + say = doc.css('Say').first + expect(say.text).to include('1, 2, 3, 4,') + end + + it 'sets the lang attribute to english' do + action + + doc = Nokogiri::XML(response.body) + say = doc.css('Say').first + + expect(say[:language]).to eq('en') + end + + context 'when the locale is in spanish' do + let(:locale) { :es } + + it 'sets the lang attribute to english' do + action + + doc = Nokogiri::XML(response.body) + say = doc.css('Say').first + + expect(say[:language]).to eq('es') + end + + it 'passes locale into the action URL' do + action + + doc = Nokogiri::XML(response.body) + gather = doc.css('Gather').first + + params = URIService.params(gather[:action]) + expect(params[:locale]).to eq('es') + end + end + + context 'when the locale is in french' do + let(:locale) { :fr } + + it 'sets the lang attribute to english' do + action + + doc = Nokogiri::XML(response.body) + say = doc.css('Say').first + + expect(say[:language]).to eq('fr') + end + + it 'passes locale into the action URL' do + action + + doc = Nokogiri::XML(response.body) + gather = doc.css('Gather').first + + params = URIService.params(gather[:action]) + expect(params[:locale]).to eq('fr') + end + end + + it 'has a with instructions to repeat with a repeat_count' do + action + + doc = Nokogiri::XML(response.body) + gather = doc.css('Gather').first + + expect(gather[:action]).to include('repeat_count=4') + end + + it 'puts the encrypted code in the action' do + action + + doc = Nokogiri::XML(response.body) + gather = doc.css('Gather').first + params = URIService.params(gather[:action]) + + expect(cipher.decrypt(params[:encrypted_code])).to eq(code) + end + + context 'when repeat_count counts down to 1' do + let(:repeat_count) { 1 } + + it 'does not have a in the response' do + action + + doc = Nokogiri::XML(response.body) + expect(doc.css('Gather')).to be_empty + end + end + end + end +end diff --git a/spec/jobs/voice_otp_sender_job_spec.rb b/spec/jobs/voice_otp_sender_job_spec.rb index caeb9e2b6d2..294288723e7 100644 --- a/spec/jobs/voice_otp_sender_job_spec.rb +++ b/spec/jobs/voice_otp_sender_job_spec.rb @@ -8,12 +8,16 @@ FakeVoiceCall.calls = [] end + let(:cipher) { Gibberish::AES.new(Figaro.env.attribute_encryption_key) } + it 'initiates the phone call to deliver the OTP', twilio: true do - VoiceOtpSenderJob.perform_now( - code: '1234', - phone: '555-5555', - otp_created_at: Time.zone.now.to_s - ) + I18n.with_locale(:fr) do + VoiceOtpSenderJob.perform_now( + code: '1234', + phone: '555-5555', + otp_created_at: Time.zone.now.to_s + ) + end calls = FakeVoiceCall.calls @@ -22,17 +26,9 @@ expect(call.to).to eq('555-5555') expect(call.from).to match(/(\+19999999999|\+12222222222)/) - code = '1234'.scan(/\d/).join(', ') - query = URIService.params(call.url) - expect(query['Message']).to eq(t('jobs.voice_otp_sender_job.message_repeat', code: code)) - - nested_query = query - while nested_query['Options'] - nested_url = URI(nested_query['Options']['1']) - nested_query = URIService.params(nested_url) - end - expect(nested_query['Message']['0']). - to eq(t('jobs.voice_otp_sender_job.message_final', code: code)) + params = URIService.params(call.url) + expect(cipher.decrypt(params[:encrypted_code])).to eq('1234') + expect(params[:locale]).to eq('fr') end context 'recording calls' do diff --git a/spec/services/basic_auth_url_spec.rb b/spec/services/basic_auth_url_spec.rb new file mode 100644 index 00000000000..a93255e0a34 --- /dev/null +++ b/spec/services/basic_auth_url_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe BasicAuthUrl do + describe '.build' do + it 'is the URL as-is with no username or password' do + url = 'https://foo.example.com/bar' + external_url = BasicAuthUrl.build(url, user: nil, password: nil) + + expect(external_url).to eq(url) + end + + context 'with basic auth username and pass set in the config' do + it 'uses the values in from application.yml/Figaro' do + url = 'https://foo.example.com/bar' + external_url = BasicAuthUrl.build(url) + + expect(external_url).to eq('https://user:secret@foo.example.com/bar') + end + end + end +end