Modifying Authentication Behavior in Devise

Reading Time: 9 minutes

Screen Shot 2019-04-01 at 5.34.11 PM

I just returned from the Ruby by the Bay civic hack in San Francisco. I wrote about that experience in this previous post. (I have very, very early breaking news. If you read that previous post and think you’d like to participate in an event like that this fall, email me. I have a lead.)

Part of the technical work at that conference included adding an authentication flow to a Rails application with some non-standard behaviors:

  1. Manual approval from an admin before new registrants could log in to do bulk uploads
  2. A “note” field where users could share why they wanted to do bulk uploads

The Rails community leans pretty hard on one particular authentication gem: devise. There are good reasons.

  • Devise has built-in features to protect user login information, and it’s actively maintained to keep up with modern security protocols. Developers have a duty of care to their users’ privacy and security, and rule #1 is, unless you are literally working for a company whose sole value proposition is security, do not roll your own auth.
  • Devise works pretty much out-of-the-box for your standard authentication flow, and it provides a number of modules for common authentication use cases like allowing users to recover their passwords, providing confirmation e-mails to registrants, and setting timeouts on individual login sessions.

devise logo

That said, the  scuttlebutt about devise is “stick with the standard behavior.” For example, devise can generate signup and login views for users, and developers find it much easier to customize the look of those views than to try to hook up authentication to their own views. Wanna make login happen via a modal? You might be in for some issues.

Even though devise is a bit tough to customize, if you need a customized authentication flow for a Rails app, devise is still the most viable option. So Ruby devs will brace themselves, dive in, and come out the other end 2 days and 8,000 cusswords later.

For this project, I needed to add some custom behavior to devise. We needed users to register via a self-serve UI, but they needed to be manually approved by an admin after a background check before they could log into the account.

To do this, I had to piece together information from about 4 different online resources, plus get some help from Betsy Haibel (who you’d be lucky to hire, if you were hiring). So I have compiled our full flow, starting with adding devise to the project. This piece will focus on changes to the controller to send an email to admins when someone registers. The next post will focus on changes to the model to add the note field.

Step 1: Add devise, with a user as the authenticable model

Inside the terminal, navigate to the directory of your existing rails app and use the following bash commands:

This creates a standard devise setup, with views that authenticate a User model. If we only needed standard authentication, we’d have that at this point.

However, we want a custom thing: we want users to create accounts that start out unapproved, so an admin can approve them. Once they are approved, they’ll be able to sign in on their own.

Step 2: Add Attribute to User Model (modifying a devise model)

We begin by adding the approved attribute to our User model.

rails g migration AddApprovedToUsers approved:boolean

The g migration command will create a migration file that looks like so:

class AddPartNumberToProducts < ActiveRecord::Migration
  def change
    add_column :users, :approved, :boolean
  end
end

We want to make sure that users start out unapproved. So we add a default of false to this migration:

class AddPartNumberToProducts < ActiveRecord::Migration
  def change
    add_column :users, :approved, :boolean, :default => false
  end
end

Now that our migration file is ready, we run the migration to make the change to the schema and to the database:

rails db:migrate

We also want to add a notes field to this model, so we’ll do another step very similar to the one we just did:

rails g migration AddApprovedToUsers notes:string

For this one, we don’t have a default value or any other reason to change the migration file, so we can run the generated migration without changing the migration file.

rails db:migrate

Now we need to make sure that users can only sign in if they are approved. For this, we’ll modify the User class itself. (I omitted this piece of the tutorial when I originally published it. A big thanks goes to Sean Hogge for noticing this and bringing it up!)

There are several ways to go about resolving this; you can check the comments below this post to see me talk about a few runners up. But the way I’m recommending in this post involves overriding methods in the User model. The method I’m advising comes from this tutorial, courtesy of zenati.

So, we update the User model like this:

zenati also suggests updating the strings for internationalization:

You will need to create an entry for :not_approved and :signed_up_but_not_approved in the [internationalization] file, located at config/locales/devise.##.yml:

  devise:
    registrations:
      user:
        signed_up_but_not_approved: 'You have signed up successfully but your account has not been approved by your administrator yet'
    failure:
      not_approved: 'Your account has not been approved by your administrator yet.'

Step 3: Expose a custom controller (modifying a devise controller)

For the next part, we need access to the devise controllers in order to modify their behavior. By default, devise does not expose those to us. We can run this command to expose the controllers:

rails generate devise:controllers users

From this command, devise generates for us a set of files containing controllers that inherit from the devise controllers. So we have a Users::RegistrationsController that inherits from Devise::RegistrationsController, et cetera.

We need our app to route to our version of the controller, rather than devise’s. For this, we go to the routes.rb file and change this line:

devise_for :users

to this:

devise_for :users, :controllers => { registrations: 'registrations' }

Step 4: Add New Controller Functionality (modifying a devise controller)

We have two pieces of behavior to add to the controller:

  1. When a registration happens, we want to send an e-mail to an admin notifying them to check the new user for potential approval.
  2. We want to allow users to add notes during registration. The admins can use the information in this field to determine whether or not this account should be approved to sign in and do bulk uploads.

The controllers that devise has generated for us look something like this:

To change this controller, we un-comment methods in here and add our custom behavior.

When a user registers (that is, when a registration is created), we want to send mail to an admin about it. From there, the admin can go into the database and check out the user. The backend for approving users doesn’t have to do with devise, so we won’t cover it here. We could build views specifically for admins to approve users, or we could install ActiveAdmin and let admins approve users through the admin console, or we could do zero additional work and let admins approve users by opening a console and directly changing values in the database.

We also won’t cover the details of creating a mailer here. This tutorial on action mailer is comprehensive and currently works, though, if that’s exactly what you’re trying to do here.

We’ll share just one line of mailer code: the line we need to add to our custom devise controller.

We uncomment the create method and add a line after the work of the super. Because we are in a method that saves off the created user to a variable @user, we pass that into the mailer to share information with the admins about which user just registered.

To allow a user to write notes, we also need to permit writing to the :notes parameter during registration. To make this possible, we override the permitted parameters methods in the RegistrationsController, adding the notes attribute in the same way that we often add an attribute to the permitted params for a model updated through a controller action in Rails.

With that addition, the uncommented code of our file looks something like this:

Step 5: Add the notes field to our form (updating a devise view)

When we ran rails generate devise views:users, devise created a series of registration, sign in, sign out, and other views for our app’s use. The registration form is located in /views/users/registrations/new.html.erb. It looks like this:

Let’s add a field for users to enter some notes when they register. This looks similar to adding a field to any model form in rails:

Note that we’re still using the devise-provided view, rather than making a new one, which avoids some additional routing headache for us.

Conclusion

We have covered all the steps of modifying the behavior of the devise gem in rails through a couple of examples: adding an admin approval flow and adding a notes section for new registrants.

Throughout this process we see the steps to set up devise, to modify a devise model, to customize a devise controller, and to change a devise view.

My hope is that this complete example can make the task of working with custom authentication flows in devise a bit less onerous for Rails developers who need to do it.

If you found this helpful, you might also like:

The other posts I’ve written about Ruby (or with examples in Ruby)

The refactoring posts (for when you get to write a lot more of your own code)

This piece about the hackathon at which the above solution was implemented

3 comments

  1. I’m currently working on a project with a similar situation, and I notice that your write-up doesn’t address how you prevent users from logging in until they’re approved. Are you just counting on the fact that they won’t try, or was that omitted for brevity? Or does Devise automatically honor an “approved” column?

    If Devise doesn’t understand the approval column, what strategy would you consider if you needed to prevent login until an admin approves?

    • Hi Sean!

      Great question. Omitted here, BUT I should probably add it in to make this example complete. I’ll do that this weekend, with credit to you for finding this!

      There are a couple of options here.

      Option 1 (Elegant but requiring more research): the customizable options for a devise `User` model:

      Devise User models allow you to enable a number of special options like `:recoverable` (to help recover a forgotten password), `:validatable` (to validate or invalidate a particular sign up), and `:timeoutable` (to set a session timeout). Some more examples are listed in the generated file (see example here), as well as in the documentation on modules. Depending on how you’d like approvals to work, one of these might be just what you need!

      Option 2 (Less elegant, but functional and fast): Add a check to devise ‘sessions_controller.rb`:

      When we obtained hooks to all the controllers, in addition to the `registrations_controller.rb` we modified, we also got a `sessions_controller.rb` We can un-comment and modify the `sign_in` method in that controller to check for our user approval. Something like:

      # POST /resource/sign_in
      def create
      super do |user|
      unless user.approved?
      flash[:error] = “Oops! You need to confirm your email address first.”
      redirect_to sign_in
      end
      end
      end

    • Hi Sean! Thank you again for pointing out this omission! I have modified the post with my recommended solution, which isn’t in my original comment.

      I did step 1 that I recommended to you: I did some research into the capabilities of the User model. I learned, from there, about the methods in that model that handle authentication. I made a hypothesis about how I should override those methods, and then I found a tutorial that confirmed that hypothesis. That’s where the solution came from that is now inscribed in the post!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.