Skip to content
This repository has been archived by the owner on Feb 6, 2024. It is now read-only.

External authenticator implementation (Issue #32) #98

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

craigweston
Copy link

I have implemented external authenticator support for CASino based on the description you provided on Issue 32:

pencil commented on Mar 19, 2014
No, it does not fit into the current CASino workflow. The best way would probably be to start off with an > "external authenticator" abstraction. Then display a button for all external authenticators at the login page.

My needs were specifically for Facebook authentication using their JavaScript SDK. I have created a Facebook authenticator to work with this external authentication implementation, which you can view here.

Configuration:

I have added a new section to the cas.yml file which mimics the existing authenticator section.

An example of this:

  external_authenticators:
    facebook:
      authenticator: "Facebook"
      options:
        connection:
          adapter: "mysql2"
          host: "localhost"
          username: "username"
          password: "password"
          database: "CASinoApp"
        app_id: "111111111111"
        app_secret: “11111111111111111”
        user_table: "users"
        username_column: "username"
        facebook_id_column: "facebook_id"

Implementation:

The external authenticators were designed with the expectation that each will provide a view and a validation function.

External authenticator base class:

  class ExternalAuthenticator
    def validate(params, cookies)
      raise NotImplementedError, "This method must be implemented by a class extending #{self.class}"
    end
    def view
      raise NotImplementedError, "This method must be implemented by a class extending #{self.class}"
    end
  end

View:

The view function provides the path to the view, used for rendering the button on the login page.

An example of this taken from my casino_facebook-authenticator:

  def view
    @options[:view] || '/facebook_login.html.erb'
  end

Because the view is embedded in the external authenticator itself, it is expected that the external authenticators will be implemented as minimal Rails Engines.

Validation

Unlike the current validation function that relies on a username and password, I have made the external authenticator's validation function require request parameters and cookies. I figured this would provide the most flexibility while still providing the necessary data to support most authentication services.

  # @param [Hash] parameters
  # @param [Hash] cookies
  def validate(params, cookies)
  ...
  end

As an example, my Facebook authenticator expects the access token to be generated on the client side and then passed via POST parameters to the server for server side verification. The assumption is that other external authenticators would use a similar client side approach for generating the necessary authentication tokens or cookies required.

Processor Concern - Authentication

In order to support this new validation functionality, I had to make changes to the app/processors/casino/processor_concern/authentication.rb.

There are now two different validation functions:

      def validate_login_credentials(username, password)
        validate :authenticators do |authenticator_name, authenticator|
          authenticator.validate(username, password)
        end
      end

      def validate_external_credentials(params, cookies)
        validate :external_authenticators do |authenticator_name, authenticator|
          if authenticator_name == params[:external]
            authenticator.validate(params, cookies)
          end
        end
      end

You'll notice that the validate_external_credentials only validates for the authenticator that was submitted via the external parameter.

The common functionality has been placed in the validate function:

      def validate(type, &validator)
        authentication_result = nil
        authenticators(type).each do |authenticator_name, authenticator|
          begin
            data = validator.call(authenticator_name, authenticator)
          rescue CASino::Authenticator::AuthenticatorError => e
            Rails.logger.error "Authenticator '#{authenticator_name}' (#{authenticator.class}) raised an error: #{e}"
          end
          if data
            authentication_result = { authenticator: authenticator_name, user_data: data }
            Rails.logger.info("Credentials for username '#{data[:username]}' successfully validated using authenticator '#{authenticator_name}' (#{authenticator.class})")
            break
          end
        end
        authentication_result
      end

Because there are now two different types of authenticators, I had to also change the authenticators method signature to take an authenticator type. The cached @authenticators has also been changed to be a hash, which is keyed on authenticator type.

      def authenticators(type)
        @authenticators ||= {}
        return @authenticators[type] if @authenticators.has_key?(type)
        @authenticators[type] = begin
          CASino.config[type].each do |name, auth|
            next unless auth.is_a?(Hash)

            authenticator = if auth[:class]
              auth[:class].constantize
            else
              load_authenticator(auth[:authenticator])
            end

            CASino.config[type][name] = authenticator.new(auth[:options])
          end
        end
      end

Login Credential Acceptor Processor

The trigger that is used to know when to invoke an external authenticator or a regular authenticator happens based on whether the external parameter is submitted. This check happens in the /login_credential_acceptor_processor.rb.

  def validate_credentials
    if @params[:external]
      validate_external_credentials(@params, @cookies)
    else
      validate_login_credentials(@params[:username], @params[:password])
    end
  end

Login Page

The external authenticator buttons are rendered on the login page within an inline list. Each view is wrapped in its own form, containing the login ticket and external hidden inputs.

      <li>
        <%= form_tag(login_path, method: :post) do %>
          <%= hidden_field_tag :lt, @login_ticket.ticket %>
          <%= hidden_field_tag :external, authenticator_name %>
          <%= render authenticator.view %>
        <% end %>
      </li>

The idea being that each authenticator will submit the wrapping form when necessary, including any additional data that may be needed on the server side.

As an example, my Facebook authenticator submits the form via JavaScript after the Facebook login process has completed and the access token has been added to the form dynamically as a hidden input.

External Authenticator List

Because the view needs to render a view for each of the authenticators, I pass a list of external authenticators to the view via the @external_authenticators. This list is passed from the processor, to the listener and then to the view. This is not ideal, as it does introduce some repetitiveness in the processor, but I needed to be able to reuse the processor_concern/authentication.rb's authenticators method, and therefore keeping this call in the processor made sense.

Example (login_credential_requestor_processor.rb):

  def handle_not_logged_in
      ...
      login_ticket = acquire_login_ticket
      external_authenticators = authenticators(:external_authenticators)
      @listener.user_not_logged_in(login_ticket, external_authenticators)
      ...
  end

Please let me know if you have any questions, concerns or changes. I look forward to hearing your input.

Thank you.

@pencil
Copy link
Member

pencil commented Apr 6, 2015

Thanks. I will look into this the coming days.

@pencil
Copy link
Member

pencil commented Apr 14, 2015

The problem I see with the proposed solution is, that there is no way to connect an arbitrary CASino account with an external connector. Ideally, the user flow would be as follows:

  1. User logs in using a "normal" authenticator (LDAP, ActiveRecord, …)
  2. User connects the CASino account with any external authenticator (Facebook, Twitter, …)
  3. Future logins can be performed by either using the normal authenticators or the external authenticators

That way the external authenticators would not depend on a specific normal authenticator.

Thoughts?

@craigweston
Copy link
Author

Thanks for looking at this.

The username seems to be the common piece of information that could be used to connect the external authenticator to another CASino authenticator account.

The external authenticator should map the "normal" authenticator's username to the external account specific identifier.

Once logged in via the normal authenticator, this mapping could be setup by clicking a "connect" button on the sessions screen. It would then be up to the external authenticator to implement a function for handling this and creating the necessary mapping based on the currently logged in username.

As an example, the Facebook authenticator maps Facebook ID to a username. Facebook specific data is returned via the extra attributes when logging in with Facebook, but it is the mapped username that is returned as the username in the user_data:

  def user_data(user, user_access_token)
    { username: user.send(@username_column), extra_attributes: extra_attributes(user, user_access_token) }
  end

There is also an option to return extra attributes from columns in the mapped user table (the "normal" authenticator). I'm not sure how this would work with something like LDAP, since the LDAP information is attained upon login and not stored in the database.

Let me know your thoughts and whether you think this would be enough to link the accounts.

@trkrameshkumar
Copy link

Hi @pencil ,

Dose this PR in consideration, this seems to have very good feature, are you considering it to merge?

@trkrameshkumar
Copy link

Hi @craigweston ,

This branch has merge conflict, can you look in to it?

@ZPVIP
Copy link
Contributor

ZPVIP commented Jun 19, 2016

@trkrameshkumar
I did the work:
ZPVIP@23b9fc7

@yzhanginwa
Copy link

Any updates on this PR? I planned to make an external authenticator for WeChat. This PR might help a lot.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants