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
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ def show
service_provider: current_sp,
remember_device_default: remember_device_default,
)
@backup_code_form = BackupCodeVerificationForm.new(current_user)
@backup_code_form = BackupCodeVerificationForm.new(user: current_user, request:)
end

def create
@backup_code_form = BackupCodeVerificationForm.new(current_user)
@backup_code_form = BackupCodeVerificationForm.new(user: current_user, request:)
result = @backup_code_form.submit(backup_code_params)
handle_result(result)
end
Expand Down
43 changes: 36 additions & 7 deletions app/forms/backup_code_verification_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@
class BackupCodeVerificationForm
include ActiveModel::Model
include ActionView::Helpers::TranslationHelper
include DOTIW::Methods

validate :validate_rate_limited
validate :validate_and_consume_backup_code!

def initialize(user)
attr_reader :user, :backup_code, :request

def initialize(user:, request:)
@user = user
@backup_code = ''
@request = request
end

def submit(params)
@backup_code = params[:backup_code]

rate_limiter.increment!

FormResponse.new(
success: valid?,
errors:,
Expand All @@ -21,10 +28,22 @@ def submit(params)
)
end

attr_reader :user, :backup_code
private

def validate_rate_limited
return if !rate_limiter.limited?
errors.add(
:backup_code,
:rate_limited,
message: t(
'errors.messages.phone_confirmation_limited',
timeout: distance_of_time_in_words(Time.zone.now, rate_limiter.expires_at),
),
)
end

def validate_and_consume_backup_code!
return if valid_backup_code?
return if rate_limiter.limited? || valid_backup_code?
errors.add(:backup_code, :invalid, message: t('two_factor_authentication.invalid_backup_code'))
end

Expand All @@ -38,9 +57,19 @@ def valid_backup_code_config_created_at
if_valid_consume_code_return_config_created_at(backup_code)
end

def rate_limiter
@rate_limiter ||= RateLimiter.new(
rate_limit_type: :backup_code_user_id_per_ip,
target: [user.id, request.ip].join('-'),
)
end

def extra_analytics_attributes
{
multi_factor_auth_method_created_at: valid_backup_code_config_created_at&.strftime('%s%L'),
}
{ multi_factor_auth_method_created_at: }
end

def multi_factor_auth_method_created_at
return nil if !valid?
valid_backup_code_config_created_at.strftime('%s%L')
end
end
8 changes: 8 additions & 0 deletions app/services/rate_limiter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ def self.load_rate_limit_config
IdentityConfig.store.sign_in_user_id_per_ip_attempt_window_exponential_factor,
attempt_window_max: IdentityConfig.store.sign_in_user_id_per_ip_attempt_window_max_minutes,
},
backup_code_user_id_per_ip: {
max_attempts: IdentityConfig.store.backup_code_user_id_per_ip_max_attempts,
attempt_window: IdentityConfig.store.backup_code_user_id_per_ip_attempt_window_in_minutes,
attempt_window_exponential_factor:
IdentityConfig.store.backup_code_user_id_per_ip_attempt_window_exponential_factor,
attempt_window_max:
IdentityConfig.store.backup_code_user_id_per_ip_attempt_window_max_minutes,
},
}.with_indifferent_access
end

Expand Down
4 changes: 4 additions & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ aws_kms_session_key_id: alias/login-dot-gov-test-keymaker
aws_logo_bucket: ''
aws_region: 'us-west-2'
backup_code_cost: '2000$8$1$'
backup_code_user_id_per_ip_attempt_window_exponential_factor: 1.1
backup_code_user_id_per_ip_attempt_window_in_minutes: 720
backup_code_user_id_per_ip_attempt_window_max_minutes: 43_200
backup_code_user_id_per_ip_max_attempts: 50
biometric_ial_enabled: true
broken_personal_key_window_finish: '2021-09-22T00:00:00Z'
broken_personal_key_window_start: '2021-07-29T00:00:00Z'
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ errors.manage_authenticator.remove_only_method_error: You cannot remove your onl
errors.manage_authenticator.unique_name_error: Name already in use. Please use a different name.
errors.max_password_attempts_reached: You’ve entered too many incorrect passwords. You can reset your password using the “Forgot your password?” link.
errors.messages.already_confirmed: was already confirmed, please try signing in
errors.messages.backup_code_limited: You tried too many times, please try again in %{timeout}.
errors.messages.blank: Please fill in this field.
errors.messages.blank_cert_element_req: We cannot detect a certificate in your request.
errors.messages.confirmation_code_incorrect: Incorrect verification code
Expand Down
1 change: 1 addition & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ errors.manage_authenticator.remove_only_method_error: No puede eliminar su únic
errors.manage_authenticator.unique_name_error: Ese nombre ya está en uso. Use un nombre diferente.
errors.max_password_attempts_reached: Ingresó demasiadas contraseñas incorrectas. Puede restablecer su contraseña usando el vínculo “¿Olvidó su contraseña?”.
errors.messages.already_confirmed: ya estaba confirmado, intente iniciar una sesión
errors.messages.backup_code_limited: Lo intentó demasiadas veces; vuelva a intentarlo en %{timeout}.
errors.messages.blank: Llene este campo.
errors.messages.blank_cert_element_req: No podemos detectar un certificado en su solicitud.
errors.messages.confirmation_code_incorrect: Código de verificación incorrecto
Expand Down
1 change: 1 addition & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ errors.manage_authenticator.remove_only_method_error: Vous ne pouvez pas supprim
errors.manage_authenticator.unique_name_error: Ce nom est déjà pris. Veuillez utiliser un nom différent.
errors.max_password_attempts_reached: Vous avez saisi des mots de passe inexacts à trop de reprises. Vous pouvez réinitialiser votre mot de passe en utilisant le lien « Mot de passe oublié ? »
errors.messages.already_confirmed: a déjà été confirmé, veuillez essayer de vous connecter
errors.messages.backup_code_limited: Vous avez essayé trop de fois, veuillez réessayer dans %{timeout}.
errors.messages.blank: Veuillez remplir ce champ.
errors.messages.blank_cert_element_req: Nous ne pouvons pas détecter un certificat sur votre demande.
errors.messages.confirmation_code_incorrect: Code de vérification incorrect
Expand Down
1 change: 1 addition & 0 deletions config/locales/zh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ errors.manage_authenticator.remove_only_method_error: 你不能去掉自己唯
errors.manage_authenticator.unique_name_error: 名字已在使用。请使用一个不同的名字。
errors.max_password_attempts_reached: 你输入了太多不正确的密码。你可以使用“忘了密码?”链接来重设密码。
errors.messages.already_confirmed: 已确认,请尝试登录
errors.messages.backup_code_limited: 你尝试了太多次。请在 %{timeout}后再试。
errors.messages.blank: 请填写这一字段。
errors.messages.blank_cert_element_req: 我们在你的请求中探查不到证书。
errors.messages.confirmation_code_incorrect: 验证码不对。你打字打对了吗?
Expand Down
4 changes: 4 additions & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def self.store
config.add(:aws_logo_bucket, type: :string)
config.add(:aws_region, type: :string)
config.add(:backup_code_cost, type: :string)
config.add(:backup_code_user_id_per_ip_attempt_window_exponential_factor, type: :float)
config.add(:backup_code_user_id_per_ip_attempt_window_in_minutes, type: :integer)
config.add(:backup_code_user_id_per_ip_attempt_window_max_minutes, type: :integer)
config.add(:backup_code_user_id_per_ip_max_attempts, type: :integer)
config.add(:biometric_ial_enabled, type: :boolean)
config.add(:broken_personal_key_window_finish, type: :timestamp)
config.add(:broken_personal_key_window_start, type: :timestamp)
Expand Down
90 changes: 87 additions & 3 deletions spec/forms/backup_code_verification_form_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
require 'rails_helper'

RSpec.describe BackupCodeVerificationForm do
subject(:result) { described_class.new(user).submit(params).to_h }
include DOTIW::Methods

subject(:result) { form.submit(params) }

let(:form) { described_class.new(user:, request:) }
let(:user) { create(:user) }
let(:request) { FakeRequest.new }
let(:backup_codes) { BackupCodeGenerator.new(user).delete_and_regenerate }
let(:backup_code_config) do
BackupCodeConfiguration.find_with_code(code: code, user_id: user.id)
Expand All @@ -16,7 +20,7 @@
let(:code) { backup_codes.first }

it 'returns success' do
expect(result).to eq(
expect(result.to_h).to eq(
success: true,
multi_factor_auth_method_created_at: backup_code_config.created_at.strftime('%s%L'),
)
Expand All @@ -34,12 +38,92 @@
let(:code) { 'invalid' }

it 'returns failure' do
expect(result).to eq(
expect(result.first_error_message).to eq(t('two_factor_authentication.invalid_backup_code'))
expect(result.to_h).to eq(
success: false,
error_details: { backup_code: { invalid: true } },
multi_factor_auth_method_created_at: nil,
)
end
end

describe 'rate limiting', :freeze_time do
before do
allow(RateLimiter).to receive(:rate_limit_config).and_return(
backup_code_user_id_per_ip: {
max_attempts: 2,
attempt_window: 60,
attempt_window_exponential_factor: 3,
attempt_window_max: 12.hours.in_minutes,
},
)
end

context 'before hitting rate limit' do
context 'with an invalid code' do
let(:code) { 'invalid' }

it 'returns failure due to invalid code' do
expect(result.first_error_message).to eq(
t('two_factor_authentication.invalid_backup_code'),
)
expect(result.to_h).to eq(
success: false,
error_details: { backup_code: { invalid: true } },
multi_factor_auth_method_created_at: nil,
)
end
end
end

context 'after hitting rate limit' do
before do
form.submit(params.merge(backup_code: 'invalid'))
end

context 'with an invalid code' do
let(:code) { 'invalid' }

it 'returns failure due to rate limiting' do
expect(result.first_error_message).to eq(
t(
'errors.messages.phone_confirmation_limited',
timeout: distance_of_time_in_words(3.hours),
),
)
expect(result.to_h).to eq(
success: false,
error_details: { backup_code: { rate_limited: true } },
multi_factor_auth_method_created_at: nil,
)
end
end

context 'with a valid code' do
let(:code) { backup_codes.first }

it 'returns failure due to rate limiting' do
expect(result.first_error_message).to eq(
t(
'errors.messages.phone_confirmation_limited',
timeout: distance_of_time_in_words(3.hours),
),
)
expect(result.to_h).to eq(
success: false,
error_details: { backup_code: { rate_limited: true } },
multi_factor_auth_method_created_at: nil,
)
end

it 'does not consume code' do
result

configuration = BackupCodeConfiguration.find_with_code(code:, user_id: user.id)
expect(configuration.used_at).to be_blank
end
end
end
end
end
end
4 changes: 4 additions & 0 deletions spec/support/fake_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ def initialize(headers: {})
@headers = headers
end

def ip
'127.0.0.1'
end

def remote_ip
'127.0.0.1'
end
Expand Down