論盡蛋
心で書く

Blog


User authentication

There are a bunch of well developed user authentication gems available for rails. One of them is OmniAuth.

Starting from version 1.x, OmniAuth separated every strategies into separated gems. OmniAuth integrated many different authentication providers, such as Facebook, Twitter, OpenId, in order to provide a standardized interface.

Each provider is a so-called strategy. Recently there is a omniauth-identity gem to due with the traditional username password authentication instead of using external providers.

In short, making use of OmniAuth, we could provide username password authentication with the omniauth-identity, while having an advantage of integrate with other external providers relatively easier.

Code

I am going to follow the episode #304 OmniAuth Identity with little modifications to suit our need.

1) add the following to the Gemfile and run bundle

#!ruby
gem 'bcrypt-ruby', '~> 3.0.0'
gem 'omniauth-identity'

2) create a config/initializers/omniauth.rb file with the following content:

#!ruby
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :identity
end

3) create the sessions_controller for handling sign in / out for omniauth

Run the following command: rails generate controller sessions.

Edit the generated app/controllers/sessions_controller.rb file as:

#!ruby
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.from_omniauth(env["omniauth.auth"])
    session[:user_id] = user.id
    redirect_to root_url, notice: "Signed in!";
  end

  def destroy
    session[:user_id] = nil
    redirect_to root_url, notice: "Signed out!";
  end

  def failure
    redirect_to root_url, alert: "Authentication failed, please try again.";
  end
end

Now you could see there are something we do not have right now: User model and root_url

4) create the User model

By using the following command:

#!bash
rails generate model user provider:string uid:string display_name:string # any other fields you might want

followed by rake db:migrate

and then update the generated app/models/user.rb as:

#!ruby
class User < ActiveRecord::Base
  def self.from_omniauth(auth)
    find_by_provider_and_uid(auth["provider"], auth["uid"]) || create_with_omniauth(auth)
  end

  def self.create_with_omniauth(auth)
    create! do |user|
      user.provider = auth["provider"]
      user.uid = auth["uid"]
      user.name = auth["info"]["name"]
    end
  end
end

Now the User model part is ready. It's time to the identity part. In omniauth-identity, there is another model for handling the authentication instead of the user model (of course you may map the model in the config...).

5) create the Identity model

By using the following command:

#!bash
rails generate model identity name:string email:string password_digest:string

followed by rake db:migrate

Now you need to update the generated app/models/identity.rb as:

#!ruby
class Identity < OmniAuth::Identity::Models::ActiveRecord
  # anything else you want
end

6) add the following two paths to suitable position in your view file:

6.1) create identity path: /auth/identity/register
6.2) login path: /auth/identity

The above 2 links are linked to the default registration and login pages. However, these 2 default pages does not match our design as well as there are no error handling. So we need to provide validations to Identity model and also provide the registration and login pages.

7) insert validation rules to the identity model (app/models/identity.rb):

#!ruby
validates :name, presence: true
validates :email, uniqueness: true, format: /^[^@ ]+@([-a-z0-9]+.)+[a-z0-9]{2

We do not need to add presence: true to the email validation as the format do not allow blank input.

8 ) points for the login form

I am not going to paste my full code in both the login form as well as the registration form as it would be too long.

Instead, I write down the things we need to notice here.

8.1) you should be using form_tag and must post to /auth/identity/callback
8.2) these two fields should present: auth_key (for email) and password
8.3) the above 2 keys should be in top level in params.

For example <%= text_field_tag :auth_key %>
Instead of <%= text_field_tag :login[auth_key] %>

If you actually using form_for, You could do: <%= f.text_field :auth_key, name: "auth_key" %>

8.4) I would recommend to put the form inside the sessions#new view. This view file in the future will also provide other external providers' login.

That's all for login form :)

9) points for the registration form

9.1) you should create a identities_controller and using at least the new action. (no need to use create action as the registration form is actually passed to omniauth for the standardized approach).
9.2) put the needed route into the routes.rb file. eg. resources :identities, only: [:new]
9.3) you should again be using form_tag and must post to /auth/identity/register
9.4) by default, only the following fields will be handled: name, email, password, password_confirmation.
password and password_confirmation must be handle, while name and email could be set in the configuration. To change name, email or to add more other fields, you should update the following line in the config/initializers/omniauth.rb as the next code block.
9.5) you need also to make omniauth-identity to redirect back to the identities#new, see the next code block too (config/initializers/omniauth.rb).

#!ruby
provider :identity, :fields => [:name, :email],
  on_failed_registration: lambda { |env|
  IdentitiesController.action(:new).call(env)
}

9.6) define the identities#new action as follow (app/controllers/identities_controller.rb):

#!ruby
def new
  @identity = env['omniauth.identity']
end

10) add the required routes to config/routes.rb

#!ruby
get '/login' => 'sessions#new', as: :login
match '/auth/:provider/callback', to: 'sessions#create'
match '/auth/failure', to: 'sessions#failure'
match '/logout', to: 'sessions#destroy', :as => :logout

resources :identities, only: [:new] # as well as the route for the registration form

All things should be done :)


Setup the test environment

There are some gems for specific parts of testing.

factory_girl - for generating fixtures

rspec2 - for doing unit test, integration test, etc

capabara - for mimicking user's behaviour in order to do user acceptance test and improviding the integration test

spork - to fork a test environment before each run of the tests, for firing test cases much faster than the traditional way which loads the entire rails test environment before you could run the test cases

guard - for handling events on file modifications. It is configured that whenever a source code or a test case is updated, the corresponding test cases would be run automatically. Together with spork, test cases would be automatically run in a fast pace whenever a source code or a test case is updated so that we could get the test result in real time

simplecov - for code coverage report generation

Code

Add the following code to Gemfile:

#!ruby
group :test, :development do
  gem 'rspec-rails', '~> 2.6'
  gem 'factory_girl_rails'
end

group :test do
  gem 'spork', '~> 0.9.0.rc'

  gem 'guard-rspec'
  gem 'guard-spork'

  gem 'capybara'

  gem 'simplecov', :require => false
end

then run bundle or bundle install.

To init rspec:

#!bash
rails generate rspec:install
> create  .rspec
> create  spec
> create  spec/spec_helper.rb

To setup spork:

#!bash
spork --bootstrap

After that, some instructions are inserted into spec/spec_helper.rb automatically. Update the spec/spec_helper.rb file according to the instructions.

Now, it's the time to guard rspec.

#!bash
guard init rspec
> Writing new Guardfile to /Users/PeterWong/Projects/sobiwi/Guardfile
> rspec guard added to Guardfile, feel free to edit it

Then to guard spork:

#!bash
guard init spork
> spork guard added to Guardfile, feel free to edit it

Now we need to update the Guardfile to move the newly appended guard 'spork' block to the top of the guard 'rspec' block.

Also we need to update the Guardfile:

#!ruby
# change the following line
guard 'rspec', :version => 2 do
# to
guard 'rspec', :version => 2, :cli => '--drb' do

Now guard is working and to run test cases in real time, run the command guard.

To setup capybara, update spec/spec_helper.rb:

#!ruby
ENV["RAILS_ENV"] ||= 'test'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'
require 'rspec/autorun'
require 'capybara/rspec' # add this new line

finally to automatically reload factory_girl fixtures, add the following line inside the Spork.each_run block in the spec/spec_helper.rb:

#!ruby
FactoryGirl.reload

Now things are all done. We could do TDD too :)

Oh, forgot to setup the simeplecov. To set it up, insert the following line in the very beginning of the spec/spec_helper.rb file:

#!ruby
require 'simplecov'
... # rest of the original file content

and then create a .simplecov file in the root of the project having the following content:

#!ruby
SimpleCov.start 'rails' do
  add_filter 'spec'

  add_group 'Mailers', 'app/mailers'
end

It is to tell simplecov to ignore the code coverage of the spec directory (we do not test out test cases) and add a group named Mailers for the mailers in case we have mailers in use.

SimpleCov.start 'rails' will automatically group controllers, helpers, models, lib and plugins code and so those groups do not need to be added by ourself.

One more note, to obtain the coverage report, guard cannot be used. Instead we should run rspec directly:

#!bash
rspec .

to run all the spec to obtain the full coverage report (as the hits per line etc need to be calculated, we must ensure every test case is run once).


Rails layout for XML builder

Github repo for the example code: CeXMLLayout

The example code shows how to use layout for xml builder.

If you create app/views/layout/application.xml.builder and simply add a yield inside it, you will find that the yield actually not working very well.

There are more detailed description inside the github repo. Here is the short answer:

#!ruby
xml.instruct!
xml.root do
  xml << yield
  # or
  xml << yield.gsub(/^/, ) # To add 2 spaces before each line in the yielded content, or else the indentation won't be correct.
end