require 'radiustar'
require 'devise/strategies/radius_authenticatable'

module Devise
  module Models
    # The RadiusAuthenticatable module is responsible for validating a user's credentials
    # against the configured radius server.  When authentication is successful, the
    # attributes returned by the radius server are made available via the
    # +radius_attributes+ accessor in the user model.
    #
    # The RadiusAuthenticatable module works by using the configured
    # +radius_uid_generator+ to generate a UID based on the username and the radius server
    # hostname or IP address.  This UID is used to see if an existing record representing
    # the user already exists.  If it does, radius authentication proceeds through that
    # user record.  Otherwise, a new user record is built and authentication proceeds.
    # If authentication is successful, the +after_radius_authentication+ callback is
    # invoked, the default implementation of which simply saves the user record with
    # validations disabled.
    #
    # The radius username is extracted from the parameters hash by using the first
    # configured value in the +Devise.authentication_keys+ array.  If the authentication
    # key is in the list of case insensitive keys, the username will be converted to
    # lowercase prior to authentication.
    #
    # == Options
    #
    # RadiusAuthenticable adds the following options to devise_for:
    # * +radius_server+: The hostname or IP address of the radius server.
    # * +radius_servers+: An array of hostnames or IP addresses for radius servers,
    #    with optional port.
    # * +radius_server_port+: The port the radius server is listening on.
    # * +radius_server_secret+: The shared secret configured on the radius server.
    # * +radius_server_timeout+: The number of seconds to wait for a response from the
    #   radius server.
    # * +radius_server_retries+: The number of times to retry a request to the radius
    #   server.
    # * +radius_uid_field+: The database column to store the UID in
    # * +radius_uid_generator+: A proc that takes the username and server as parameters
    #   and returns a string representing the UID
    # * +radius_dictionary_path+: The path containing the radius dictionary files to load
    # * +handle_radius_timeout_as_failure+: Option to handle radius timeout as authentication failure
    #
    # == Callbacks
    #
    # The +after_radius_authentication+ callback is invoked on the user record when
    # radius authentication succeeds for that user but prior to Devise checking if the
    # user is active for authentication.  Its default implementation simply saves the
    # user record with validations disabled.  This method should be overriden if further
    # actions should be taken to make the user valid or active for authentication.  If
    # you override it, be sure to either call super to save the record or to save the
    # record yourself.
    #
    # Authorization callbacks are triggered when +after_radius_authentication is
    # called:
    # * +before_radius_authorization :method_name
    # * +around_radius_authorization :method_name
    # * +after_radius_authorization :method_name
    module RadiusAuthenticatable
      extend ActiveSupport::Concern

      ACCESS_ACCEPT = 'Access-Accept'

      included do
        attr_accessor :radius_attributes
        define_model_callbacks :radius_authorization
      end

      # Use the currently configured radius server to attempt to authenticate the
      # supplied username and password.  If authentication succeeds, make the radius
      # attributes returned by the server available via the radius_attributes accessor.
      # Returns true if authentication was successful and false otherwise.
      #
      # Parameters::
      # * +username+: The username to send to the radius server
      # * +password+: The password to send to the radius server
      def valid_radius_password?(username, password)
        reply   = nil
        secret  = self.class.radius_server_secret
        options = {
          reply_timeout:  self.class.radius_server_timeout,
          retries_number: self.class.radius_server_retries
        }

        if self.class.radius_dictionary_path
          options[:dict] = Radiustar::Dictionary.new(self.class.radius_dictionary_path)
        end

        self.class.radius_servers_with_ports.each do |server, port|
          req = Radiustar::Request.new("#{server}:#{port}", options)

          # The authenticate method will raise a RuntimeError if we time
          # out waiting for a response from the server. If the server responds,
          # break and process the radius response. If not, try the next server.
          begin
            reply = req.authenticate(username, password, secret)
            break
          rescue
            next
          end
        end

        # Handle the error if no servers respond.
        unless reply
          return false if self.class.handle_radius_timeout_as_failure
          raise
        end

        if reply[:code] == ACCESS_ACCEPT
          reply.extract!(:code)
          self.radius_attributes = reply
          true
        else
          false
        end
      end

      # Callback invoked by the RadiusAuthenticatable strategy after
      # authentication with the radius server has succeeded and devise has
      # indicated the model is valid. This callback is invoked prior to devise
      # checking if the model is active for authentication.
      def after_radius_authentication
        run_callbacks :radius_authorization do
          self.save(validate: false)
        end
      end

      module ClassMethods

        Devise::Models.config(self, :radius_server, :radius_servers, :radius_server_port,
                              :radius_server_secret, :radius_server_timeout,
                              :radius_server_retries, :radius_uid_field,
                              :radius_uid_generator, :radius_dictionary_path,
                              :handle_radius_timeout_as_failure)

        # Invoked by the RadiusAuthenticatable stratgey to perform the authentication
        # against the radius server.  The username is extracted from the authentication
        # hash and a UID is generated from the username and server IP.  We then search
        # for an existing resource using the UID and configured UID field.  If no resource
        # is found, a new resource is built (not created).  If authentication is
        # successful the callback is responsible for saving the resource.  Returns the
        # resource if authentication succeeds and nil if it does not.
        def find_for_radius_authentication(authentication_hash)
          uid_field = self.radius_uid_field.to_sym
          username, password = radius_credentials(authentication_hash)
          uid = self.radius_uid_generator.call(username, self.radius_server)

          resource = find_for_authentication({ uid_field => uid }) ||
            new(uid_field => uid)

          resource.valid_radius_password?(username, password) ? resource : nil
        end

        # Extract the username and password from the supplied authentication hash.  The
        # username is extracted using the first value from +Devise.authentication_keys+.
        # The username is converted to lowercase if the authentication key is in the list
        # of case insensitive keys configured for Devise.
        def radius_credentials(authentication_hash)
          key = self.authentication_keys.first
          value = authentication_hash[key]
          value.downcase! if (self.case_insensitive_keys || []).include?(key)

          [value, authentication_hash[:password]]
        end

        # Returns an enumerable that provides each server with it's configured
        # port. If no port is given with the server config, the default
        # +radius_server_port is used.
        def radius_servers_with_ports
          @radius_servers_with_ports ||= begin
            retval   = []
            servers  = self.radius_servers
            servers += [self.radius_server] if self.radius_server

            servers.each do |server|
              server, port = server.split(':')
              port ||= self.radius_server_port

              retval << [server, port]
            end

            retval
          end
        end
      end
    end
  end
end
