Rails 4.2 Tutorial 2 - User Authentication

  • Feb 11

To begin, we will have a landing page where everyone can access, then set up user authentication and allow users to login. Instead of password, it will use Google OAuth2 for authentication. This voids the problem of keeping password safe and let the capable ones deal with security.

As CoffeeScript and SCSS replace Javascript and CSS, we will use slim to replace HTML. Thus, add gem slim-rails in Gemfile and install it by bundle install command.

A generic pages controller is created with a view (html page):

rails generate controller pages landing

It will generate a controller called PagesController with a view at pages/landing. If you are not sure about what it will generate, you can append --pretend at the end of command and it will not really write to the disk but only show what it will generate.

Once we generate the new pages#landing (a notion of controller#action in rails), we can set the root path of this application to this landing page.

# in config/routes.rb
  root 'pages#landing' 

If you start the rails server, the original welcome is replace by this landing site.

For user, we will use devise, omniauth and omniauth-google-oauth2. Thus, add these three gems into Gemfile and install them.

First, follow devise's instruction to set up user model:

$ rails generate devise:install
$ rails generate devise User

Since we do not really handle user's password, we can simplify the user model like this:

# in app/models/user.rb
class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :validatable, :registerable and :recoverable
  devise :database_authenticatable, :omniauthable, :rememberable, :trackable
end

And corresponding migration:

# in db/migrate/20150213083500_add_devise_to_users.rb
class DeviseCreateUsers < ActiveRecord::Migration
  def change
    create_table(:users) do |t|
      t.string :username

      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      # t.string   :reset_password_token
      # t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.inet     :current_sign_in_ip
      t.inet     :last_sign_in_ip

      ## Confirmable
      # t.string   :confirmation_token
      # t.datetime :confirmed_at
      # t.datetime :confirmation_sent_at
      # t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at

      t.timestamps
    end

    add_index :users, :email,                unique: true
    # add_index :users, :reset_password_token, unique: true
    # add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end

Note that we add an extra field username. You are freely to extra fields. After that, we can add these fields into database:

$ rake db:migrate

If you look at the devise initiaion file config/initializers/devise.rb, it also has secret key inside. Before you commit it into git repository, you should use figaro to move the secret value to config/application.yml. If a new secret key is needed, use rake secret to generate one. Then use environment variable for Devise secret like this:

# in config/initializers/devise.rb
  config.secret_key = ENV['DEVISE_SECRET_KEY']

Since we will use Google OAuth2 to handle user sign in, there is not user registration. Therefore, we only need to handle user sign-in and sign-out. If we look at the routes of this application by rake routes, you will find the existing paths:

                 Prefix Verb     URI Pattern                            Controller#Action
       new_user_session GET      /users/sign_in(.:format)               devise/sessions#new
           user_session POST     /users/sign_in(.:format)               devise/sessions#create
   destroy_user_session DELETE   /users/sign_out(.:format)              devise/sessions#destroy
user_omniauth_authorize GET|POST /users/auth/:provider(.:format)        devise/omniauth_callbacks#passthru {:provider=>/(?!)/}
 user_omniauth_callback GET|POST /users/auth/:action/callback(.:format) devise/omniauth_callbacks#:action
                   root GET      /      

Only three paths are needed: sign-in (user_omniauth_authorize), OAuth2 callback (user_omniauth_callback) and sign-out(destroy_user_session). We will use the landing page to implement the sign-in and sign-out.

For development purporse, we will use development strategy, which only check email. But I will also show the code for using Google OAuth2 and you need to get the OAuth 2 access token from Google. Once the application is hosted online, you can rely on Google for user authentication.

Device create the user model already. We need to add the controllers and views.

$ rails g scaffold_controller user

And the generated file:

     create  app/controllers/users_controller.rb
      invoke  slim
      create    app/views/users
      create    app/views/users/index.html.slim
      create    app/views/users/edit.html.slim
      create    app/views/users/show.html.slim
      create    app/views/users/new.html.slim
      create    app/views/users/_form.html.slim
      invoke  test_unit
      create    test/controllers/users_controller_test.rb
      invoke  helper
      create    app/helpers/users_helper.rb
      invoke    test_unit
      invoke  jbuilder
      create    app/views/users/index.json.jbuilder
      create    app/views/users/show.json.jbuilder

Since we do not manage users through this controller, we can safely delete the action :index, :new and :create. To administrate the application, it is better to use something like RailsAdmin.

Then config/routes.rb is modified like this:

Rails.application.routes.draw do
  devise_for :users
  resources :users, :only => [:show, :destroy, :edit, :update]

To implement both developer and oauth2 strategy of omniauth, there is the code in config/routes.rb

# config/routes.rb
Rails.application.routes.draw do                                                                               
  devise_for :users,                                                                                           
             :controllers => {                                                                                 
               :omniauth_callbacks => 'users/omniauth_callbacks'                                               
             }                                                                                                 
  resources :users, :only => [:show, :destroy, :edit, :update]                                                        
                                                                                                               
  root 'pages#landing'                                                                                         
end    

And controllers at app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_filter :verify_authenticity_token, :only => [:developer]

  def developer
    user = User.find_for_developer(request.env['omniauth.auth'])
    if user
      sign_in_and_redirect user, :event => :authentication, :notice => "Sign in as #{user.email}."
    else
      flash[:notice] = I18n.t "devise.omniauth_callbacks.failure", :kind => "Developer", :reason => "Cannot find guest account"
      redirect_to :root
    end
  end

  def google_oauth2
    user = User.find_or_create_for_google(request.env['omniauth.auth'])
    if user
      sign_in_and_redirect user, :event => :authentication, :notice => "Sign in as #{user.email}."
    else
      flash[:notice] = I18n.t "devise.omniauth_callbacks.failure", :kind => "Google OAuth2", :reason => "Cannot find google oauth2 account"
      redirect_to :root
    end
  end
end

And two methods in User model are needed:

# app/models/user/omniauth_callbacks.rb
module User::OmniauthCallbacks
  extend ActiveSupport::Concern

  module ClassMethods
    def find_for_developer(response)
      user = User.find_by(email: response.uid)
      user
    end

    def find_or_create_for_google(response)
      info = response.info
      user = User.find_by(email: info[:email])
      if user.nil?
        password = Devise.friendly_token[0, 20]
        user = User.create!(email: info[:email], password: password, password_confirmation: password)
      end
      user
    end
  end
end

This module is written as Concern. Therefore, we need to add this module to User model:

class User < ActiveRecord::Base
  include OmniauthCallbacks

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :validatable, :recoverable and :registerable
  devise :database_authenticatable, :omniauthable, :rememberable, :trackable
end

Then include these two stragety in config/initializers/devise.rb

  config.omniauth :developer unless Rails.env.production?                                                      
  config.omniauth :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], {:scope => 'email, profile'}   

Now, if you run rake routes, you will find the strategy like this:

user_omniauth_authorize GET|POST /users/auth/:provider(.:format)        users/omniauth_callbacks#passthru {:provider=>/developer|google_oauth2/}

To show the status of login, add these lines to landing page:

h1 Pages#landing                                                                                               
- flash.each do |name, msg|                                                                                    
  = content_tag :div, msg, class: name                                                                         
p Find me in app/views/pages/landing.html.slim 

It will show all the information from flash.

Now, you can fire up the server by rails server and link to the develope login page at /users/auth/developer. A default developer login page will show up. You can input any email and see. It will jump back to landing page and show error message because we do not have a valid user account yet.

Unlike oauth2 strategy, which find and create new user, developer strategy only find user because we do not want outsiders to login. So here, we will create accounts for developers manually. Fire the Rails console by rails console first.

Then create user like this:

> password = Devise.friendly_token[0, 20]
> user = User.create!(email: 'developer@gmail.com', password: password, password_confirmation: password)

Now. login again with this email and the message should show successful login.

It is easy to have a small view showing sign-in and sign-out link like this:

-if current_user
  = link_to current_user.username || current_user.email, current_user
  |  |  
  = link_to "Sign Out", destroy_user_session_path, method: :delete
-else
  = "Sign in with "
  | [ 
  - if not Rails.env.production?
    = link_to "Developer", user_omniauth_authorize_path(:developer) 
    |  | 
  = link_to "Google OAuth2", user_omniauth_authorize_path(:google_oauth2)
  | ]

To check whether user has signed in, check the existing of current_user variable.

Now, you have a basic sign in and sign out mechanism. You probably should find a good HTML/CSS template for app/views/layouts/application.html.erb.

To let users show and edit their username, modify app/views/users/show.html.slim like this:

p#notice = notice
                                                                                                               
p
  Username = @user.username
                                                                                                               
p
  Email = @user.email                                                                                          
                                                                                                               
= link_to 'Edit', edit_user_path(@user) 

and app/views/users/_form.html.slim

=form_for @user do |f|
  - if @user.errors.any?
    #error_explanation
      h2 = "#{pluralize(@user.errors.count, "error")} prohibited this user from being saved:"
      ul
        - @user.errors.full_messages.each do |message|
          li = message

  p
    label Username:

  p
    = f.text_field :username

  .actions = f.submit

and app/views/users/edit.html.slim

h1 Editing user

== render 'form'

= link_to 'Show', @user

And finally the permitted parameter in app/controllers/users_controller.rb

    def user_params
      params.require(:user).permit(:username)
    end

Now, you should be able to edit username.

Finally, we only allow sign-in user to do most things, we can lock down every page by this:

# app/controllers/application_controller.rb
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                                                                        
  before_action :authenticate_user!                                                                            
end 

But the landing page is for everyone, thus, add this to it:

# app/controllers/pages_controller.rb
class PagesController < ApplicationController                                                                  
  skip_before_action :authenticate_user!, only: [:landing]                                                     
                                                                                                               
  def landing                                                                                                  
  end                                                                                                          
end 

The last issue to take care is that devise provides the password signin page by default. Since we do not need it, it is better to close the door to avoid hacking. Thus, we will change the routes like this:

Rails.application.routes.draw do                                                                               
  devise_for :users,
             :skip => [:sessions],
             :controllers => {
               :omniauth_callbacks => 'users/omniauth_callbacks'
             }
  devise_scope :user do
    delete 'users/sign_out' => 'devise/sessions#destroy', :as => :destroy_user_session
  end
  resources :users, :only => [:show, :edit, :update, :destroy]

  root 'pages#landing'
end  

And rake routes now look like this:

                 Prefix Verb     URI Pattern                            Controller#Action
user_omniauth_authorize GET|POST /users/auth/:provider(.:format)        users/omniauth_callbacks#passthru {:provider=>/developer|google_oauth2/}
 user_omniauth_callback GET|POST /users/auth/:action/callback(.:format) users/omniauth_callbacks#:action
   destroy_user_session DELETE   /users/sign_out(.:format)              devise/sessions#destroy
              edit_user GET      /users/:id/edit(.:format)              users#edit
                   user GET      /users/:id(.:format)                   users#show
                        PATCH    /users/:id(.:format)                   users#update
                        PUT      /users/:id(.:format)                   users#update
                        DELETE   /users/:id(.:format)                   users#destroy
                   root GET      /                                      pages#landing

You will notice that users/sign_in disappears. We only keep what we need.