In the last post we created step definitions for the first part of a sign in scenario, and we got as far as adding a dead "Sign In" link to the page. Now we're ready to actually build the sign in system. We've decided that we do not want to be storing passwords in our system, instead we'll depend on 3rd party authentication providers, namely GitHub and Twitter to start.
OmniAuth is a great gem that handles most of the work of delegating authentication. The Simple OmniAuth RailsCast has a great overview of getting started with OmniAuth. We're going to use a slightly modified version of the approach in the RailsCast.
We want to allow a user to connect both their Twitter account and their GitHub account, and potentially other providers down the road. So, we'll have the standard User
model that will represent an individual, and then we'll have an Aunthentication
model that will represent one linked account. When a signed out user authenticates through one of the providers for the first time, we'll create an Authentication
record for that provider, and also a User
record to represent the individual. Then we'll prompt them to link other accounts. Each new linked account will result in a new Authentication
record linked to that User
.
To follow BDD/TDD best practices, let's just start with the next step definition that we need to implement. When he signs in with GitHub
.
This sounds like a simple step, but it's actually going to be comprised of a few steps. For simplicity of UI, we'll want to have a single "Sign In" link in the header of the site. When that is clicked the user should be presented with the available auth providers so that they can choose which one to use. In this case we'll assume that they choose to authenticate through GitHub.
First let's use this as the step definition
When(/^he signs in with GitHub$/) do
click_on "Sign In"
page.should have_content("Sign In With GitHub")
click_on "Sign In With GitHub"
end
Now when we run Cucumber we see this:
$ cucumber features/users/signin.feature -r features/
Using the default profile...
Feature: Sign In
As a visiting user
I want to sign in
Sign in will happen via GitHub (and possibly other auth providers)
Scenario: Sucessful sign in # features/users/signin.feature:6
Given a signed out user # features/step_definitions/users_steps.rb:1
When he visits the home page # features/step_definitions/users_steps.rb:5
Then he should see "Sign In" # features/step_definitions/users_steps.rb:9
When he signs in with GitHub # features/step_definitions/users_steps.rb:13
expected to find text "Sign In With GitHub" in "Sign In Home#index Find me in app/views/home/index.html.erb" (RSpec::Expectations::ExpectationNotMetError)
features/users/signin.feature:10:in `When he signs in with GitHub'
Then he should see "Sign Out" # features/step_definitions/users_steps.rb:9
The error message shows that we're still viewing our home/index.html.erb
template. That's because the "Sign In" link is still empty. We need to point that link somewhere, so let's create a SessionsController
to handle prompting the user for a new session and creating the session when the auth provider returns. We'll tell the generator that we want a new
action including a template.
rails g controller sessions new
Also add this line to config/routes.rb
:
get "signin" => "sessions#new", :as => :signin
Then update the views/layouts/_header.html.erb
template to point to the real sign in page.
<%%= link_to "Sign In", signin_path %>
Now we can add an empty "Sign In With GitHub" link to views/sessions/new.html.erb
and our current step definition will pass, but we'll get an error that the "Sign Out" link has not appeared.
<%%= link_to "Sign In With GitHub", "#" %>
Now we need to add OmniAuth to the Gemfile
. Since we need strategies for GitHub and Twitter, we can just include those gems, and they'll include OmniAuth itself as a dependency.
gem "omniauth-github", "~> 1.1.1"
gem "omniauth-twitter", "~> 1.0.1"
Then run bundle install
.
Now we can change the link to point to /auth/github
and OmniAuth with take care of the rest. We'll also add a link to the built in 'developer' strategy that comes with omni-auth. This is handy for local testing so that you don't have to set up GitHub or Twitter integration yet.
<%%= link_to "Sign In With GitHub", "/auth/github" %>
<%% unless Rails.env.production? %>
<br />
<%%= link_to "Sign In With Developer", "/auth/developer" %>
<%% end %>
But now Cucumber will fail, saying that there's no route for /auth/github
.
...
When he signs in with GitHub # features/step_definitions/users_steps.rb:13
No route matches [GET] "/auth/github" (ActionController::RoutingError)
features/users/signin.feature:10:in `When he signs in with GitHub'
This is because we haven't configured OmniAuth yet. Put this at config/initilizers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :developer unless Rails.env.production?
provider :twitter, ENV['TWITTER_CONSUMER_KEY'], ENV['TWITTER_CONSUMER_SECRET']
provider :github, ENV['GITHUB_CONSUMER_KEY'], ENV['GITHUB_CONSUMER_SECRET']
end
We also need to setup OmniAuth for testing. Add this to features/support/omniauth.rb
Before('@omniauth_test') do
OmniAuth.config.test_mode = true
# the symbol passed to mock_auth is the same as the name of the provider set up in the initializer
OmniAuth.config.mock_auth[:github] = {
"provider"=>"github",
"uid"=>"abc123",
"user_info"=>{"email"=>"test@xxxx.com", "first_name"=>"Test", "last_name"=>"User", "name"=>"Test User"}
}
OmniAuth.config.mock_auth[:twitter] = {
"provider"=>"twitter",
"uid"=>"def456",
"user_info"=>{"email"=>"test@xxxx.com", "first_name"=>"Test", "last_name"=>"User", "name"=>"Test User"}
}
end
After('@omniauth_test') do
OmniAuth.config.test_mode = false
end
Then we need to update the feature file (features/users/signin.feature
) to indicate that the sing in test is an @omniauth_test
.
Feature: Sign In
As a visiting user
I want to sign in
Sign in will happen via GitHub (and possibly other auth providers)
@omniauth_test
Scenario: Sucessful sign in
Given a signed out user
When he visits the home page
Then he should see "Sign In"
When he signs in with GitHub
Then he should see "Sign Out"
...
Now Cucumber will complain about a missing route for /auth/github/callback
.
...
When he signs in with GitHub # features/step_definitions/users_steps.rb:13
No route matches [GET] "/auth/github/callback" (ActionController::RoutingError)
features/users/signin.feature:11:in `When he signs in with GitHub'
This means that OmniAuth is properly simulating the "OAuth Dance" with GitHub and is now ready for the application to handle an authenticated user. Now we need to add handling for that route and point it to SessionsController#new
.
# add to config/routes.rb
match "/auth/:provider/callback" => "sessions#create", :via => [:get, :post]
Before we can implement the session handling we're going to need models to represent the User
and Authentication
. Let's generate the models and run the DB migrations.
rails g model user name:string
rails g model authentication user_id:integer provider:string uid:string name:string
rake db:migrate; rake db:test:prepare;
We can also set up the relationship between the models.
class Authentication < ActiveRecord::Base
belongs_to :user
end
class User < ActiveRecord::Base
has_many :authentications
end
Now we can get back to creating the user session. Handling the info that we get back from OmniAuth is potentially messy, so in order to keep the controller code nice and light, let's use a from_omniauth
method on the Authentication
class to handle looking up or creating an Authentication
based on the OAuth info. Then we'll grab the User
for that Authentication
. So the create
method on SessionsController
looks like this.
protect_from_forgery :except => :create
def create
authentication = Authentication.from_omniauth(env["omniauth.auth"])
user = authentication.user
session[:user_id] = user.id
redirect_to root_url, notice: "Signed in!"
end
In the from_omniauth
method, we're just going to return the first Authentication that matches the provider and uid, or create a new one.
class Authentication < ActiveRecord::Base
belongs_to :user
def self.from_omniauth
where(auth.slice("provider", "uid")).first || create_from_omniauth(auth)
end
def self.create_from_omniauth(auth)
info = auth["user_info"] || auth["info"]
name = info["name"] rescue ""
create! do |authentication|
authentication.provider = auth["provider"]
authentication.uid = auth["uid"]
authentication.name = name
authentication.user = User.create(:name => name)
end
end
end
Now we'll actually be logged in, but Cucumber still isn't happy because we haven't added the "Sign Out" link to the page.
First we'll add a helper function that will give us easy access to the currently signed in user. How about we call it current_user
?
class ApplicationController < ActionController::Base
# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception
helper_method :current_user
private
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
end
Now we can update the app/views/layouts/_header.html.erb
partial to have a sign out link.
<%% if current_user %>
Welcome <%%= current_user.name %>!
<%%= link_to "Sign Out", signout_path %>
<%% else %>
<%%= link_to "Sign In", signin_path %>
<%% end %>
signout_path
doesn't exist yet, so we need to add a route to config/routes.rb
get "signout" => "sessions#destroy", :as => :signout
And we need to add the destroy
method to SessionsController
def destroy
session[:user_id] = nil
redirect_to root_url, notice: "Signed out!"
end
And that's it! Kind of. Not really….
Now a user can sign in through a single provider, but they can't yet connect multiple accounts.
Next time we'll look at allowing multiple connections as well as smoothing out some of the existing rough spots. We'll also cover getting environment variables set up correctly to allow local testing of the GitHub integration. Right now things only work with the simulated OAuth dance inside of Cucumber.
As always feel free to check out the Techlahoma code in progress and don't hesitate to contact us or submit a pull request if you'd like to get involved.