lib/rodauth/features/lockout.rb



# frozen-string-literal: true

module Rodauth
  Lockout = Feature.define(:lockout) do
    depends :login, :email_base

    view 'unlock-account-request', 'Request Account Unlock', 'unlock_account_request'
    view 'unlock-account', 'Unlock Account', 'unlock_account'
    before 'unlock_account'
    before 'unlock_account_request'
    after 'unlock_account'
    after 'unlock_account_request'
    additional_form_tags 'unlock_account'
    additional_form_tags 'unlock_account_request'
    button 'Unlock Account', 'unlock_account'
    button 'Request Account Unlock', 'unlock_account_request'
    error_flash "There was an error unlocking your account", 'unlock_account'
    error_flash "This account is currently locked out and cannot be logged in to.", "login_lockout"
    notice_flash "Your account has been unlocked", 'unlock_account'
    notice_flash "An email has been sent to you with a link to unlock your account", 'unlock_account_request'
    redirect :unlock_account
    redirect :unlock_account_request
      
    auth_value_method :unlock_account_autologin?, true
    auth_value_method :max_invalid_logins, 100
    auth_value_method :account_login_failures_table, :account_login_failures
    auth_value_method :account_login_failures_id_column, :id
    auth_value_method :account_login_failures_number_column, :number
    auth_value_method :account_lockouts_table, :account_lockouts
    auth_value_method :account_lockouts_id_column, :id
    auth_value_method :account_lockouts_key_column, :key
    auth_value_method :account_lockouts_deadline_column, :deadline
    auth_value_method :account_lockouts_deadline_interval, {:days=>1}
    auth_value_method :no_matching_unlock_account_key_message, 'No matching unlock account key'
    auth_value_method :unlock_account_email_subject, 'Unlock Account'
    auth_value_method :unlock_account_key_param, 'key'
    auth_value_method :unlock_account_requires_password?, false

    auth_value_methods(
      :unlock_account_redirect,
      :unlock_account_request_redirect
    )
    auth_methods(
      :clear_invalid_login_attempts,
      :create_unlock_account_email,
      :generate_unlock_account_key,
      :get_unlock_account_key,
      :invalid_login_attempted,
      :locked_out?,
      :send_unlock_account_email,
      :unlock_account_email_body,
      :unlock_account_email_link,
      :unlock_account,
      :unlock_account_key
    )
    auth_private_methods :account_from_unlock_key

    route(:unlock_account_request) do |r|
      check_already_logged_in
      before_unlock_account_request_route

      r.post do
        if account_from_login(param(login_param)) && get_unlock_account_key
          transaction do
            before_unlock_account_request
            send_unlock_account_email
            after_unlock_account_request
          end

          set_notice_flash unlock_account_request_notice_flash
        else
          set_redirect_error_flash no_matching_login_message
        end

        redirect unlock_account_request_redirect
      end
    end

    route(:unlock_account) do |r|
      check_already_logged_in
      before_unlock_account_route

      r.get do
        if account_from_unlock_key(param(unlock_account_key_param))
          unlock_account_view
        else
          set_redirect_error_flash no_matching_unlock_account_key_message
          redirect require_login_redirect
        end
      end

      r.post do
        key = param(unlock_account_key_param)
        unless account_from_unlock_key(key)
          set_redirect_error_flash no_matching_unlock_account_key_message
          redirect unlock_account_request_redirect
        end

        if !unlock_account_requires_password? || password_match?(param(password_param))
          transaction do
            before_unlock_account
            unlock_account
            after_unlock_account
            if unlock_account_autologin?
              update_session
            end
          end

          set_notice_flash unlock_account_notice_flash
          redirect unlock_account_redirect
        else
          set_field_error(password_param, invalid_password_message)
          set_error_flash unlock_account_error_flash
          unlock_account_view
        end
      end
    end

    def locked_out?
      if t = convert_timestamp(account_lockouts_ds.get(account_lockouts_deadline_column))
        if Time.now < t
          true
        else
          unlock_account
          false
        end
      else
        false
      end
    end

    def unlock_account
      transaction do
        remove_lockout_metadata
      end
    end

    def clear_invalid_login_attempts
      unlock_account
    end

    def invalid_login_attempted
      ds = account_login_failures_ds.
          where(account_login_failures_id_column=>account_id)

      number = if db.database_type == :postgres
        ds.returning(account_login_failures_number_column).
          with_sql(:update_sql, account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1).
          single_value
      else
        # :nocov:
        if ds.update(account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1) > 0
          ds.get(account_login_failures_number_column)
        end
        # :nocov:
      end

      unless number
        # Ignoring the violation is safe here.  It may allow slightly more than max_invalid_logins invalid logins before
        # lockout, but allowing a few extra is OK if the race is lost.
        ignore_uniqueness_violation{account_login_failures_ds.insert(account_login_failures_id_column=>account_id)}
        number = 1
      end

      if number >= max_invalid_logins
        @unlock_account_key_value = generate_unlock_account_key
        hash = {account_lockouts_id_column=>account_id, account_lockouts_key_column=>unlock_account_key_value}
        set_deadline_value(hash, account_lockouts_deadline_column, account_lockouts_deadline_interval)

        if e = raised_uniqueness_violation{account_lockouts_ds.insert(hash)}
          # If inserting into the lockout table raises a violation, we should just be able to pull the already inserted
          # key out of it.  If that doesn't return a valid key, we should reraise the error.
          raise e unless @unlock_account_key_value = account_lockouts_ds.get(account_lockouts_key_column)

          show_lockout_page
        end
      end
    end

    def get_unlock_account_key
      account_lockouts_ds.get(account_lockouts_key_column)
    end

    def account_from_unlock_key(key)
      @account = _account_from_unlock_key(key)
    end

    def send_unlock_account_email
      @unlock_account_key_value = get_unlock_account_key
      create_unlock_account_email.deliver!
    end

    def unlock_account_email_link
      token_link(unlock_account_route, unlock_account_key_param, unlock_account_key_value)
    end

    private

    attr_reader :unlock_account_key_value

    def before_login_attempt
      if locked_out?
        show_lockout_page
      end
      super
    end

    def after_login
      clear_invalid_login_attempts
      super
    end

    def after_login_failure
      invalid_login_attempted
      super
    end

    def after_close_account
      remove_lockout_metadata
      super if defined?(super)
    end

    def generate_unlock_account_key
      random_key
    end

    def remove_lockout_metadata
      account_login_failures_ds.delete
      account_lockouts_ds.delete
    end

    def show_lockout_page
      set_error_flash login_lockout_error_flash
      response.write unlock_account_request_view
      request.halt
    end

    def create_unlock_account_email
      create_email(unlock_account_email_subject, unlock_account_email_body)
    end

    def unlock_account_email_body
      render('unlock-account-email')
    end

    def use_date_arithmetic?
      db.database_type == :mysql
    end

    def account_login_failures_ds
      db[account_login_failures_table].where(account_login_failures_id_column=>account_id)
    end

    def account_lockouts_ds(id=account_id)
      db[account_lockouts_table].where(account_lockouts_id_column=>id)
    end

    def _account_from_unlock_key(token)
      account_from_key(token){|id| account_lockouts_ds(id).get(account_lockouts_key_column)}
    end
  end
end