Sunday, June 5, 2016

Devise Two-Step

DeviseTwoStep

Finally

Let me first say I am not a Devise expert by any stretch of the imagination. The project I am working on has a requirement to use two different models for authentication and I could not find a complete example of how to setup Devise to do this.

The documentation and several web sites and stack overflow articles I found gave hints on how to do it but I still had questions. So I thought I would write this post to show how I setup a test project to do it from end to end.

My goal is to have two models, user and admin that both are authenticated using Devise. Each model (I'll call it an account from here on out when referring to both) will have a set of pages that are only accessed when the account is logged in.

Additionally these protected pages are only accessible by one of the two accounts (depending on who is loved in). Finally, each account will have it's own (and separate) sign-in page and layout.

Much of the advice on the net recommended using a role based approach to solve this problem, but for many reasons that won't work for the app I will eventually apply this to. So a role based solution is off the table.

I started this investigation at the Devise website{***}. Unfortunately, I could not follow it enough to get a successful implementation. It felt like I was missing something.

The problem I kept running into was I would login as an Admin and when I logged out I would get redirected to the User's login page or if I logged in as the User I could then get to the Admin protected pages when it shouldn't. So something just wasn't clicking.

I next worked through this tutorial with the following deviations:

  • When I copied the devise views into the app I put them in their own directories with this command:
`rails generate devise:views User`
  • I did not do the section on Sending E-Mail and DelayedJob as they were not germane to the problem I was trying to solve

Once I had that going I created my Admin model and migrated my database.

rails generate devise Admin
rake db:migrate

Note: I did not add a name attribute to the Admin model like was done in the tutorial for the User model, as there was no need for it. I also left the registration support in so I could quickly add admin users, but obviously in a real app you wouldn't want to allow that.

I next created the Devise views for the Admin model (I had already copied the user ones when working through the tutorial):

rails generate devise:views Admin

This creates two directories under app/views, users and admins respectively. Under each directory will be sub directories for the different devise features that are supported.

Next I created sub directories in the controllers directory called users and admins and in those sub directories I created a controller for registration and session. This has to be done in order to customize registration and authentication for each of the models.

So for the User side I created a directory under controllers called ... wait for it ... users.

The registration controller (named registrations_controller.rb) looks like this:

class Users::RegistrationsController < Devise::RegistrationsController
  # disable default no_authentication action
  skip_before_action :require_no_authentication, only: [:new, :create, :cancel]
  
  protected

  def sign_up(resource_name, resource)
    # just overwrite the default one
    # to prevent auto sign in as the new sign up
  end
end

The session controller (named sessions_controller.rb) looks like this:

class Users::SessionsController < Devise::SessionsController
  # disable default no_authentication action
  skip_before_action :require_no_authentication, only: [:new, :create, :cancel]
end

Next is the registration controller for the Admin model (same name as above):

class Admins::RegistrationsController < Devise::RegistrationsController
  # disable default no_authentication action
  skip_before_action :require_no_authentication, only: [:new, :create, :cancel]
  
  protected

  def sign_up(resource_name, resource)
    # just overwrite the default one
    # to prevent auto sign in as the new sign up
  end
end

and the session controller for Admin (same name as above):

class Admins::SessionsController < Devise::SessionsController
  # disable default no_authentication action
  skip_before_action :require_no_authentication, only: [:new, :create, :cancel]
  # now we need admin to register new admin
  #prepend_before_action :authenticate_scope!, only: [:new, :create, :cancel]

  protected


  # def sign_up(resource_name, resoure)
  #   # just overwrite the default one
  #   # to prevent auto sign in as the new sign up
  # end
end

We are almost there I promise. Next are the changes to the routes.rb file:

Rails.application.routes.draw do
#  devise_for :admins
  devise_for :admins, module: 'admins', controllers: {sessions: 'admins/sessions', registrations:'admins/registrations'}
#  devise_for :users
  devise_for :users, module: 'users', controllers: {sessions: 'users/sessions', registrations:'users/registrations'}

  # These are the protected routes.
  # The pages controller is for the user model and the
  # admin_pages is for the admin model
  get '/secret', to: 'pages#secret', as: :secret
  get '/adminsecret', to: 'admin_pages#secret', as: :adminsecret
  get '/userhome', to: 'pages#index'
  get '/adminhome', to: 'admin_pages#index'

  # Define the root for when a user is authenticated
  authenticated :user do
    root 'pages#index', as: :authenticated_user_root
  end

  # define the root for when an admin is authenticated
  authenticated :admin do
    root 'admin_pages#index', as: :authenticated_admin_root
  end

  # default root (should never use this)
  root to: 'pages#index'
end

When you run the devise generator it will add a devise_for call in your routes. I have left those in (but commented out) to show what the default is so you can see how I adjusted the devise routes for the different models to point to their respective controllers.

Also you can see the "secret" pages and how they are defined as regular routes. I'll show them shortly. Finally I defined scoped routes for the "roots" for the different models.

Next is the application controller. This is where the "glue/magic" happens. I have included comments in the code so you can see what I was doing/thinking.

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :configure_permitted_parameters, if: :devise_controller?

  respond_to :html, :json

  # the layout should be specified by the resource (i.e. admin or user)
  layout :layout_by_resource

  protected

  # ensure name is allowed
  def configure_permitted_parameters
    devise_parameter_sanitizer.for(:sign_up) << :name  
    devise_parameter_sanitizer.for(:account_update) << :name
  end

  # if we are using a devise controller then we can check the
  # resource_name and return the appropriate layout, this can 
  # also be done at the controller level
  def layout_by_resource
    if devise_controller? && resource_name == :admin
      'adminlayout'
    elsif devise_controller? && resource_name == :user
      'userlayout'
    else
      'application'
    end
  end

  # Specify where to go after successful login again this is 
  # dictated by the resource that logged in
  def after_sign_in_path_for(resource)
    if devise_controller? && resource_name == :admin
      authenticated_admin_root_path
    elsif devise_controller? && resource_name == :user
      authenticated_user_root_path
    else
      root_path
    end
  end

  # Specify where to go after successful logout again this is 
  # dictated by the resource that logged in
  def after_sign_out_path_for(resource)
    if devise_controller? && resource_name == :admin
      new_admin_session_path
    elsif devise_controller? && resource_name == :user
      new_user_session_path
    else
      'application'
    end
  end

end

Next up is the protected content. For the user, he is directed to the pages index when properly authenticated. For the admin, she is directed to the admin_pages index when successfully authenticated. The controller for pages looks like this:

class PagesController < ApplicationController
  before_action :authenticate_user!

  layout 'userlayout'
end

By adding the before_action to the controller all routes will be forced to be authenticated.

The controller for the pages accessible to the admin look very similar:

class AdminPagesController < ApplicationController
  before_action :authenticate_admin!
  layout 'adminlayout'
end

Again all routes in this controller are protected by the before_action but this time the session must be a logged in admin.

With that said we are done. I modified the various views to indicate which one was being shown when you loaded it up. Check the completed code here here for what those views look like.

In summary, I guess I was just too dense to understand it but the key to all of this turned out to be how the application controller is configured.