# frozen-string-literal: true
module Rodauth
Feature.define(:sms_codes, :SmsCodes) do
depends :two_factor_base
additional_form_tags 'sms_auth'
additional_form_tags 'sms_confirm'
additional_form_tags 'sms_disable'
additional_form_tags 'sms_request'
additional_form_tags 'sms_setup'
before 'sms_auth'
before 'sms_confirm'
before 'sms_disable'
before 'sms_request'
before 'sms_setup'
after 'sms_confirm'
after 'sms_disable'
after 'sms_failure'
after 'sms_request'
after 'sms_setup'
button 'Authenticate via SMS Code', 'sms_auth'
button 'Confirm SMS Backup Number', 'sms_confirm'
button 'Disable Backup SMS Authentication', 'sms_disable'
button 'Send SMS Code', 'sms_request'
button 'Setup SMS Backup Number', 'sms_setup'
error_flash "Error authenticating via SMS code", 'sms_invalid_code'
error_flash "Error disabling SMS authentication", 'sms_disable'
error_flash "Error setting up SMS authentication", 'sms_setup'
error_flash "Invalid or out of date SMS confirmation code used, must setup SMS authentication again", 'sms_invalid_confirmation_code'
error_flash "No current SMS code for this account", 'no_current_sms_code'
error_flash "SMS authentication has been locked out", 'sms_lockout'
error_flash "SMS authentication has already been setup", 'sms_already_setup'
error_flash "SMS authentication has not been setup yet", 'sms_not_setup'
error_flash "SMS authentication needs confirmation", 'sms_needs_confirmation'
notice_flash "SMS authentication code has been sent", 'sms_request'
notice_flash "SMS authentication has been disabled", 'sms_disable'
notice_flash "SMS authentication has been setup", 'sms_confirm'
translatable_method :sms_auth_link_text, "Authenticate Using SMS Code"
translatable_method :sms_setup_link_text, "Setup Backup SMS Authentication"
translatable_method :sms_disable_link_text, "Disable SMS Authentication"
redirect :sms_already_setup
redirect :sms_confirm
redirect :sms_disable
redirect(:sms_auth){sms_auth_path}
redirect(:sms_needs_confirmation){sms_confirm_path}
redirect(:sms_needs_setup){sms_setup_path}
redirect(:sms_request){sms_request_path}
redirect(:sms_lockout){two_factor_auth_required_redirect}
loaded_templates %w'sms-auth sms-confirm sms-disable sms-request sms-setup sms-code-field password-field'
view 'sms-auth', 'Authenticate via SMS Code', 'sms_auth'
view 'sms-confirm', 'Confirm SMS Backup Number', 'sms_confirm'
view 'sms-disable', 'Disable Backup SMS Authentication', 'sms_disable'
view 'sms-request', 'Send SMS Code', 'sms_request'
view 'sms-setup', 'Setup SMS Backup Number', 'sms_setup'
auth_value_method :sms_already_setup_error_status, 403
auth_value_method :sms_needs_confirmation_error_status, 403
auth_value_method :sms_auth_code_length, 6
auth_value_method :sms_code_allowed_seconds, 300
auth_value_method :sms_code_column, :code
translatable_method :sms_code_label, 'SMS Code'
auth_value_method :sms_code_param, 'sms-code'
auth_value_method :sms_codes_table, :account_sms_codes
auth_value_method :sms_confirm_code_length, 12
auth_value_method :sms_failure_limit, 5
auth_value_method :sms_failures_column, :num_failures
auth_value_method :sms_id_column, :id
translatable_method :sms_invalid_code_message, "invalid SMS code"
translatable_method :sms_invalid_phone_message, "invalid SMS phone number"
auth_value_method :sms_issued_at_column, :code_issued_at
auth_value_method :sms_phone_column, :phone_number
translatable_method :sms_phone_label, 'Phone Number'
auth_value_method :sms_phone_input_type, 'tel'
auth_value_method :sms_phone_min_length, 7
auth_value_method :sms_phone_param, 'sms-phone'
auth_cached_method :sms
auth_value_methods :sms_codes_primary?
auth_methods(
:sms_auth_message,
:sms_available?,
:sms_code_issued_at,
:sms_code_match?,
:sms_confirm_message,
:sms_confirmation_match?,
:sms_current_auth?,
:sms_disable,
:sms_failures,
:sms_locked_out?,
:sms_needs_confirmation?,
:sms_new_auth_code,
:sms_new_confirm_code,
:sms_normalize_phone,
:sms_record_failure,
:sms_remove_failures,
:sms_send,
:sms_set_code,
:sms_setup,
:sms_setup?,
:sms_valid_phone?
)
internal_request_method :sms_setup
internal_request_method :sms_confirm
internal_request_method :sms_request
internal_request_method :sms_auth
internal_request_method :valid_sms_auth?
internal_request_method :sms_disable
route(:sms_request) do |r|
require_login
require_account_session
require_two_factor_not_authenticated('sms_code')
require_sms_available
before_sms_request_route
r.get do
sms_request_view
end
r.post do
transaction do
before_sms_request
sms_send_auth_code
after_sms_request
end
set_notice_flash sms_request_notice_flash
redirect sms_auth_redirect
end
end
route(:sms_auth) do |r|
require_login
require_account_session
require_two_factor_not_authenticated('sms_code')
require_sms_available
unless sms_current_auth?
if sms_code
sms_set_code(nil)
end
set_response_error_reason_status(:no_current_sms_code, invalid_key_error_status)
set_redirect_error_flash no_current_sms_code_error_flash
redirect sms_request_redirect
end
before_sms_auth_route
r.get do
sms_auth_view
end
r.post do
transaction do
if sms_code_match?(param(sms_code_param))
before_sms_auth
sms_remove_failures
two_factor_authenticate('sms_code')
else
sms_record_failure
after_sms_failure
end
end
set_response_error_reason_status(:invalid_sms_code, invalid_key_error_status)
set_field_error(sms_code_param, sms_invalid_code_message)
set_error_flash sms_invalid_code_error_flash
sms_auth_view
end
end
route(:sms_setup) do |r|
require_account
unless sms_codes_primary?
require_two_factor_setup
require_two_factor_authenticated
end
require_sms_not_setup
if sms_needs_confirmation?
set_redirect_error_status(sms_needs_confirmation_error_status)
set_error_reason :sms_needs_confirmation
set_redirect_error_flash sms_needs_confirmation_error_flash
redirect sms_needs_confirmation_redirect
end
before_sms_setup_route
r.get do
sms_setup_view
end
r.post do
catch_error do
unless two_factor_password_match?(param(password_param))
throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message)
end
phone = sms_normalize_phone(param(sms_phone_param))
unless sms_valid_phone?(phone)
throw_error_reason(:invalid_phone_number, invalid_field_error_status, sms_phone_param, sms_invalid_phone_message)
end
transaction do
before_sms_setup
sms_setup(phone)
sms_send_confirm_code
after_sms_setup
end
set_notice_flash sms_needs_confirmation_error_flash
redirect sms_needs_confirmation_redirect
end
set_error_flash sms_setup_error_flash
sms_setup_view
end
end
route(:sms_confirm) do |r|
require_account
unless sms_codes_primary?
require_two_factor_setup
require_two_factor_authenticated
end
require_sms_not_setup
before_sms_confirm_route
r.get do
sms_confirm_view
end
r.post do
if sms_confirmation_match?(param(sms_code_param))
transaction do
before_sms_confirm
sms_confirm
after_sms_confirm
unless two_factor_authenticated?
two_factor_update_session('sms_code')
end
end
set_notice_flash sms_confirm_notice_flash
redirect sms_confirm_redirect
end
sms_confirm_failure
set_redirect_error_status(invalid_key_error_status)
set_error_reason :invalid_sms_confirmation_code
set_redirect_error_flash sms_invalid_confirmation_code_error_flash
redirect sms_needs_setup_redirect
end
end
route(:sms_disable) do |r|
require_account
require_sms_setup
before_sms_disable_route
r.get do
sms_disable_view
end
r.post do
if two_factor_password_match?(param(password_param))
transaction do
before_sms_disable
sms_disable
if two_factor_login_type_match?('sms_code')
two_factor_remove_session('sms_code')
end
after_sms_disable
end
set_notice_flash sms_disable_notice_flash
redirect sms_disable_redirect
end
set_response_error_reason_status(:invalid_password, invalid_password_error_status)
set_field_error(password_param, invalid_password_message)
set_error_flash sms_disable_error_flash
sms_disable_view
end
end
def two_factor_remove
super
sms_disable
end
def two_factor_remove_auth_failures
super
sms_remove_failures
end
def require_sms_setup
unless sms_setup?
set_redirect_error_status(two_factor_not_setup_error_status)
set_error_reason :sms_not_setup
set_redirect_error_flash sms_not_setup_error_flash
redirect sms_needs_setup_redirect
end
end
def require_sms_not_setup
if sms_setup?
set_redirect_error_status(sms_already_setup_error_status)
set_error_reason :sms_already_setup
set_redirect_error_flash sms_already_setup_error_flash
redirect sms_already_setup_redirect
end
end
def require_sms_available
require_sms_setup
if sms_locked_out?
set_redirect_error_status(lockout_error_status)
set_error_reason :sms_locked_out
set_redirect_error_flash sms_lockout_error_flash
redirect sms_lockout_redirect
end
end
def sms_code_match?(code)
return false unless sms_current_auth?
timing_safe_eql?(code, sms_code)
end
def sms_confirmation_match?(code)
sms_needs_confirmation? && sms_code_match?(code)
end
def sms_disable
sms_ds.delete
@sms = nil
end
def sms_confirm_failure
sms_ds.delete
end
def sms_setup(phone_number)
# Cannot handle uniqueness violation here, as the phone number given may not match the
# one in the table.
sms_ds.insert(sms_id_column=>session_value, sms_phone_column=>phone_number)
remove_instance_variable(:@sms) if instance_variable_defined?(:@sms)
end
def sms_remove_failures
update_sms(sms_failures_column => 0, sms_code_column => nil)
end
def sms_confirm
sms_remove_failures
super if defined?(super)
end
def sms_send_auth_code
code = sms_new_auth_code
sms_set_code(code)
sms_send(sms_phone, sms_auth_message(code))
end
def sms_send_confirm_code
code = sms_new_confirm_code
sms_set_code(code)
sms_send(sms_phone, sms_confirm_message(code))
end
def sms_valid_phone?(phone)
phone.length >= sms_phone_min_length
end
def sms_auth_message(code)
"SMS authentication code for #{domain} is #{code}"
end
def sms_confirm_message(code)
"SMS confirmation code for #{domain} is #{code}"
end
def sms_set_code(code)
update_sms(sms_code_column=>code, sms_issued_at_column=>Sequel::CURRENT_TIMESTAMP)
end
def sms_record_failure
update_sms(sms_failures_column=>Sequel.expr(sms_failures_column)+1)
sms[sms_failures_column] = sms_ds.get(sms_failures_column)
end
def sms_phone
sms[sms_phone_column]
end
def sms_code
sms[sms_code_column]
end
def sms_code_issued_at
convert_timestamp(sms[sms_issued_at_column])
end
def sms_failures
sms[sms_failures_column]
end
def sms_setup?
return false unless sms
!sms_needs_confirmation?
end
def sms_needs_confirmation?
sms && sms_failures.nil?
end
def sms_available?
sms_setup? && !sms_locked_out?
end
def sms_locked_out?
sms_failures >= sms_failure_limit
end
def sms_current_auth?
sms_code && sms_code_issued_at + sms_code_allowed_seconds > Time.now
end
def possible_authentication_methods
methods = super
methods << 'sms_code' if sms_setup?
methods
end
private
def _two_factor_auth_links
links = super
links << [30, sms_request_path, sms_auth_link_text] if sms_available?
links
end
def _two_factor_setup_links
links = super
links << [30, sms_setup_path, sms_setup_link_text] if !sms_setup? && (sms_codes_primary? || uses_two_factor_authentication?)
links
end
def _two_factor_remove_links
links = super
links << [30, sms_disable_path, sms_disable_link_text] if sms_setup?
links
end
def _two_factor_remove_all_from_session
two_factor_remove_session('sms_code')
super
end
def sms_codes_primary?
(features & [:otp, :webauthn]).empty?
end
def sms_normalize_phone(phone)
phone.to_s.gsub(/\D+/, '')
end
def sms_new_auth_code
SecureRandom.random_number(10**sms_auth_code_length).to_s.rjust(sms_auth_code_length, "0")
end
def sms_new_confirm_code
SecureRandom.random_number(10**sms_confirm_code_length).to_s.rjust(sms_confirm_code_length, "0")
end
def sms_send(phone, message)
raise NotImplementedError, "sms_send needs to be defined in the Rodauth configuration for SMS sending to work"
end
def update_sms(values)
update_hash_ds(sms, sms_ds, values)
end
def _sms
sms_ds.first
end
def sms_ds
db[sms_codes_table].where(sms_id_column=>session_value)
end
end
end