Friday, March 7, 2014

Authentication with Devise

Part 1: Devise Users

Devise is a gem that makes adding authentication to any web application very simple. While it does take a few steps to set up, they're quick and since you'll be doing this for virtually every web application (should you choose to continue using Devise), you'll remember them. First, add the Devise gem to your Gemfile and run bundle install.

# Bundle edge Rails instead:
# gem 'rails', :git => 'git://github.com/rails/rails.git'

gem 'sqlite3'
gem 'devise'

$ bundle install

To start using bundle, you have to run a generator called bundle:install. This will install the library into your Rails application and get ready to actually generate a devise user model and migration. The generator will tell you of a few more things to clean up after it's finished running, you should read through these. Never mind the first one, we won't be using mail and the last one, we won't be using the Devise views yet. But you do need to set up the root route, display alerts and tell it not to load the DB when pre-compiling assets. The directions are rather clear, but I show the relevant portions of the files in this project and if you've been following along, some of these are already done in the project.

$ rails generate devise:install

From config/routes.rb.

Quick_Blog::Application.routes.draw do
  resources :posts
  
  root :to => 'posts#index'

From app/views/layouts/application.html.erb.

<body>
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>

<%= yield %>

From config/application.rb.

# Enable the asset pipeline
config.assets.enabled = true
config.assets.initialize_on_precompile = false

And finally, delete public/index.html, our root route won't work with it there.

$ rm public/index.html

And now, if your Rails server is already running, restart your Rails server. This is something I forget to do when installing Devise, and then wonder why none of the Devise methods work. This is necessary when you add any gem to your Gemfile, as these are only loaded when the server starts.

Part 2: User Model

Good, we're ready to go. Let's generate our User model and migration. This is handled by Devise for the most part, there's very little we want to add on our own. The only field we'll need to add is an is_blogger field to differentiate between registered comment posters (comments are implemented in a later article) and bloggers able to post blog posts on the site. These are simply fields added to the end of the devise generator. Running this generator looks a lot like running the scaffold generator. The first parameter after the generator name is the name of the model we want to either generate, or modify. In our case, we want to generate a User model. Devise will automatically generate all the fields it needs (email, password, etc), and any fields you list will be generated in addition to the Devise fields.

$ rails generate devise user is_blogger:boolean

      invoke  active_record
      create    db/migrate/20130211163618_add_devise_to_users.rb
      insert    app/models/user.rb
       route  devise_for :users

Devise's functionality lives in modules. These modules can be turned off for features you don't need or want and are selected (in our project) in the newly generated app/models/user.rb and the data they need are laid out in the newly generated migration in db/migrate, with comments telling which fields are needed for which features. There is one feature we don't want for our users, recoverable (if only so I can show you how to remove one of the modules). So let's remove it from app/models/user.rb, it will now look like this.

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

And its associated fields from the migration. Also remember to remove the index from the bottom of the file, since you're telling it to add an index for a field that no longer exists.

class DeviseCreateUsers < ActiveRecord::Migration
  def change
    create_table(:users) do |t|
      ## 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

      # Some fields not listed here for brevity

      t.boolean :is_blogger

      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
    # add_index :users, :authentication_token, :unique => true
  end
end

Everything else looks fine, so let's run the migration and start the server if it's not running.

$ rake db:migrate
...
$ rails server
=> Booting WEBrick
=> Rails 3.2.11 application starting in development on http://0.0.0.0:3000
=> Call with -d to detach
=> Ctrl-C to shutdown server

Also, it's a good time to see what kind of routes were generated by Devise. You're going to need to know the names of the routes in order to generate the links to these features, so run rake routes and peruse its output. If you're curious as to where these routes come from, take a look at config/routes.rb, the Devise generator added a line that reads devise_for :users.

Note that there can be more than one Devise model in a web application, and the route names will differ. We called our Devise model "User," but we could have a second one called "Admin." This would generate two sets of named routes with names like new_user_session, but also new_admin_session. This is not an issue for our web application though, as we differentiate between our users with a column in the database, not a separate table. Of particular interest are new_user_registration, which displays the form to register, new_user_session which lets you log in and destroy_user_session which lets you log out.

Part 3: Sign In, Sign Out and Register Links

Now it's time to do a bit of work on the views a little bit. We're going to add either "Sign In" and "Register" links to the top of the page or, if you're logged in, it'll tell you who you're logged in as and give you a "Sign Out" link. Later on we'll put these in a more intelligent location, normal users don't want to be pestered to log in unless they want to post a comment.

So open up a new file at app/views/common/_session.html.erb, you'll have to create that directory first. Any view that begins with an underscore (in this case, _session) is known as a partial, or a small piece of a view that you're going to be rendering from other views. This is going to be the partial that displays the sign in, sign out and register links. The contents are below, and they're pretty straightforward. The user_signed_in? helper is from Devise (and again, if we had a second Devise class called Admin, there would also be a admin_signed_in?), it'll return true if the user is signed in (what a surprise) and current_user is another helper that returns the current user.

<%- if user_signed_in? %>
  <p>Hello <%= current_user.email %><br />
  <%= link_to 'Sign out', destroy_user_session_path, :method => :delete %></p>
<%- else %>
  <p>
    <%= link_to 'Register', new_user_registration_path %> |
    <%= link_to 'Sign in', new_user_session_path %>
  </p>
<%- end %>

And since we want to render this on every page, put it in the application template. We'll put it in a temporary location of the secondaryContent div in app/views/layouts/application.html.erb.

  <div id="secondaryContent">
    <%= render 'common/session' %>
  </div>

Users can now register, sign in and sign out. Great, this is what we wanted, but what about the is_blogger field? It wasn't in the registration form, there's no way to access it. It's also protected from mass assignment by the attr_accessible line in the User model, so even if a nefarious user were to add it to the client-side form, it still wouldn't be accessible. Whether you want to create an interface for this is up to you, but I prefer not to. Since this is intended to be a personal blog, there's no real reason to. You can set that field manually in the Rails console once and be done with it, it's not likely you'll need more than that. And yes, even when the application is deployed this is not a hard thing to do. So go ahead and create yourself a user and fire up the Rails console, find the user you created (preferably by email, don't accidentally make the wrong user a blogger, not an issue here but probably a good practice), set the is_blogger field and save the user.

>> u = User.find_by_email('uzimonkey@gmail.com')
 User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."email" = 'uzimonkey@gmail.com' LIMIT 1
...
>> u.is_blogger = true
true
>> u.save
  (0.0ms) begin transaction
  (0.4ms) UPDATE "users" SET "is_blogger" = 't', "updated_at" = '2013-02-11 17:26:03.807505' WHERE "users"."id" = 1
  (7.3ms) commit transaction
true

Part 4: Authorization

Authentication only goes so far. The site now knows who you are, but it still needs to restrict what you can do. We only want bloggers to be able to create or modify posts in any way, users can only list and show posts. While Devise does give us a helper that restricts methods to those who are logged in, this is not exactly what we want. We want a before filter to check if we're logged in and the current user has the is_blogger field set. This is easy though, we'll add a method in app/controllers/application_controller.rb to do this.

class ApplicationController < ActionController::Base
  protect_from_forgery

  protected
  def authorize_blogger!
    if user_signed_in? && current_user.is_blogger
      return
    elsif user_signed_in?
      flash[:notice] = 'You must be an authorized blogger to do that'
      redirect_to :root
    else
      flash[:notice] = 'You need to sign in first'
      redirect_to new_user_session_path
    end
  end
end

And then add this as a before_filter in app/controllers/posts_controller.rb.

class PostsController < ApplicationController
  before_filter :authorize_blogger!, :except => [:index, :show]

This is pretty straightforward, if the user is logged in and is a blogger, keep on going with the request. However, if either of the other conditions are met (user is logged in but is not a blogger, user is not logged in) the user gets redirected. If a before_filter either renders a page or redirects, the action the before filter protects will not run. Also, make sure you use :except with the before filter. It's tempting to go and list all the actions you want protected, but it's easy to miss one (or forget to add one) and leave it unprotected.

Test this by creating a second user without the is_blogger field set. You should see error message if you try to create or edit blog posts.

plataformatec/devise · GitHub
Return to Internship Note (LoanStreet)
Previous Episode: Update Blog with Tags
Next Episode: CarrierWave File Uploads

0 comments:

Post a Comment