lib/rodauth/features/sms_codes.rb



# 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