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:
- Manual approval from an admin before new registrants could log in to do bulk uploads
- 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.
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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
echo "gem 'devise'" >> Gemfile # Or edit Gemfile and add line: gem 'devise' | |
bundle install # Fetch and install the gems | |
rails generate devise:install # Creates config file, etc. | |
rails generate devise user # Create model class, routes, etc. | |
rake db:migrate # Create user table | |
rails generate devise:views users # Creates (minimal) views | |
# Code sample courtesy of launch school: https://launchschool.com/blog/how-to-use-devise-in-rails-for-authentication |
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class User < ApplicationRecord | |
# Include default devise modules. Others available are: | |
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable | |
… | |
def active_for_authentication? | |
super && approved? | |
end | |
def inactive_message | |
approved? ? super : :not_approved | |
end | |
end |
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 atconfig/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:
- When a registration happens, we want to send an e-mail to an admin notifying them to check the new user for potential approval.
- 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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class Users::RegistrationsController < Devise::RegistrationsController | |
# before_action :configure_sign_up_params, only: [:create] | |
# before_action :configure_account_update_params, only: [:update] | |
# GET /resource/sign_up | |
# def new | |
# super | |
# end | |
# POST /resource | |
# def create | |
# super | |
# end | |
# GET /resource/edit | |
# def edit | |
# super | |
# end | |
# PUT /resource | |
# def update | |
# super | |
# end | |
# DELETE /resource | |
# def destroy | |
# super | |
# end | |
# GET /resource/cancel | |
# Forces the session data which is usually expired after sign | |
# in to be expired now. This is useful if the user wants to | |
# cancel oauth signing in/up in the middle of the process, | |
# removing all OAuth session data. | |
# def cancel | |
# super | |
# end | |
# protected | |
# If you have extra params to permit, append them to the sanitizer. | |
# def configure_sign_up_params | |
# devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute]) | |
# end | |
# If you have extra params to permit, append them to the sanitizer. | |
# def configure_account_update_params | |
# devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) | |
# end | |
# The path used after sign up. | |
# def after_sign_up_path_for(resource) | |
# super(resource) | |
# end | |
# The path used after sign up for inactive accounts. | |
# def after_inactive_sign_up_path_for(resource) | |
# super(resource) | |
# end | |
end |
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class RegistrationsController < Devise::RegistrationsController | |
# before_action :configure_sign_up_params, only: [:create] | |
# before_action :configure_account_update_params, only: [:update] | |
# GET /resource/sign_up | |
# def new | |
# super | |
# end | |
def create | |
super | |
UserMailer.admin_approval_email(@user).deliver_now #This is the line we are adding | |
end | |
… |
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class RegistrationsController < Devise::RegistrationsController | |
# before_action :configure_sign_up_params, only: [:create] | |
# before_action :configure_account_update_params, only: [:update] | |
# GET /resource/sign_up | |
# def new | |
# super | |
# end | |
def create | |
super | |
UserMailer.admin_approval_email(@user).deliver_now #This is the line we are adding | |
end | |
… | |
private | |
def sign_up_params | |
params.require(:user).permit(:email, :password, :password_confirmation, :notes) | |
end | |
def account_update_params | |
params.require(:user).permit(:email, :password, :password_confirmation, :current_password, :notes) | |
end | |
end |
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<h2>Sign up</h2> | |
<%= simple_form_for(resource, as: resource_name, url: user_registration_path) do |f| %> | |
<%= f.error_notification %> | |
<div class="form-inputs"> | |
<%= f.input :email, | |
required: true, | |
autofocus: true, | |
input_html: { autocomplete: "email" }%> | |
<%= f.input :password, | |
required: true, | |
hint: ("#{@minimum_password_length} characters minimum" if @minimum_password_length), | |
input_html: { autocomplete: "new-password" } %> | |
<%= f.input :password_confirmation, | |
required: true, | |
input_html: { autocomplete: "new-password" } %> | |
</div> | |
<div class="form-actions"> | |
<%= f.button :submit, "Sign up" %> | |
</div> | |
<% end %> | |
<%= render "users/shared/links" %> |
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<h2>Sign up</h2> | |
<%= simple_form_for(resource, as: resource_name, url: user_registration_path) do |f| %> | |
<%= f.error_notification %> | |
<div class="form-inputs"> | |
<%= f.input :email, | |
required: true, | |
autofocus: true, | |
input_html: { autocomplete: "email" }%> | |
<%= f.input :password, | |
required: true, | |
hint: ("#{@minimum_password_length} characters minimum" if @minimum_password_length), | |
input_html: { autocomplete: "new-password" } %> | |
<%= f.input :password_confirmation, | |
required: true, | |
input_html: { autocomplete: "new-password" } %> | |
<%= f.input :notes, #here's the piece we're adding | |
required: true, | |
input_html: { autocomplete: "notes" } %> | |
</div> | |
<div class="form-actions"> | |
<%= f.button :submit, "Sign up" %> | |
</div> | |
<% end %> | |
<%= render "users/shared/links" %> |
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
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:
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!