Web and Native Technologies

User Authentication with Devise + Omniauth

Okay, so I was trying to make a sample web app reflecting the React Native Mobile version I am currently trying to implement when I ran into a problem. I needed to set up some things only when a user was logged in. In order to do this, I needed some form of authentication. I then used Devise to set up some basic authentication, but it was socially lacking. I needed some Facebook, Twitter and Google log-in’s as well! I mean, if you are going to make a user login, at least give them some options right?

I started doing some research to see how to implement a system where a user can have multiple login providers, but still point to a specific user. I didn’t really find a good resource to solve all my problems, so I kind of had to stitch different pieces together and add my own twist to it. Thus, the idea of this blog came to life. Maybe this will help others like myself who want to set up authentication with multiple providers with rails.

Before I get started I am assuming you already have a rails app setup and running.

Now that we have all that out of the way, let’s get started. First of all, add these gems to your gemfile:

  gem "figaro"
  gem 'devise'
  gem 'omniauth-twitter'
  gem 'omniauth-facebook'
  gem 'omniauth-google-oauth2'

and run

bundle install

Let’s go over a brief overview of what these gem do. The figaro gem allows us to set up our app ids and secrets through environment variables. The devise gem is like the best authentication system that is highly configurable and has a great community (at least in my opinion). This will allow us to authenticate our users and allow them to sign in and out of our app. The various omniauth gems we provided here are various strategies that work very well with devise to allow us to login and signup with various providers. In this case, we are going to set it up with twitter, facebook and google.

Next, run

rails g devise:install

to setup up and configure devise. There will be some instructions on your terminal after the installation is complete. Be sure to follow those.

After that is done we simply have to create our user model. Type the following command:

rails g devise User

(assuming you want to call your model User) and migrate our database to create the columns with

rake db:migrate

If you go through the wiki’s of the various omniauth strategies, you will see that the auth hash from twitter varies from the facebook and the google one. It is the only one with a nickname key instead of an email one. To account for this and allow it to persist in our db, let’s create a migration for it. This is the perfect chance to add other fields you want the user to have as well. In this case, I will get the nickname and name fields. Run

rails g migration AddNameNicknameToUser name nickname

We now have the user setup, but what about all the providers the user can have? We will have to abstract this logic into another table which the user can have many of. Let’s do that now. Simply run

rails g model Identity uid provider user:references

After that is set up, it’s time to create their relationships.

Navigate to app/models/user.rb and add

has_many :identities, dependent: :destroy

Then Navigate to app/models/identity.rb and add

validates_presence_of :uid, :provider

to add some simple validation.

Don’t forget to migrate the db with

rake db:migrate

It’s finally time to set up omniauth!

Add the following devise modules to the user model

 :omniauthable, :omniauth_providers => [:facebook, :twitter, :google_oauth2]

If you haven’t added any modules yet, and it’s just the default devise modules, then the top of your user model should look something like this:

devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :omniauth_providers => [:facebook, :twitter, :google_oauth2]

After you have that done, you have to create your google dev, twitter dev, and facebook dev apps. Once you have set those up, take note of the key and secrets because you will need them! If you need help setting them up, visit their respective strategy repos for more guidance.

It’s finally time to put that figaro gem that you installed earlier to use. Run

bundle exec figaro install

Navigate to config/application.yml (this file was just created when you ran figaro installer) and setup the key and secrets you took note of earlier.

development:
    FACEBOOK_APP_ID: 'some_fb_id'
    FACEBOOK_APP_SECRET: 'some_fb_secret'
    GOOGLE_APP_ID: 'some_google_id'
    GOOGLE_APP_SECRET: 'some_google_secret'
    TWITTER_APP_ID: 'some_twitter_id'
    TWITTER_APP_SECRET: 'some_twitter_secret'

I have mine set up by environment, so it knows to use those ids and secrets when I am in my development environment only. This file is also automatically added to your .gitignore so no need to worry about people seeing them.

We now have access to these ids and secrets via environment variables which you can access like this: ENV['PROVIDER_APP_ID']

We will have to set up these keys and secrets in the Devise initializer. So, navigate to /config/initializers/devise.rb and add the following

config.omniauth :facebook, ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_APP_SECRET'], scope: 'public_profile,email'
config.omniauth :google_oauth2, ENV['GOOGLE_APP_ID'], ENV['GOOGLE_APP_SECRET'] # has default scope of email and profile
config.omniauth :twitter, ENV['TWITTER_APP_ID'], ENV['TWITTER_APP_SECRET']

The scope you see on the devise omniauth facebook config is just some permissions you will need in order to access the fb data, in this case we want the user’s public profile data and email.

Now Devise will automagically set some login_with_provider links for you to use. These will take you to the provider’s authorization path which will then redirect you to a callback which we have to provide. So, let’s do that now.

First, we have to tell devise to use our custom controller which will handle the callback routes. Run

rails g controller auth_callbacks

This will create controller boilerplate. We now need to add three methods, one for each provider to handle it’s respective callback. Make sure that the method name matches the provider!

def facebook
end

def twitter
end

def google_oauth2
end

The next step is to tell Devise to use our custom controller. Navigate to /config/routes.rb and add the following in the devise_for method:

:controllers => { :omniauth_callbacks => "auth_callbacks" }

Your code should look something like this now:

devise_for :users, :controllers => { :omniauth_callbacks => "auth_callbacks" }

We are almost done! It’s time for the last push. We have all the connections made. The only thing that is left is to actually access the auth hash and sign in or create the user.

Since a lot of the code is similar for each provider, we can abstract this data into it’s own method. Let’s call it handle_user_auth and it will take one parameter being the provider name. Your code should now look like this:

def facebook
  handle_user_auth 'facebook'
end

def twitter
  handle_user_auth 'twitter'
end

def google_oauth2
  handle_user_auth 'google'
end

Next, we have to implement this handle_user_auth method. Add this as a private method in the controller.

def handle_user_auth(provider)
  if @user.persisted?
    sign_in_and_redirect @user, :event => :authentication
    set_flash_message(:notice, :success, :kind => provider) if is_navigational_format?
  else
    session["devise.#{provider}_data"] = request.env["omniauth.auth"]["info"] # Not to overflow default 4kb of cookies
    redirect_to new_user_registration_url
  end
end

Here, we are simply checking if the user actually persisted to the db and was created successfully. If it was successful, then sign the user in and show a flash message. If not, then save the data returned from the auth hash (which is accessible via: request.env['omniauth.auth']) to the session hash and send them to complete the sign up.

There is one thing to note here, however. Instead of saving the whole auth hash to the session hash, I dug into the ‘info’ key which has all the info I currently need. This avoids an error being raised of an overflow of cookies since the default limit is 4kb. You can of course create a new store with something like the activerecord-session_store gem, but this is good enough for our purposes.

At this point you may be asking: Where the hell is the @user coming from? Good observation. We will do that now.

Create a method called get_user which will get the user depending on the identity chosen or create one if it doesn’t exist.

def get_user
  # Get the user if they already signed up with the provider, else create it
  identity = Identity.find_with_omniauth(request.env["omniauth.auth"])
  @user = identity && identity.user || User.find_from_identity(identity, request.env["omniauth.auth"])
end

We now have to make sure that these methods are being called in the provider callbacks. In order to do this, we can simply add them with a before_action. Go to the top of your controller and add

before_action :get_user, only: [:facebook, :twitter, :google_oauth2]

If you have been really paying attention so far, you will notice that we have not created any of the methods called upon our models. Let’s start with out Identity model.

Navigate to app/models/identity.rb and add the following:

def self.find_with_omniauth(auth)
  find_by(uid: auth['uid'], provider: auth['provider'])
end

def self.create_with_omniauth(auth, user)
  create!(uid: auth.uid, provider: auth.provider, user: user)
end

This simply finds or creates an Identity depending on the uid and provider of the auth hash.

Next up is the User. Navigate to app/models/user.rb and add the following:

def self.create_with_omniauth(info, using_twitter)
  # TODO: add image: info.image
  user_info = { name: info.name, password: Devise.friendly_token[0,20] }
  using_twitter ? user_info.merge!(nickname: info.nickname) : user_info.merge!(email: info.email)
  create(user_info)
end

def self.find_from_identity(identity, auth)
  # Find the user associated with the auth provider
  # Twitter has an edge case where instead of returning an email, they return their nickname
  using_twitter = auth.provider == 'twitter'
  user = using_twitter ? where(nickname: auth.info.nickname).first : where(email: auth.info.email).first
  if user.present?
      # The user has already signed up with the same email or nickname the provider is returning
      # So create the user provider and link it to the user
      Identity.create_with_omniauth(auth, user)
      user
  else
      # First time the user is signing up to the site
      new_user = create_with_omniauth(auth.info, using_twitter)
      Identity.create_with_omniauth(auth, new_user)
      new_user
  end
end

There is a little bit more logic involved here. First, you have to remember that the twitter hash differs from the other two providers. To fix this, simply create a boolean variable that knows if the current provider is twitter or not. If it is twitter, then simply replace the email field with the nickname field. So if you creating a User, create it with a nickname instead of an email and vice versa. I also set up some comments so you can follow the flow of how everything falls into place and account for some edge cases.

Remember that if and else block we set up in the controller where we account for users that weren’t persisted? Well we can hook into the underlying devise method new_with_session which gets called when a resource is being built, and we can pre-populate some fields based on the auth hash.

Here, I will get the email or nickname fields of the user from the session which we added in the controller based on the auth hash.

# copy email/nickname if available from session whenever a user is initialized before signing up
def self.new_with_session(params, session)
  super.tap do |user|
    if data = session["devise.facebook_data"] || session["devise.google_oauth2_data"]
      user.email = data["email"] if user.email.blank?
    elsif data = session["devise.twitter_data"]
      user.nickname = data["nickname"] if user.nickname.blank?
    end
  end
end

This is really displayed when you go to edit the user’s profile and you see the email or nickname field pre-populated since we grabbed them from the data returned by the providers. Pretty neat huh?

There are still some edge cases to consider like disabling the password for when a user logs in through a provider, since we do not need it because they are already validated. (Assuming of course that they were succesfully authenticated). The same applies for when a user is editing their profile. In this case, they will not have a password if they signed in through a provider. A good way of fixing this issue is overriding the Devise RegistrationsController. There is some useful information about this on Devise’s Wiki and a quick search on SO will give you a lot of hits as well.

Congratulations! You now have successfully set up Devise with omniauth spanning multiple providers. I hope this helps others as much as it helped me.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s