An Alternative to the Facebook Registration Tool

Comments

With the recent announcement of the Facebook registration tool, there's no doubt that you, the intrepid Rails developer, will be looking into it as a possible means of new user signup, either by choice or by client request. My preference has always been to limit an app's dependence on Facebook as much as possible. I guess it's a holdover from my hardcore OSS zealot days, or something -- though I never had a hacker beard, I can't help feeling there's something inherently evil about Facebook (apart from their love affair with PHP ;)). Anyway, I recently implemented something very much like this feature using Devise and OmniAuth, so I thought I'd share.

Is this for me?

Before we get started, a quick pro/con list, because everyone loves pro/con lists.

Registration Tool

Pros:

  • Facebook look and feel
  • Pre-filled responses from FB profile
  • One click
  • Built-in type-ahead field support

Cons:

  • Facebook look and feel
  • Facebook server dependence (unless you code a custom fallback form)
  • (Related to above) slower loading of registration form
  • All registration data (even non-FB prefills) pass through FB servers
  • Limited input types for custom fields
  • Rigid presentation options for custom fields

The alternative

Pros:

  • Complete control over look and feel
  • Pre-filled responses from FB profile
  • Pre-filled responses from any other OmniAuth-supported source
  • Faster load time (for non-FB signups)
  • Don't feed non-FB info to the Zuck

Cons:

  • Two clicks

Laying the groundwork

So, for the sake of space, we're going to assume you've got some basic understanding of how to set up Devise in your application. If you don't, the relatively terse instructions that follow may not make much sense.

At the time of this writing, you'll need the current master branch of Devise from GitHub. Here are the relevant Gemfile lines:

    gem "oa-oauth", :require => "omniauth/oauth"
    gem 'devise', :git => 'git://github.com/plataformatec/devise.git'

In your user model, you'll want something like this -- note the :omniauthable addition:

      devise :database_authenticatable, :registerable, :confirmable,
             :recoverable, :rememberable, :trackable, :validatable, :omniauthable

In your config/initializers/devise.rb, you'll want the following (you can drop publish_stream if you don't intend to publish on a user's behalf):

      config.omniauth :facebook, APP_CONFIG['fb_app_id'],
                                 APP_CONFIG['fb_app_secret'],
                      :scope => 'publish_stream,email,offline_access'

And lastly, to lay the groundwork for the various overrides we'll be doing, you'll want something like this in your routes.rb:

      devise_for :user, :controllers => {
        :registrations => 'registrations',
        :sessions => 'sessions',
        :omniauth_callbacks => "users/omniauth_callbacks"
      }

Don't forget -- when overriding devise controllers you'll need to copy the generated views from app/views/devise/* to app/views/*.

The "hard" part

Now, let's go ahead and get into the custom code required to make this all work.

For starters, we're going to need a place to store various OmniAuth authorizations. Let's add an Authorization model.

    $ rails g model Authorization user:references provider:string uid:string \
      nickname:string url:string credentials:text
    $ rake db:migrate

app/models/authorization.rb

    class Authorization < ActiveRecord::Base
      belongs_to :user
      serialize :credentials

      validates_uniqueness_of :uid, :scope => :provider

      # Maybe we'll cover twitter in another post?
      scope :twitter, where(:provider => 'twitter')
      scope :facebook, where(:provider => 'facebook')
    end

We'll also need to add a few things to our User model:

app/models/user.rb

      [...]
      has_many :authorizations, :dependent => :destroy
      [...]

     class << self
        def new_with_session(params, session)
          super.tap do |user|
            if data = session['devise.omniauth_info']
              user.name = data[:name] if user.name.blank?
              user.email = data[:email] if user.email.blank?
              user.gender = data[:gender] if user.gender.blank?
            end
          end
        end
      end

      def set_token_from_hash(hash)
        token = self.authorizations.find_or_initialize_by_provider(hash[:provider])
        token.update_attributes(
          :uid         => hash[:uid],
          :nickname    => hash[:nickname],
          :url         => hash[:url],
          :credentials => hash[:credentials]
        )
      end

We'll be using the two methods we added to User in the next steps. Well, to be honest, Devise uses User.new_with_session on its own. By defining our own new_with_session method, we're able to pull in the "autofill" values for the new user on our form after we request permissions from Facebook.

Let's get our OmniAuthCallbacksController set up:

app/controllers/users/omniauth_callbacks_controller.rb

    class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
      before_filter :set_auth

      def facebook

        if current_user
          if current_user.set_token_from_hash(facebook_authorization_hash)
            flash[:notice] = "Authentication successful"
          else
            flash[:alert] = "This Facebook account is already attached to a user account!"
          end
          redirect_to edit_user_registration_path + '#tabs-3'
        else

          authorization = Authorization.find_by_provider_and_uid(@auth['provider'], @auth['uid'])

          if authorization
            authorization.user.set_token_from_hash(facebook_authorization_hash)
            flash[:notice] = I18n.t "devise.omniauth_callbacks.success", :kind => @auth['provider']
            sign_in_and_redirect(:user, authorization.user)
          else
            session['devise.omniauth_info'] = facebook_authorization_hash.merge(facebook_user_hash)
            redirect_to new_user_registration_url
          end
        end
      end

      private

      def facebook_authorization_hash
        {
          :provider    => @auth['provider'],
          :uid         => @auth['uid'],
          :nickname    => @auth['user_info']['nickname'],
          :url         => @auth['user_info']['urls']['Facebook'],
          :credentials => @auth['credentials']
        }
      end

      def facebook_user_hash
        {
          :name        => @auth['user_info']['name'],
          :email       => @auth['extra']['user_hash']['email'],
          :gender      => case @auth['extra']['user_hash']['gender']
            when 'male'
              'M'
            when 'female'
              'F'
            else
              'N'
            end
        }
      end

      def set_auth
        @auth = env['omniauth.auth']
      end
    end

You can probably see at this point how simply additional services can be added, mapping relevant fields from the OmniAuth hash into our User attributes.

Now, let's use this data in our custom RegistrationsController -- this is Devise customization 101, so not going to go into details here:

app/controllers/registrations_controller.rb

    class RegistrationsController < Devise::RegistrationsController

      def create
        build_resource # Here's where the autofill magic happens

        if resource.save
          resource.set_token_from_hash(session['devise.omniauth_info']) if session['devise.omniauth_info'].present?
          if resource.active?
            set_flash_message :notice, :signed_up
            sign_in_and_redirect(resource_name, resource)
          else
            set_flash_message :notice, :inactive_signed_up, :reason => resource.inactive_message.to_s
            expire_session_data_after_sign_in!
            redirect_to after_inactive_sign_up_path_for(resource)
          end
        else
          clean_up_passwords(resource)
          render_with_scope :new
        end
      end
    end

That's it! When build_resource is called in the above code, Devise calls User.new_with_session and we pre-fill the fields with the info from OmniAuth.

All that remains is to add a Facebook signup link in our view:

<%= link_to "Sign Up with Facebook", user_omniauth_authorize_path(:facebook) %>
comments powered by Disqus