homeASCIIcasts

236: OmniAuth Part 2 

(view original Railscast)

Other translations: Es

In the last episode [watch, read] we showed you how to add various forms of authentication to a user account in a Rails application using OmniAuth. By the end of that episode users who had already signed in with a username and password could also authenticate via third-party services such as Twitter. In this episode we’ll continue to look at OmniAuth and will extend our application so that it can handle users who have signed out and want to sign back and also users who have not signed in at all.

Updating OmniAuth

A new version of OmniAuth that includes some important fixes has been released since the last episode was written. To update our application to use the new version we just need to run

$ bundle update

from the application’s directory. This new version of OmniAuth changes the name of the request environment variable that it uses from rack.auth to omniauth.auth and so we’ll have to update our application to reflect this.

/app/controllers/authentications_controller.rb

def create
  auth = request.env["ominauth.auth"]
  current_user.authentications.find_or_create_by_provder_and_uid↵ 
    (auth['provider'], auth['uid'])
  flash[:notice] = "Authentication successful."
  redirect_to authentications_url
end

Signing In With OmniAuth

The first change we’ll make to the application is to enable it to allow existing users to sign in via Twitter. Currently if we try this we’ll be taken to Twitter but when we authenticate there and are redirected back to our application we’ll see an error message.

The error that is thrown when an existing user tries to sign in via Twitter.

The reason this error is thrown is that the AuthenticationController’s create action expects there to be a currently logged-in user and tries to find or create a new authentication for that user. As we’re trying to authenticate without having first logged in with a user name and password the current_user variable will be nil.

/app/controllers/authentications_controller.rb

def create
  auth = request.env["ominauth.auth"]
  current_user.authentications.find_or_create_by_provder_and_uid↵ 
    (auth['provider'], auth['uid'])
  flash[:notice] = "Authentication successful."
  redirect_to authentications_url
end

We’ll need to change this code so that it tries to find the appropriate authentication and its associated user. If it finds one then the application will sign the user in, otherwise it will create one. We still need to handle the scenario where no authentication exists for a new user, but we’ll come back to that later.

/app/controllers/authentications_controller.rb

def create
  omniauth = request.env["omniauth.auth"]
  authentication = Authentication.find_by_provder_and_uid ↵
    (omniauth['provider'], omniauth['uid'])
  if authentication
    flash[:notice] = "Signed in successfully."
    sign_in_and_redirect(:user, authentication.user)
  else
    current_user.authentications.create(:provider => omniauth ↵
      ['provider'], :uid => omniauth['uid'])
    flash[:notice] = "Authentication successful."
    redirect_to authentications_url
  end
end

The create method now looks for an Authentication based on the provider and uid parameters from the omniauth.auth environment variable. If it finds one then we want that user to be signed in, so we use Devise’s sign_in_and_redirect method to sign in the user associated with that authentication. If a matching authentication isn’t found then we’ll create a new authentication for the current user.

We can test this out by visiting the authentications page while we’re not logged in and signing in via Twitter. We’ll should be signed in to the application and then redirected back to the home page.

Successfully signed in via Twitter.

Handling Brand-New Users

Next we’ll need change our application to handle brand-new users coming to our site and trying to sign in through Twitter. Our application won’t work for these users as it stands as the code in the create action still expects a current_user when it fails to find an existing authentication.

We can see what will happen in this case by signing out of both Twitter and our app then logging in with a different Twitter account. Twitter redirects back to our application but we get an error message as the code in the create action tries to retrieve authentications for the non-existent current_user.

Trying to sign up via Twitter throws an error.

To fix this we need to add another condition in the create action so that it can handle this situation.

/app/controllers/authentications_controller.rb

def create
  omniauth = request.env["omniauth.auth"]
  authentication = Authentication.find_by_provider_and_uid ↵
    (omniauth['provider'], omniauth['uid'])
  if authentication
    flash[:notice] = "Signed in successfully."
    sign_in_and_redirect(:user, authentication.user)
  elsif current_user
    current_user.authentications.create(:provider => omniauth  ↵
      ['provider'], :uid => omniauth['uid'])
    flash[:notice] = "Authentication successful."
    redirect_to authentications_url
  else
    user = User.new
    user.authentications.build(:provider => omniauth ↵
      ['provider'], :uid => omniauth['uid'])
    user.save!
    flash[:notice] = "Signed in successfully."
    sign_in_and_redirect(:user, user)
  end
end

This method now handles three different conditions. If a matching authentication is found it signs in the user associated with that authentication. If there is no matching authentication, but there is a currently signed-in user an authentication is created for that user. Finally, if there is no current user then a new user is created, a new authentication is built for that user and the user is signed in.

If we try logging in as a new user via Twitter now we get a different error message when Twitter redirects back to our application.

Signing up now throws validation errors.

This time the application throws the error “Validation failed: Email can't be blank, Password can't be blank”. Devise reports these validation errors on the User model because it assumes that these two fields exist on the model.

In the User model we’ve included :validatable in the list of options that we’ve passed to the devise method and it’s here that the validations for the email and password fields are set.

/app/models/user.rb

class User < ActiveRecord::Base
  has_many :authentications
  # Include default devise modules. Others available are:
  # :token_authenticatable, :lockable, :timeoutable 
  # :confirmable and :activatable
  devise :database_authenticatable, :registerable, 
         :recoverable, :rememberable, :trackable, :validatable

  # Setup accessible (or protected) attributes for your model
  attr_accessible :email, :password, :password_confirmation
end

The simplest way to fix this would be to remove the :validatable option. This would work if we only wanted to support authentication via Twitter but we also want to keep the ability for people to log in with a username and password so this isn’t an option as it would remove the validation in this case. Alternatively we could save the new user without validating it by replacing

user.save!

in the create action with

user.save(:validate => false)

This will skip the validation when we save the user but of course it would mean that we could end up with invalid data in the database. The User model has a username field and we want each username to be unique. Skipping the validation here could easily lead to duplicated usernames.

Unfortunately there is no easy solution to this problem. What we’ll do is keep the validation and redirect the user to a form where they can fix any problems if the validation fails when we try to save the new user. We’ll change the code in create so that if the validation fails when we try to save a new user they’ll be redirected the new user registration page. We don’t want to lose the user’s OmniAuth information when this happens and so we’ll store it in the session. The OmniAuth hash can sometimes contain too much data to fit in the cookie session store so we’ll remove the extra key. This key stores a lot of information that we don’t need to get the user registered so it is safe to remove it.

/app/controllers/authentications_controller.rb

user = User.new
user.authentications.build(:provider => omniauth['provider'], ↵
  :uid => omniauth['uid'])
if user.save
  flash[:notice] = "Signed in successfully."
  sign_in_and_redirect(:user, user)
else
  session[:omniauth] = omniauth.except('extra')
  redirect_to new_user_registration_url
end

The next thing we need to do is to customize the way the registration controller behaves. This is buried deep inside the Devise code but we can override it by creating a new registration controller.

$ rails g controller registrations

We’ll need views to go with this controller and we can copy over the ones that Devise uses by using the devise:views generator.

$ rails g devise:views

This copies the views from the Devise engine into the /app/views/devise directory in our application. In this directory is a registrations directory that contains two erb files. We’ll need to move these two files into /app/views/registrations so that they work with our new controller.

Next we need to change the routes file so that we tell Devise to use our controller for registrations rather than its default one.

/config/routes.rb

ProjectManage::Application.routes.draw do |map|
  match '/auth/:provider/callback' => 'authentications#create'
  devise_for :users, :controllers => { :registrations => ↵ 
    'registrations' }
  resources :projects
  resources :tasks
  resources :authentications
  root :to => 'projects#index'
end

In the controller we’ve just generated we want to override some of Devise’s registrations controller’s functionality so we’ll modify to so that it inherits from Devise::RegistrationsController rather than from ApplicationController. Devise’s RegistrationsController class has a method called build_resource that builds a User model inside the new and create actions. By overriding this method we can add some custom behaviour to the user model that is created and add an associated authentication based on the information that we saved in the OmniAuth session variable. What we’ll do here is very similar to what we do in the create action in the AuthenticationsController so we’ll extract this functionality out into a new method in the User model that we’ll call apply_omniauth.

/app/models/user.rb

def apply_omniauth(omniauth)
  authentications.build(:provider => omniauth['provider'], ↵
    :uid => omniauth['uid'])
end

We can now use this method in the AuthenticationController’s create action.

/app/controllers/authentications_controller.rb

user = User.new
user.apply_omniauth(omniauth)
if user.save
  flash[:notice] = "Signed in successfully."
  sign_in_and_redirect(:user, user)
else
  session[:omniauth] = omniauth.except('extra')
  redirect_to new_user_registration_url
end

And also in our new RegistrationsController.

/app/controllers/registrations_controller.rb

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

We only want to do this if the session variable exists, so we check for it first. Also we’ll validate the user so that the validations are shown in the new action when a user is redirected there.

Let’s see if this new code works. If we visit the application while not signed in and authenticate via Twitter we’ll now be redirected back to the sign up page as we don’t yet have a user account and we’ll see the validation errors.

The sign-up form now shows the validation errors.

We don’t want to validate the password field when the user has other means of authentication so in the User model we’ll disable the password validations. Devise gives us a way to do this by overriding the boolean password_required? method. We only want to validate the password when the user has no other authentications or when they are trying to add a password. We also want to delegate to super so that we get the overridden behaviour is applied as well.

/app/models/user.rb

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

We want to hide the password fields in the signup form when they’re not required and we can use password_required? in the view code to do this.

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

<h2>Sign up</h2>

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

  <p><%= f.label :email %><br />
  <%= f.text_field :email %></p>

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

  <p><%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %></p>
<% end %>
  <p><%= f.submit "Sign up" %></p>
<% end %>

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

If we reload the form now we’ll see that there is only one validation error displayed and that the password fields are not shown.

Only the email address field and error are now shown.

There’s one more thing we need to do inside the RegistrationsController and that is to remove the OmniAuth data from the session once the user has successfully registered. We can do that by overriding the create action.

/app/controllers/registration_controllers.rb

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

Now, when the user successfully registers the OmniAuth session data will be removed. If we fill in an email address in the form now we’ll be able to sign up successfully and when we go to the authentications page we’ll see our Twitter authentication listed.

The Twitter authentication is now listed for the new user.

Adding a Service To OmniAuth

We’ll finish off by showing how easy it is to add another service to OmniAuth by adding OpenID support. Our application is currently using WEBrick in development mode and OpenID has some problems when used with WEBrick because it uses some long URLs so we’ll switch over to Mongrel.

To do this we just need to add a reference to Mongrel in our application’s Gemfile. We’ll specify version 1.2.0.pre as this works with Rails.

/Gemfile

gem 'mongrel', '1.2.0.pre2'

Next we need to modify our OmniAuth config file and and add OpenId to the list of providers.

/config/initializers/omniauth.rb

require 'openid/store/filesystem'
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :twitter, 's3dXXXXXXXX', 'lR23XXXXXXXXXXXXXXXXXX'
  provider :open_id, OpenID::Store::Filesystem.new('/tmp')
end

We’re using a file-system store with OpenID. If your host doesn’t provide this then you could use memcached or an ActiveRecord store, but OpenID does require a persistent store. Note that OpenID doesn’t include the filesystem store automatically so we need to require it at the top of the file.

Lastly we need to go into the User model and change the way that the apply_omniauth method works. OpenID provides an email parameter and we’ll use it in the User model unless the user already has an email address.

/app/models/user.rb

def apply_omniauth(omniauth)
  self.email = omniauth['user_info']['email'] if email.blank?
  authentications.build(:provider => omniauth['provider'], ↵
    :uid => omniauth['uid'])
end

We can try this out now by signing in with an OpenID account. When we do we’ll be redirected to our OpenID provider’s website so that we can log in there and then we’re redirected back to our application and signed in. The email address from our OpenID account is automatically applied to our account details here.

Signed up via OpenID.

If we look at our authentications page as well we’ll see the OpenID authentication listed.

The OpenID authentication is now listed for the user.

The title for the OpenID authentication on the page above is wrong so we’ll quickly fix it. The title for each authentication is rendered using the following code:

/app/views/authentications/index.html.erb

<div class="provider">
  <%= authentication.provider.titleize %>
</div>

Using titleize doesn’t really work in this case so instead we’ll write a new method in the Authentication class and use that here instead.

/app/views/authentications/index.html.erb

<div class="provider">
  <%= authentication.provider_name %>
</div>

The provider_name method is straightforward and looks like this.

/app/models/authentication.rb

class Authentication < ActiveRecord::Base
  belongs_to :user
  
  def provider_name
    if provider == 'open_id'
      "OpenID"
    else
      provider.titleize
    end
  end
end

That’s all we have time for in this episode. OmniAuth is an impressive gem, especially given the number of services it can connect to. Once you get its foundations established then it’s easy to add services as you need them. Shoehorning them into an existing user account system is a little tricky and we’ve not covered all of the details here but there should been enough here to give you a good start when it comes to using OmniAuth with Devise.