Rails – Twitter and Facebook Authentications with Omniauth and Devise 22

I have been struggling with multiple authentications with FaceBook and Twitter + normal user registration process all required in one project. Since rails and apis’ are developing rapidly, almost all tutorials are outdated or not respond all of these requirements at the same time.

In that tutorial, your project will provide:

  • Normal user registration process with Devise
  • Registration and logins with Facebook or Twitter
  • And both at the same time

And for the future usage, the system will keep tokens and token_secrets so that your app will be able to tweet or post something with the authenticated social services.

For that project I mainly updated Ryan Bates’ (rbates on Twitter and ryanb on GitHub) following tutorials:

  • http://railscasts.com/episodes/235-devise-and-omniauth-revised
  • http://railscasts.com/episodes/235-omniauth-part-1
  • http://railscasts.com/episodes/236-omniauth-part-2

and couple of other articles which I’m going to link all of them at the end of the post. But you should thank Ryan Bates for all of them primarily.

So let’s begin.

The first thing that we need to do is, adding Devise, Omniauth, Facebook and Twitter to our Gemfile.

gem 'devise'
gem 'omniauth-twitter'
gem 'omniauth-facebook'
gem 'twitter'
gem 'fb_graph'

And then we call

bundle install

command to apply our changes in Gemfile.

Now before we get into the code, I am going to explain the logic behind the authentications:

Devise prodives us a great User model with the following schema:

</pre>
1
 create_table "users", :force => true do |t|
 t.string "email", :default => "", :null => false
 t.string "encrypted_password", :default => "", :null => false
 t.string "reset_password_token"
 t.datetime "reset_password_sent_at"
 t.datetime "remember_created_at"
 t.integer "sign_in_count", :default => 0
 t.datetime "current_sign_in_at"
 t.datetime "last_sign_in_at"
 t.string "current_sign_in_ip"
 t.string "last_sign_in_ip"
 t.datetime "created_at", :null => false
 t.datetime "updated_at", :null => false
 end

However as you see in that schema, there is no signal that we can attach Facebook's or Twitter's authentication information.

Since we want to provide multiple authentications for each user, but a user may not have any authentications also we should have separate table which keeps track of authentications.

 create_table "authentications", :force => true do |t|
 t.string "user_id"
 t.string "provider"
 t.string "uid"
 t.string "token"
 t.string "token_secret"
 t.datetime "created_at", :null => false
 t.datetime "updated_at", :null => false
 end

So in this schema, user_id is our foreign key so that if any user authenticates him/herself with either Facebook or Twitter will have a record in that database. Token and token_secret fields are really important for us. In the future usage, we will be able to authenticate our application for Facebook and Twitter to post any update, or and feed from app to the authenticated social media.

The other thing is, if you only want to provide single authentication with any of these providers, you don't need to create a separate table and work on this stuff. The more efficient solution is, adding provider, uid, token and token_secret fields into the User model.

So after we have this infrastructure, we will direct rails to fulfill these terms.

We should tell Devise that we are going to use Facebook and Twitter authentications. Hence in /config/initializiers/devise.rb file, there is a section starts with "# ==> OmniAuth". Add:

config.omniauth :twitter, ENV["TWITTER_CONSUMER_KEY"], ENV["TWITTER_CONSUMER_SECRET"]
config.omniauth :facebook, 'APP_ID', 'APP_SECRET' 

Let's first create User model from devise with

devise generate Users

command. After you execute this command, you will have a file called User.rb in your views folder which is nicely coded by Devise.

To have the Authentication models and controllers, run this command:

rails g nifty:scaffold authentication user_id:integer provider:string uid:string index create destroy

You may encounter an error because you don't have nifty:scaffold in your project. To solve this issue, open your Gemfile and add

gem 'nifty-generators'

line and run

bundle install

code.

So we have all models now but we should make them connected with a few lines adding onto them.

Since every single User may have many Authentication. We should add

has_many :authentications

line into the user.rbfile. And

 belongs_to :user 

line to the authentication.rbfile.

And since we're going to get help from Devise, we should add :omniauthable keyword into the user model:

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

This provides really useful functions and strong support in the background and now we'll be able to add our login links.

Let's create a really really really simple, ugly login page with the following code:

 <% if user_signed_in? %>
 Logged in as <strong><%= current_user.email %></strong>.
 <%= link_to 'Edit profile', edit_user_registration_path %> |
 <%= link_to 'Authentications', authentications_path %> |
 <%= link_to "Logout", destroy_user_session_path, method: :delete %>
<% else %>
 <%= link_to "Sign up", new_user_registration_path %> |
 <%= link_to "Login", new_user_session_path %>
 <li><%= link_to "Twitter", user_omniauth_authorize_path(:twitter) %></li>
 <li><%= link_to "Facebook", user_omniauth_authorize_path(:facebook) %></li>
<% end %>

I created a file called home.html.erb in views/authentications folder, added the code above and modified my routes.rb with the following line:

root to: 'authentications#home'

And of course in my authentications_controller.rb I added home action which does nothing:

 def home

end

OK. Now it is time to handle to authentication part.

When we click a "Twitter" or "Facebook" button in our home page it actually calls the user_omniauth_authorize_path(:provider) method. So in that case we need to do sth, because now we have our own authentications controller since we are going to provide multiple authentications.

To redirect this call into our controller we need to change routes.rb file

 devise_for :users, path_names: {sign_in: "login", sign_out: "logout"},
 controllers: {omniauth_callbacks: "authentications", registrations: "registrations"}

With this line, we are telling to our app that instead of devise to handle our omniauth_callbacks, our authentications controller would handle these calls.

So now it's time to click "Twitter" button in the home page ! When we click that button, after we authenticate our app from Twitter, we'll get the following error:

Unknown action

The action 'twitter' could not be found for AuthenticationsController

Which means that, when we click that button, Devise redirects us to the our Authentication controller's twitter action. But we don't have any, then let's create.

To test and see what is the incoming hash from twitter, first write the following code into the authentications_conttoller.rb and click "Twitter" link again.

 def twitter
 raise omni = request.env["omniauth.auth"].to_yaml

What did you see? This output includes lots of information about our Twitter authentication and we only need to extract a few of them. Now, firstly we need to figure out how many possibilities for twitter authentication.

  1. There exists a user, who is not authenticated his/her twitter account yet.
  2. There maybe a person, who is going to register to our website by authenticating the Twitter.
  3. There maybe a user, who is already authenticated before.

So 3rd one is the easiest one, because we know that in Authentications table, there exists a record for a user, who's provider is twitter,  and uid is omni['uid']. Where omni variable is the hash which is retrieved from the following line:

 omni = request.env["omniauth.auth"]

So to handle the easiest case, we can search that whether there exists a record with the following inputs or not.

authentication = Authentication.find_by_provider_and_uid(omni['provider'], omni['uid'])

if there is an any Authentication record, the variable authentication will be initialized with that so that we can process the authentication:

 if authentication
 flash[:notice] = "Logged in Successfully"
 sign_in_and_redirect User.find(authentication.user_id)

Pretty easy ha ?

Let's look at for the 2nd case, where there exists a logged-in user but there is no twitter Authentication record for that user.

In that case, we would have a variable called "current_user" since the user is currently logged in. But user maybe logged in with Facebook or normal log in process. Therefore we need to extract the necessary information from twitter hash, create the Authentication record and redirect user.

So, if there is a logged in user, in our current web site, the variable "current_user" will return TRUE in if statement.

 elsif current_user
 token = omni['credentials'].token
 token_secret = omni['credentials'].secret

current_user.authentications.create!(:provider => omni['provider'], :uid => omni['uid'], :token => token, :token_secret => token_secret)
 flash[:notice] = "Authentication successful."
 sign_in_and_redirect current_user

So basically, we're extracting the token and token_secret information from the Omniauth hash and create an Authentication record with the following inputs, and redirecting the user.

Aand the last and the most tricky case, if there is a NEW user wants to register by authenticating his/her twitter account.

So let's think the use case scenario first before we start writing any lines of code.

We don't have any information about new-coming user, there is any record in neither Users nor Authentications table. Therefore, we need to create both User and Authentication records. And there are couple of problems in here, the first one is, since the user will sign up with twitter account, there won't be any password. The second, twitter api will not provide user's e-mail address, therefore we need to ask the user to fill registration form again with our own registration controller. That is the reason we have our own registration controller rather than Devise's ! Here's the all code for that, then we'll focus on line by line.

 else
 user = User.new
 user.apply_omniauth(omni)

if user.save
 flash[:notice] = "Logged in."
 sign_in_and_redirect User.find(user.id)
 else
 session[:omniauth] = omni.except('extra')
 redirect_to new_user_registration_path
 end
 end

As you can see, we have a function called apply_omniauth called with user instance, therefore we need to create a method called apply_omniauth in user.rb file.

def apply_omniauth(omni)
 authentications.build(:provider => omni['provider'],
 :uid => omni['uid'],
 :token => omni['credentials'].token,
 :token_secret => omni['credentials'].secret)
 end

So this method isn't doing something fancy, I just separated it for the sake of simplicity. Firstly we're creating a User object, then we're building an Authentication object, but you should know that, since we use "build" method, there won't be any Authentication record until the User record is successfully saved. If you try to use create instead of build, then you'll get error because then you're trying to create an object which's super class is not successfully created yet !

OK. Now, we know that "user.save" will return FALSE, because we haven't initialized user's email since Twitter doesn't provide to us. Therefore we need to redirect a user to the new registration page. However we also not to lose the hash information from omniauth, so that we add these hash into our session with:

session[:omniauth] = omni.except('extra')

.except helps us to get rid of a lot of unnecessary information from hash, so that our session hash will not get rejected.

Now we need our registration controller to handle this request. But you know, Devise already has the new registration page as you remember, but do you know where they are ? They're not actually visible at the beginning, but to copy all of these files and modify them with respect to your needs, you need to run this command:

rails g devise:views

This command copies all files from Devise engine into views/devise directory. And in views/devise/registrations directory, has two files called edit.html.erb and new.html.erb . You should move these files from there to views/registrations directory, to use by ourselves.

And to handle registrations if the session variable exists with the help of Devise, here's the code for registration_controller.rb

 def build_resource(*args)
 super
 if session[:omniauth]
 @user.apply_omniauth(session[:omniauth])
 @user.valid?
 end
 end

But unfortunately, it is still not enough to have perfect login system, because if you try to register now, you will be redirected to the new registration page with the session, and you will see these two errors:

  • Email can't be blank
  • Password can't be blank

Which is not bad actually, we only want to force our user to enter an e-mail address, not the password. So to handle this add this method into user.rb file

 def password_required?
 (authentications.empty? || !password.blank?) && super
 end

Now we created a function that returns FALSE if there is an authentication. Let's use it !

The errors were in views, therefore our aim is to use this method in new.html.erb in /views/registrations directory.

 <h2>Sign up</h2>

<%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %>
 <%= devise_error_messages! %>

<div><%= f.label :email %><br />
 <%= f.email_field :email %></div>

<% if @user.password_required? %>
 <div><%= f.label :password %><br />
 <%= f.password_field :password %></div>

<div><%= f.label :password_confirmation %><br />
 <%= f.password_field :password_confirmation %></div>
 <% end %>

<div><%= f.submit "Sign up" %></div>
<% end %>

<%= render "devise/shared/links" %>

There is one last thing that we need to do in Registrations Controller. Which is deleting the omniauth hash from session. We can easily handle this issue by overriding the create action

 def create
 super
 session[:omniauth] = nil unless @user.new_record?
 end

Yes it is DONE, except one small problem :) When a user tries to edit his/her settings, there is a current password field which needs to be filled in. However there is no password for these users, therefore we need to override the update_with_password method in user.rb:

def update_with_password(params, *options)
 if encrypted_password.blank?
 update_attributes(params, *options)
 else
 super
 end
end

OK. Twitter is done. What do you think about Facebook, or the others ? What do you think we need to change to implement Facebook ? It is actually nothing at all ! Because we already have a working system which is capable of handling many authentications. We just need to know the  structure of the omniauth hash, maybe Facebook will provide user's e-mail ha ? And yes ! It is easier than Twitter, because Facebook already provide all the necessary information. The only thing is, you need to know how to ask for Facebook :) And here's how:

In your devise.rb file after you add your app id and app secret, you also need to add scope:

config.omniauth :facebook, 'APP_ID', 'APP_SECRET', {:scope => 'publish_stream, email'}

And this is the permission for, posting on behalf of the user and reaching the user's email.

And in Facebook case, we need to have an action called Facebook in authentications_controller.rb, and the only difference from the twitter action is the new user registration case:

else
 user = User.new
 user.email = omni['extra']['raw_info'].email

user.apply_omniauth(omni)

if user.save
 flash[:notice] = "Logged in."
 sign_in_and_redirect User.find(user.id)
 else
 session[:omniauth] = omni.except('extra')
 redirect_to new_user_registration_path
 end
 end

Yes, it's DONE. Congratulations ! I intentionally separated these two actions so that you can see how easy to implement any service just within a seconds !

One week ago I didn't know anything about Ruby on Rails,  but I had a competition to finish in three days provided by RailsArena.
It is a simple web applications that allows you to add friends and lend or borrow moneys to them. You will be able to keep track of the transactions and post them to a social media. You can reach the app from here: http://moltosoldi.herokuapp.com/ and source code from my github.

And the hardest part of my project was handling the multiple authentications and posting or tweeting something to the social media. Because the APIs' are changing so rapidly and most of the tutorials were for the old versions of these gems or they weren't what I was looking for.

So I gathered all these tutorials, and finally created successful authentication model. Then I want to share this information with everyone.

And finally, if you want to see the source code, you can reach it from my  github ;) I purposely didn't add anything unnecessary than the authentication, so don't be shocked when you see the bad design :) since every web site has it's own style, you can only use the functionality easily ;)

22 thoughts on “Rails – Twitter and Facebook Authentications with Omniauth and Devise

  1. Reply Eric Berry Nov 20, 2012 10:13 pm

    Great article. Thank you for researching this and sharing your knowledge.

  2. Reply prem Nov 26, 2012 4:03 am

    I get error
    NoMethodError in AuthenticationsController#create

    undefined method `find_by_provider_and_uid’ for Authentication:Class
    what could be the solutioN?

    • Reply Orhan Can Ceylan Nov 26, 2012 3:05 pm

      find_by_provider_and_uid is an automatically defined method when you create the Authentication model with fields named ‘uid’ and ‘provider’. So if your field names are different, or if you didn’t run ‘bundle install’ command you may get this error. Can you try again ?

  3. Reply James Dec 2, 2012 6:35 am

    Thank you !
    Your article helped me a lot.

    Now I need to allow iphone app to authenticate and use my REST API.
    Any idea how I could implement that ?

    • Reply Orhan Can Ceylan Dec 2, 2012 11:11 am

      You’re welcome.

      I’m not really sure but maybe in your app you can just call the same callbacks that you’re calling in your login page such as user_omniauth_authorize_path(:twitter)

  4. Reply John Cho Apr 10, 2013 12:20 am

    Hi Orhan,

    First, thank you for making an up-to-date resource for creating an authentication system.

    I have a question about multiple vs single authentication. What is the difference? I don’t really understand the exact nature of it from your description, except that it can gather more information from the user.

    From the ‘single authentication’ from the railscast 235, you can use multiple logins. You can login with a username, a twitter, or any other provider. I used that to allow me to log in with twitter, a username, and a facebook. When someone registers with an email that is already registered with facebook, it will say ‘error email already exists’. That’s from Devise, not my writing. My authentication system is having a few problems, which is why I’m here to learn more. But the thing that confuses me is that it seems like railscast 235 is a multiple authentication solution. Can you clarify what the difference is? That way I can decide if I should rehaul my login system to something similar to yours, or if i should continue to hack away at mine until I figure out the few errors I’m currently getting.

    Thanks!

  5. Reply Nicolas Apr 17, 2013 1:08 pm

    Thanks a lot, this was exactly what I needed.

    One thing, in this paragraph:
    “When we click a “Twitter” or “Facebook” button in our home page it actually calls the user_omniauth_authorize_path(:provider) method. So in that case we need to do sth, because now we have our own authentications controller since we are going to provide multiple authentications.”

    sth is a typo?

  6. Reply Darwish Apr 28, 2013 10:01 pm

    How does your code work without changing the form for the registration. If I just copy the registration form from devise and move it, they keep asking what resource and resource_name are. Arent these devise attributes?

  7. Reply Darwish Apr 28, 2013 10:01 pm

    How does your code work without changing the form for the registration. If I just copy the registration form from devise and move it, they keep asking what resource and resource_name are. Arent these devise attributes? Thanks!

  8. Reply Shashank May 17, 2013 9:09 am

    For authentication, it is asking for the password_field.Why??

  9. Reply Rohit May 23, 2013 7:53 am

    Thank you so much OCC. This helps a lot

  10. Reply Diana May 26, 2013 3:37 pm

    Hi Orhan,

    I tried to apply your approach to a MongoId backend and it surfaced a bug in the code you have on github. I haven’t seen the SQL database with your version, but in MongoDB I saw a new user created with every twitter login. I think it happens because you are refering to provider and uid for the user instead of the user’s authenticatons in the authentications_controller:

    else
    user = User.new
    user.provider = omni.provider
    user.uid = omni.uid
    user.apply_omniauth(omni)

    I could be wrong. Even with some tweaks I couldn’t get it to store authentications properly so I went with a different approach.

    Best,
    Diana

  11. Reply Alex May 31, 2013 1:50 pm

    Great article. Is it possible to fetch twitter feed by adding just the twitter username to our rails app where we can just pass the authentication request for once?

  12. Reply Stephen Liu Jun 4, 2013 8:11 pm

    Thanks. I am trying to set up facebook. On facebook, I set up the site URL to be: https://localhost:3000.

    But right now, I get this error when I click the facebook link:

    {
    “error”: {
    “message”: “Invalid redirect_uri: Given URL is not allowed by the Application configuration.”,
    “type”: “OAuthException”,
    “code”: 191
    }
    }

  13. Reply Leo Chen Jun 6, 2013 2:05 am

    Thanks a lot! it helps! But when I implement your practice, I found there is still one condition you didn’t think about, which is: there an existing user without twitter/facebook auth, but who is not logging currently, and he/she want to login with twitter/facebook auth.(I use “email” as unique identifier) I do a little modification of function def facebook in authentications_controller.rb :


    def facebook
    omni = request.env["omniauth.auth"]
    authentication = Authentication.find_by_provider_and_uid(omni['provider'], omni['uid'])

    if authentication # already have auth before.
    flash[:notice] = "Logged in Successfully"
    sign_in_and_redirect User.find(authentication.user_id)
    elsif current_user # current logging user but still don't have auth before.
    token = omni['credentials'].token
    token_secret = ""

    current_user.authentications.create!(:provider => omni['provider'], :uid => omni['uid'], :token => token, :token_secret => token_secret)

    flash[:notice] = "Authentication successful."
    sign_in_and_redirect current_user

    else
    user = User.find_by_email(omni['extra']['raw_info'].email)
    if user.nil?
    user = User.new
    user.name = omni['extra']['raw_info'].name
    user.email = omni['extra']['raw_info'].email

    user.apply_omniauth(omni)

    if user.save
    flash[:notice] = "Logged in."
    sign_in_and_redirect User.find(user.id)
    else
    session[:omniauth] = omni.except('extra')
    redirect_to new_user_registration_path
    end
    else # old user but not logging now
    token = omni['credentials'].token
    token_secret = ""

    user.authentications.create!(:provider => omni['provider'], :uid => omni['uid'], :token => token, :token_secret => token_secret)

    flash[:notice] = "Authentication successful."
    sign_in_and_redirect user
    end
    end
    end

  14. Reply Odong Jun 10, 2013 12:04 am

    Hi!
    I got this message when trying sing in with provider twitter.
    OAuth::Unauthorized

    401 Unauthorized
    How to fix this?

  15. Reply Arel Jun 11, 2013 12:11 am

    Thanks! Great tutorial! Really appreciate the work.

  16. Reply Mitch Jun 11, 2013 2:32 pm

    Incredibly helpful post, many thanks!!

    I’ve made one small adjustment to the Facebook action by adding one additional elsif condition. If the user has signed in previously and now they are signing in with Facebook, these two users should be linked if their email addresses match. Additionally, fill in some values to the user table if they haven’t been filled in previously.

    elsif User.find_by_email(omni['extra']['raw_info'].email)
    user = User.find_by_email(omni['extra']['raw_info'].email)
    user.first_name ||= omni['extra']['raw_info'].first_name
    user.last_name ||= omni['extra']['raw_info'].last_name
    user.username ||= omni['extra']['raw_info'].name
    user.location ||== omni['extra']['raw_info'].location
    user.skip_confirmation!
    user.save!
    token = omni['credentials'].token
    token_secret = “”
    user.authentications.create!(:provider => omni['provider'], :uid => omni['uid'], :token => token, :token_secret => token_secret)
    flash[:notice] = “Authentication successful.”
    sign_in_and_redirect user

  17. Reply Arey Jun 18, 2013 5:18 am

    How do you solve the problem, when your user already registered with email on your server, then tries to login with twitter. It asks for a mail but this mail already exists so you cannot continue. It doesn’t associate your twitter account automatically to existing local account.

  18. Pingback: Login com o facebook twittter em rails | Zen Development

  19. Reply Jazear Sep 7, 2013 10:36 am

    Thanks for this. Under `create table ‘authentications’` user_id should be an integer. You have it listed as a string

Leave a Reply