# frozen-string-literal: true
module Rodauth
Feature.define(:recovery_codes, :RecoveryCodes) do
depends :two_factor_base
additional_form_tags 'recovery_auth'
additional_form_tags 'recovery_codes'
before 'add_recovery_codes'
before 'view_recovery_codes'
before 'recovery_auth'
after 'add_recovery_codes'
button 'Add Authentication Recovery Codes', 'add_recovery_codes'
button 'Authenticate via Recovery Code', 'recovery_auth'
button 'View Authentication Recovery Codes', 'view_recovery_codes'
error_flash "Error authenticating via recovery code", 'invalid_recovery_code'
error_flash "Unable to add recovery codes", 'add_recovery_codes'
error_flash "Unable to view recovery codes", 'view_recovery_codes'
notice_flash "Additional authentication recovery codes have been added", 'recovery_codes_added'
redirect(:recovery_auth){recovery_auth_path}
redirect(:add_recovery_codes){recovery_codes_path}
loaded_templates %w'add-recovery-codes recovery-auth recovery-codes password-field'
view 'add-recovery-codes', 'Authentication Recovery Codes', 'add_recovery_codes'
view 'recovery-auth', 'Enter Authentication Recovery Code', 'recovery_auth'
view 'recovery-codes', 'View Authentication Recovery Codes', 'recovery_codes'
auth_value_method :add_recovery_codes_param, 'add'
translatable_method :add_recovery_codes_heading, '
Add Additional Recovery Codes
'
auth_value_method :auto_add_recovery_codes?, false
auth_value_method :auto_remove_recovery_codes?, false
translatable_method :invalid_recovery_code_message, "Invalid recovery code"
auth_value_method :recovery_codes_limit, 16
auth_value_method :recovery_codes_column, :code
auth_value_method :recovery_codes_id_column, :id
translatable_method :recovery_codes_label, 'Recovery Code'
auth_value_method :recovery_codes_param, 'recovery-code'
auth_value_method :recovery_codes_table, :account_recovery_codes
translatable_method :recovery_auth_link_text, "Authenticate Using Recovery Code"
translatable_method :recovery_codes_link_text, "View Authentication Recovery Codes"
auth_cached_method :recovery_codes
auth_value_methods(
:recovery_codes_primary?
)
auth_methods(
:add_recovery_code,
:can_add_recovery_codes?,
:new_recovery_code,
:recovery_code_match?,
)
internal_request_method :recovery_codes
internal_request_method :recovery_auth
internal_request_method :valid_recovery_auth?
route(:recovery_auth) do |r|
require_login
require_account_session
require_two_factor_setup
require_two_factor_not_authenticated('recovery_code')
before_recovery_auth_route
r.get do
recovery_auth_view
end
r.post do
if recovery_code_match?(param(recovery_codes_param))
before_recovery_auth
two_factor_authenticate('recovery_code')
end
set_response_error_reason_status(:invalid_recovery_code, invalid_key_error_status)
set_field_error(recovery_codes_param, invalid_recovery_code_message)
set_error_flash invalid_recovery_code_error_flash
recovery_auth_view
end
end
route(:recovery_codes) do |r|
require_account
unless recovery_codes_primary?
require_two_factor_setup
require_two_factor_authenticated
end
before_recovery_codes_route
r.get do
recovery_codes_view
end
r.post do
if two_factor_password_match?(param(password_param))
if can_add_recovery_codes?
if param_or_nil(add_recovery_codes_param)
transaction do
before_add_recovery_codes
add_recovery_codes(recovery_codes_limit - recovery_codes.length)
after_add_recovery_codes
end
set_notice_now_flash recovery_codes_added_notice_flash
end
self.recovery_codes_button = add_recovery_codes_button
end
before_view_recovery_codes
add_recovery_codes_view
else
if param_or_nil(add_recovery_codes_param)
set_error_flash add_recovery_codes_error_flash
else
set_error_flash view_recovery_codes_error_flash
end
set_response_error_reason_status(:invalid_password, invalid_password_error_status)
set_field_error(password_param, invalid_password_message)
recovery_codes_view
end
end
end
attr_accessor :recovery_codes_button
def two_factor_remove
super
recovery_codes_remove
end
def otp_add_key
super if defined?(super)
auto_add_missing_recovery_codes
end
def sms_confirm
super if defined?(super)
auto_add_missing_recovery_codes
end
def add_webauthn_credential(_)
super if defined?(super)
auto_add_missing_recovery_codes
end
def recovery_codes_remove
recovery_codes_ds.delete
end
def recovery_code_match?(code)
recovery_codes.each do |s|
if timing_safe_eql?(code, s)
recovery_codes_ds.where(recovery_codes_column=>code).delete
if recovery_codes_primary?
add_recovery_code
end
return true
end
end
false
end
def can_add_recovery_codes?
recovery_codes.length < recovery_codes_limit
end
def add_recovery_codes(number)
return if number <= 0
transaction do
number.times do
add_recovery_code
end
end
remove_instance_variable(:@recovery_codes)
end
def add_recovery_code
# This should never raise uniqueness violations unless the recovery code is the same, and the odds of that
# are 1/256**32 assuming a good random number generator. Still, attempt to handle that case by retrying
# on such a uniqueness violation.
retry_on_uniqueness_violation do
recovery_codes_ds.insert(recovery_codes_id_column=>session_value, recovery_codes_column=>new_recovery_code)
end
end
def possible_authentication_methods
methods = super
methods << 'recovery_code' unless recovery_codes_ds.empty?
methods
end
private
def _two_factor_auth_links
links = super
links << [40, recovery_auth_path, recovery_auth_link_text] unless recovery_codes_ds.empty?
links
end
def _two_factor_setup_links
links = super
links << [40, recovery_codes_path, recovery_codes_link_text] if (recovery_codes_primary? || uses_two_factor_authentication?)
links
end
def _two_factor_remove_all_from_session
two_factor_remove_session('recovery_code')
super
end
def after_otp_disable
super if defined?(super)
auto_remove_recovery_codes
end
def after_sms_disable
super if defined?(super)
auto_remove_recovery_codes
end
def after_webauthn_remove
super if defined?(super)
auto_remove_recovery_codes
end
def new_recovery_code
random_key
end
def recovery_codes_primary?
(features & [:otp, :sms_codes, :webauthn]).empty?
end
def auto_add_missing_recovery_codes
if auto_add_recovery_codes?
add_recovery_codes(recovery_codes_limit - recovery_codes.length)
end
end
def auto_remove_recovery_codes
if auto_remove_recovery_codes? && (%w'totp webauthn sms_code' & possible_authentication_methods).empty?
recovery_codes_remove
end
end
def _recovery_codes
recovery_codes_ds.select_map(recovery_codes_column)
end
def recovery_codes_ds
db[recovery_codes_table].where(recovery_codes_id_column=>session_value)
end
end
end