Authorization via Facebook, Google, Twitter and Github using Omniauth

Original author: Markus Proske
  • Transfer
  • Tutorial
Having once been puzzled by the question of adding registration / login to the site through third-party services, he began to look for what was already ready, or a description of how someone had already done it. Ready services were thrown right away, there was an option to implement yourself. And then Google pointed to the detailed instructions. After reviewing and inspired by that decision, I made my modification, everything worked, I was just happy.

After some time, I decided to see what else is on that resource of interest, but to my disappointment the site was not available. Glory to the Yandex cache, from where a copy of that material was pulled. And so that he would not disappear irrevocably, I decided to make his translation and put it here.

So let's get started ...


This chapter will be dedicated to the famous gem Omniauth. Omniauth is a new authentication system on top of Rack for multi-provider external authentication. It will be used to connect CommunityGuides (note: at the moment the resource is not available and it seems it will not return) with Facebook, Google, Twitter and Github. This chapter will show you how to integrate all this with your existing identity through Devise.

Add Facebook Login


Omniauth is an authentication system on top of Rack for multi-provider external authentication.
To get started, we will register our application on Facebook developers.facebook.com/setup . Specify a name (it will be displayed to users) and a URL (for example www.communityguides.eu ). Facebook only allows redirecting to a registered site, for development you need to specify a different URL (for example, http: // localhost: 3000 / ). Do not specify localhost or 127.0.0.1 in the URL; this will result in an “invalid redirect_uri” error, which is quite common. Add the 'omniauth' gem to your project, run bundle install, create an initializer with your APP_ID / APP_SECRET and restart the server.

Gemfile
gem 'omniauth', '0.1.6'

config / initializers / omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do  
 provider :facebook, 'APP_ID', 'APP_SECRET'  
end

Now we will create a new controller and model that will expand our user with various services and establish a connection between them.

Terminal
rails generate model service user_id:integer provider:string uid:string uname:string uemail:string
rails generate controller services

app / models / user.rb
class User < ActiveRecord::Base
 devise :database_authenticatable, :oauthable, :registerable,
        :recoverable, :rememberable, :trackable, :validatable,
        :confirmable, :lockable
 has_many :services, :dependent => :destroy
 has_many :articles, :dependent => :destroy
 has_many :comments, :dependent => :destroy
 has_many :ratings, :dependent => :destroy
 belongs_to :country
 attr_accessible :email, :password, :password_confirmation, :remember_me, :fullname, :shortbio, :weburl
 validates :weburl, :url => {:allow_blank => true}, :length => { :maximum => 50 }
 validates :fullname, :length => { :maximum => 40 }
 validates :shortbio, :length => { :maximum => 500 }  
end

app / models / service.rb
class Service < ActiveRecord::Base
 belongs_to :user
 attr_accessible :provider, :uid, :uname, :uemail
end

config / routes.rb
...
match '/auth/facebook/callback' => 'services#create'
resources :services, :only => [:index, :create]
...

We defined new routes for services (so far only index and create) and added the so-called route for callback. What is it? We make a request for user authentication through http: // localhost: 3000 / auth / facebook . The request is sent to Facabook and then Facebook redirects the request to your page using the path / auth / facebook / callback. We have mapped this path to our Services controller, in particular the create method. Now this method returns only the received hash.

app / controllers / services_controller.rb
class ServicesController < ApplicationController
 def index
 end
 def create
   render :text => request.env["omniauth.auth"].to_yaml
 end
end

Let's check it out. We will go to the address http: // localhost: 3000 / auth / facebook and then we will get to the request to access your data on Facebook. We accept the offer and return to our application, which will display the received data (see the source code of the page for normal formatting).

Page source code
---
user_info:
 name: Markus Proske
 urls:
   Facebook: http://www.facebook.com/profile.php?id=....
   Website:
 nickname: profile.php?id=....
 last_name: Proske
 first_name: Markus
uid: "..."
credentials:
 token: ...........
extra:
 user_hash:
   name: Markus Proske
   timezone: 1
   gender: male
   id: "...."
   last_name: Proske
   updated_time: 2010-11-18T13:43:01+0000
   verified: true
   locale: en_US
   link: http://www.facebook.com/profile.php?id=........
   email: markus.proske@gmail.com
   first_name: Markus
provider: facebook

We are only interested in the id, provider name and email fields located in extra: user_hash. For verification, replace the create method with the following code:

app / controllers / services_controller.rb
...
def create
 omniauth = request.env['omniauth.auth']
 if omniauth
   omniauth['extra']['user_hash']['email'] ? email =  omniauth['extra']['user_hash']['email'] : email = ''
   omniauth['extra']['user_hash']['name'] ? name =  omniauth['extra']['user_hash']['name'] : name = ''
   omniauth['extra']['user_hash']['id'] ?  uid =  omniauth['extra']['user_hash']['id'] : uid = ''
   omniauth['provider'] ? provider =  omniauth['provider'] : provider = ''
   render :text => uid.to_s + " - " + name + " - " + email + " - " + provider
 else
   render :text => 'Error: Omniauth is empty'
 end
end
...

Great, we were able to authenticate the user through Facebook! There is still much to do, we are integrating this into our circuit with Devise. There are several points that you need to pay attention to:
  • User logs in using Facebook: Facebook provides user mail. Check if there is already one, if not, then create a new user with the address provided and automatically confirm. Create a new entry in the Service model for Facebook and assign it to the created user.
  • The user registers or logs in through Facebook for the first time, but already has a local user: again, we get the email address from Facebook and look at our database. If we find such an address, then create a new blog for Facebook and associate with the user found.
  • The user re-logs in via Facebook: we look at the database and log in for it.

Omniauth provides the ability to add more services, as we will do. Our authentication is tied to a mailing address, so only the providers providing it can be used. For example, Github returns an address only if the user has specified a public address. Twitter, on the contrary, never shows a mailing address. However, a Github account with an address can be used like Fb to login / register, and Github without an address or Twitter accounts can be added to an existing local user, or created through another provider.
Each provider returns a hash containing various parameters. Unfortunately, this is not standardized in any way and everyone can give different names to the same attributes. This means that we must distinguish between services in the create method. Also note that there is only one method to call back. Therefore, what we must do with the data received (log in or register) depends only on us. We will change our route again for all services, add a parameter to it, in which the name of the one used will be placed: params [: service].

config / routes.rb
...
match '/auth/:service/callback' => 'services#create'
resources :services, :only => [:index, :create, :destroy]
...


Next, go to the pages for Github and Twitter. We register again on localhost (for Twitter, instead of localhost you need to use 127.0.0.1). Get the new routes http: // localhost: 3000 / auth / github / callback / and http: //127.0.0.1haps000/auth/twitter/callback . Then change the initializer.


config / initializers / omniauth.rb
# Do not forget to restart your server after changing this file
Rails.application.config.middleware.use OmniAuth::Builder do  
 provider :facebook, 'APP_ID', 'APP_SECRET'
 provider :twitter, 'CONSUMER_KEY', 'CONSUMER_SECRET'
 provider :github, 'CLIENT ID', 'SECRET'
end

The created method will check for the presence of the parameter from the path and the Omniauth hash. Further, depending on the authentication service, the necessary values ​​from the hash are transferred to our variables. At the very least, the service provider and user ID for it must be defined, otherwise stop.
Part one:the user has not logged in yet: First, we check whether there is a provider-identifier pair in our Service model, which implies that this pair is associated with the user and can be used to log in. If so, then enter. If not, then check the existence of the mailing address. Using this address, we can find in the existing model of the user if he has already been registered with him. When such a user is found, this service will be added to him and in the future he will be able to use it for login. If this is a new mailing address, then instead we create a new user, confirm it and add this authentication service to him.
Part two : if the user has already logged in: We simply add this service to his account if it has not been added before.
Take a closer look at the Create method below. It contains all the necessary code to handle the various cases described above and provides identification for Facebook, Github and Twitter. Note that only 4 lines of code are needed to add a new provider. There is still no interface for this, but you can check by clicking on the links yourself:

app / controllers / services_controller.rb
class ServicesController < ApplicationController
 before_filter :authenticate_user!, :except => [:create]
def index
 # get all authentication services assigned to the current user
 @services = current_user.services.all
end
def destroy
 # remove an authentication service linked to the current user
 @service = current_user.services.find(params[:id])
 @service.destroy
 redirect_to services_path
end
def create
 # get the service parameter from the Rails router
 params[:service] ? service_route = params[:service] : service_route = 'no service (invalid callback)'
 # get the full hash from omniauth
 omniauth = request.env['omniauth.auth']
 # continue only if hash and parameter exist
 if omniauth and params[:service]
   # map the returned hashes to our variables first - the hashes differ for every service
   if service_route == 'facebook'
     omniauth['extra']['user_hash']['email'] ? email =  omniauth['extra']['user_hash']['email'] : email = ''
     omniauth['extra']['user_hash']['name'] ? name =  omniauth['extra']['user_hash']['name'] : name = ''
     omniauth['extra']['user_hash']['id'] ?  uid =  omniauth['extra']['user_hash']['id'] : uid = ''
     omniauth['provider'] ? provider =  omniauth['provider'] : provider = ''
   elsif service_route == 'github'
     omniauth['user_info']['email'] ? email =  omniauth['user_info']['email'] : email = ''
     omniauth['user_info']['name'] ? name =  omniauth['user_info']['name'] : name = ''
     omniauth['extra']['user_hash']['id'] ?  uid =  omniauth['extra']['user_hash']['id'] : uid = ''
     omniauth['provider'] ? provider =  omniauth['provider'] : provider = ''
   elsif service_route == 'twitter'
     email = ''    # Twitter API never returns the email address
     omniauth['user_info']['name'] ? name =  omniauth['user_info']['name'] : name = ''
     omniauth['uid'] ?  uid =  omniauth['uid'] : uid = ''
     omniauth['provider'] ? provider =  omniauth['provider'] : provider = ''
   else
     # we have an unrecognized service, just output the hash that has been returned
     render :text => omniauth.to_yaml
     #render :text => uid.to_s + " - " + name + " - " + email + " - " + provider
     return
   end
   # continue only if provider and uid exist
   if uid != '' and provider != ''
     # nobody can sign in twice, nobody can sign up while being signed in (this saves a lot of trouble)
     if !user_signed_in?
       # check if user has already signed in using this service provider and continue with sign in process if yes
       auth = Service.find_by_provider_and_uid(provider, uid)
       if auth
         flash[:notice] = 'Signed in successfully via ' + provider.capitalize + '.'
         sign_in_and_redirect(:user, auth.user)
       else
         # check if this user is already registered with this email address; get out if no email has been provided
         if email != ''
           # search for a user with this email address
           existinguser = User.find_by_email(email)
           if existinguser
             # map this new login method via a service provider to an existing account if the email address is the same
             existinguser.services.create(:provider => provider, :uid => uid, :uname => name, :uemail => email)
             flash[:notice] = 'Sign in via ' + provider.capitalize + ' has been added to your account ' + existinguser.email + '. Signed in successfully!'
             sign_in_and_redirect(:user, existinguser)
           else
             # let's create a new user: register this user and add this authentication method for this user
             name = name[0, 39] if name.length > 39             # otherwise our user validation will hit us
             # new user, set email, a random password and take the name from the authentication service
             user = User.new :email => email, :password => SecureRandom.hex(10), :fullname => name
             # add this authentication service to our new user
             user.services.build(:provider => provider, :uid => uid, :uname => name, :uemail => email)
             # do not send confirmation email, we directly save and confirm the new record
             user.skip_confirmation!
             user.save!
             user.confirm!
             # flash and sign in
             flash[:myinfo] = 'Your account on CommunityGuides has been created via ' + provider.capitalize + '. In your profile you can change your personal information and add a local password.'
             sign_in_and_redirect(:user, user)
           end
         else
           flash[:error] =  service_route.capitalize + ' can not be used to sign-up on CommunityGuides as no valid email address has been provided. Please use another authentication provider or use local sign-up. If you already have an account, please sign-in and add ' + service_route.capitalize + ' from your profile.'
           redirect_to new_user_session_path
         end
       end
     else
       # the user is currently signed in
       # check if this service is already linked to his/her account, if not, add it
       auth = Service.find_by_provider_and_uid(provider, uid)
       if !auth
         current_user.services.create(:provider => provider, :uid => uid, :uname => name, :uemail => email)
         flash[:notice] = 'Sign in via ' + provider.capitalize + ' has been added to your account.'
         redirect_to services_path
       else
         flash[:notice] = service_route.capitalize + ' is already linked to your account.'
         redirect_to services_path
       end  
     end  
   else
     flash[:error] =  service_route.capitalize + ' returned invalid data for the user id.'
     redirect_to new_user_session_path
   end
 else
   flash[:error] = 'Error while authenticating via ' + service_route.capitalize + '.'
   redirect_to new_user_session_path
 end
end


Our code is fully functional and right now you can use one local account and three services for login or registration. Despite the fact that login and registration always go along the same path / auth / service and the callback always goes to / auth / service / callback.
Our example works fine, but there is a drawback that can lead to unwanted accounts: take a user with a local account (mail: one@user.com) and a Facebook account (mail: two@user.com) that is already tied to the local one. No problem, the addresses do not match. If the user has a Google account with mail: three@user.com, then he can be linked without problems while the session is active. On the other hand, suppose that the user has never linked a Google account and has not logged in yet: if he clicks on “log in through Google”, our create method will search for three@user.com, find nothing and create a new user.
It's time to add a couple of views, let's start by logging in and registering:

app / views / devise / sessions / new.html.erb

Sign in

Sign in with your CommunityGuides account -- OR -- use an authentication service

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

<%= f.label :email %>
<%= f.text_field :email %>

<%= f.label :password %>
<%= f.password_field :password %>

<% if devise_mapping.rememberable? %>

<%= f.check_box :remember_me %> <%= f.label :remember_me %>

<% end %>

<%= f.submit "Sign in" %>

<% end %>

app / views / users / registrations / new.html.erb

Sign up

Sign up on CommunityGuides manually -- OR -- or use one of your existing accounts

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

<%= f.label :email %>
<%= f.text_field :email %>

<%= f.label :password %>
<%= f.password_field :password %>

<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>

<%= recaptcha_tags %>

<%= f.submit "Sign up" %>

<% end %>


You can download the images of Github: Authbuttons . Now our users can login or register through a convenient interface. In addition, we need a settings page where users can manage accounts associated with the local one.

app / views / services / index.html.erb

Authentication Services - Setting

The following <%= @services.count == 1 ? 'account is' : 'accounts are' %> connected with your local account at CommunityGuides:

<% @services.each do |service| %>
<%= image_tag "#{service.provider}_64.png", :size => "64x64" %>
Name: <%= service.uname %> (ID: <%= service.uid %>)
Email: <%= service.uemail != '' ? service.uemail : 'not set' %>
<% @services.count == 1 ? @msg = 'Removing the last account linked might lock you out of your account if you do not know the email/password sign-in of your local account!' : @msg = '' %> <%= link_to "Remove this service", service, :confirm => 'Are you sure you want to remove this authentication service? ' + @msg, :method => :delete, :class => "remove" %>
<% end %>

You can connect more services to your account:

If you signed-up for CommunityGuides via an authentication service a random password has been set for the local password. You can request a new password using the "Forgot your Password?" link on the sign-in page.



Add Google

Finally, let's add Google to our list of service providers. Google (and OpenID in particular) require persistent storage. You can use ActiveRecord or the file system as shown below. If you want to deploy to Heroku, remember that you do not have write access to / tmp. Although, as noted in Heroku Docs , you can write in ./tmp.

Two lines of configurations and four for assigning values ​​from a hash is all that is needed to add authorization via Google in your code. Isn't that great? Omniauth is enough for today, but if you want to use it in one of your projects, you can find a lot of resources in the Omniauth Wiki , and Raina Bates made great screencasts on it.

Reconfigure Devise

There is a slight flaw in the profile of our users. The user needs to enter the current password to change the settings. If it is registered through one of the services, then it does not have a password, remember, we set it to a random line. The Devise Wiki has an article on how to completely remove the password. But at home we want to leave the password only for local users. For other users, we’ll allow you to change your profile without using a password. In addition, they will be able to set a local password if they wish. This is achieved by modifying the update method for the registration controller:

app / controllers / users / registrations_controller.rb
...
def update
 # no mass assignment for country_id, we do it manually
 # check for existence of the country in case a malicious user manipulates the params (fails silently)
 if params[resource_name][:country_id]          
   resource.country_id = params[resource_name][:country_id] if Country.find_by_id(params[resource_name][:country_id])
 end
 if current_user.haslocalpw
   super
 else
   # this account has been created with a random pw / the user is signed in via an omniauth service
   # if the user does not want to set a password we remove the params to prevent a validation error
   if params[resource_name][:password].blank?
     params[resource_name].delete(:password)
     params[resource_name].delete(:password_confirmation) if params[resource_name][:password_confirmation].blank?
   else
     # if the user wants to set a password we set haslocalpw for the future
     params[resource_name][:haslocalpw] = true
   end
   # this is copied over from the original devise controller, instead of update_with_password we use update_attributes
   if resource.update_attributes(params[resource_name])
      set_flash_message :notice, :updated
      sign_in resource_name, resource
      redirect_to after_update_path_for(resource)
    else
      clean_up_passwords(resource)
      render_with_scope :edit
    end
 end
end
...


The code uses an additional field in the user model, you can return and add it to the migration ( t.boolean: haslocalpw,: null => false,: default => true ), change the model to allow mass assignment for this field, change the view to hide field for entering the current password if haslocalpw is false and change the create method of our service controller to set this field when creating the user:

app / controllers / services_controller.rb
...
 user = User.new :email => email, :password => SecureRandom.hex(10), :fullname => name, :haslocalpw => false
...


PS: this is my first big translation, so please error / wording wording in PM. Many thanks.

Also popular now: