If you have to implement authentication in your rails app, Devise is your safest option. Rolling your own is shooting yourself in the foot.
But using Devise didn't feel like coding to me. It's like setting up your new mac by reading instructional blog posts around the net. Devise has great documentation and has all of your questions covered. You just follow them and you get industry level security.
But it would be good coding practice if we can understand how Devise, and authentication in general works.
So I implemented it from scratch following the famous Ryan Bates tutorial. But the actual motivation came from Justin Searls, who in his recent talk "Selfish Programmer" said he himself doesn't understand Devise and so implemented authentication from scratch for one of his side project. He implemented the usual workflows all by himself - the signup, sign in, forgot password, reset password etc - which helped him "keep all of his app's code within his head". (Which is a state you too should be in during the entire life of a project you are involved in.)
I did the same thing. But after the main workflows, I started implementing other features similar to the way Devise had done them. I just cloned their repo and searched it for how a feature was implemented. For each of the feature that Devise supports, they take care of all possible edgecases. I could care less. So I took only the core of the feature and coded it.
The features I implemented are:
- user registration
- authentication by email and password (signin and signout)
- remember and redirect to an auth-required page that the user tried to visit while logged-out, and then logged in
- user confirmation (only confirmed users can do certain/all actions)
- forgot password and password-reset
In the rest of the section, I'll explain how I implemented these. For missing pieces of the code here, you can find them in the actual repo: https://github.com/npras/meaningless.
The User database design
Here's the users migration file showing all the fields, indices and the datatypes.
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.string :email, null: false
t.string :password_digest
t.string :remember_token
t.string :password_reset_token
t.datetime :password_reset_sent_at
t.string :confirmation_token
t.string :unconfirmed_email
t.datetime :confirmation_sent_at
t.datetime :confirmed_at
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :remember_token, unique: true
# I like to use empty lines to group related code
add_index :users, :password_reset_token, unique: true
add_index :users, :confirmation_token, unique: true
add_index :users, :unconfirmed_email, unique: true
end
end
Code structure
All of devise's controllers inherit from DeviseController
. I'd like my authentication functionalities to inherit from a PrasDeviseController
.
(Not just for vanity reasons. Earlier I had user creation (signup) happen in UsersController, which made it hard to pull all common code in an ancenstral controller. That's when I realized Devise has a special thing called registrations which is where user creation happens. This allows us to make UsersController do non-authentication stuff while pulling out the user creation code to the authentication related code. Ok, the naming is still vanity.)
The PrasDevise
controller hosts all the common methods that's used by the other auth-related code. As an example, this is a great place to put your recaptcha code if you are ever going to use them in any of your auth forms. I put them everywhere - signup, login, password-reset etc just to annoy the user (who's just me). Here are the 2 methods used for recaptcha: https://github.com/npras/meaningless/blob/master/app/controllers/pras_devise/pras_devise_controller.rb#L70-L79.
Since the controllers are scoped, it'd be nice if the urls are scoped too. So here's how the routes.rb
file looks like:
scope module: :pras_devise do
resources :registrations, only: [:new, :create]
resources :confirmations, only: [:new, :show]
resources :sessions, only: [:new, :create, :destroy]
resources :password_resets, only: [:new, :edit, :create, :update]
end
These controller files are all present in app/controllers/pras_devise/
folder.
The User Registration workflow
I'd like to allow all authenticated operations to be performed by confirmed users only. A confirmed user is one who had clicked a special link sent to their email that they claimed is theirs by signing up.
In the create
action, we lookup/initialize the user only by the unconfirmed_email
field and not by the email
field. Once the user confirms, we'll remove the email from unconfirmed field.
# in pras_devise/registrations_controller.rb
def create
@user = User.find_or_initialize_by(unconfirmed_email: user_params[:email])
@user.attributes = user_params
if @user.save
@user.generate_token_and_send_instructions!(token_type: :confirmation)
redirect_to root_url, notice: "Check your email with subject 'Confirmation instructions'"
else
render :new
end
end
private def user_params
params
.require(:user)
.permit(:name, :email, :password, :password_confirmation)
end
(Notice the 2nd line in the create action. I found it a nice way to assign a hash-like datastructure to all attributes of an activerecord object.)
The User Confirmation workflow
The user.generate_token_and_send_instructions!
method just generates a unique confirmation_token and sends an email to that user with a link containing that token.
# in models/user.rb
# token_type is:
# confirmation for confirmation_token,
# password_reset for password_reset_token
# etc.
def generate_token_and_send_instructions!(token_type:)
generate_token(:"#{token_type}_token")
self[:"#{token_type}_sent_at"] = Time.now.utc
save!
UserMailer.with(user: self).send(:"email_#{token_type}").deliver_later
end
The mailer method looks like so:
# in mailers/user_mailer.rb
def email_confirmation
@user = params[:user]
@email = @user.unconfirmed_email
@token = @user.confirmation_token
mail to: @email, subject: "Confirmation instructions"
end
And the email itself looks like this:
<p>Welcome <%= @email %>!</p>
<p>You can confirm your account email through the link below:</p>
<p><%= link_to 'Confirm my account', confirmation_url(@user, confirmation_token: @token) %></p>
The link looks something like this: http://example.com/confirmations/111?confirmation_token=sOmErandomToken123
where 111
is the user's id which is irrelevant here. We only need it because we are defining the related controller action in a restful manner.
In confirmations_ocntroller#show
action we lookup the user by the confirmation_token
param. If the token isn't expired yet, then we confirm them by marking the unconfirmed_email
as nil and saving the record.
# confirmation_controller.rb
def show
user = User.find_by(confirmation_token: params[:confirmation_token])
if user.confirmation_token_expired?
redirect_to new_registration_path, alert: "Confirmation token has expired. Signup again." and return
end
if user
user.email, user.unconfirmed_email = user.unconfirmed_email, nil
user.confirmed_at = Time.now.utc
user.save
redirect_to root_url, notice: "You are confirmed! You can now login."
else
redirect_to root_url, alert: "No user found for this token"
end
end
Note that if the token expired, we are redirecting to the signup page. If the user now tries to signup with the same email, it will still work because there, in registrations_controller#create
we use User.find_or_initialize_by
rather than User.new
every time someone attempts to signup.
The Sign-In / Sign-Out workflow
This is very straightforward.
- find the user from db based on the email incoming from the sign-in form
- try to authenticate the user with the incoming password
- If the authentication succeeds, create a unique
remember_token
, encrypt and save it in a cookie.
def create
if @user&.authenticate(params[:password])
login!
redirect_to after_sign_in_path_for(:user), notice: "Logged in!"
else
flash.now.alert = "Email or password is invalid"
render :new
end
end
private def login!
unless @user.remember_token
@user.generate_token(:remember_token)
@user.save
end
if params[:remember_me]
cookies.encrypted.permanent[:remember_token] = @user.remember_token
else
cookies.encrypted[:remember_token] = @user.remember_token
end
end
If the user checked the Remember me
checkbox in the sign in form, then we create a permanent cookie, otherwise it's just a normal one.
If the authentication succeeds, we redirect the user to the page they previously attempted to visit unsuccessfully because they were un-authenticated at the time. This part of the code is taken straight from Devise. It's all in the parent controller PrasDeviseController
.
Let's see how it's implemented in detail...
Redirect to specific page after sign in
To do this, first you need to save each page the user visits in a session (a fancy cookie). But not all page should be saved. Devise saves only requests that satisfy all of these conditions:
- the request should be a GET request. Anything else sounds dangerous and is not simple to implement either. (Here's an interesting stack overflow discussion about this.)
- the request should not be an ajax request
- the request should be for an action that doesn't come from any PrasDevise controller. ie, it shouldn't be for pages like sign-in, sign-up, forgot-password forms etc. Doesn't make sense
- the request format should be of html only
These kind of requests are then stored in a session cookie with the key :user_return_to
. Once the user successfull logs in, then the sessions_controller#create
action redirects them to the correct
So, here's a before_action callback that's called on every request to the app.
# in pras_devise_controller.rb
before_action :store_user_location!, if: :storable_location?
private def storable_location?
request.get? &&
is_navigational_format? &&
!is_a?(PrasDevise::PrasDeviseController) &&
!request.xhr?
end
private def is_navigational_format?
["*/*", :html].include?(request_format)
end
private def request_format
@request_format ||= request.format.try(:ref)
end
private def store_user_location!
# :user is the scope we are authenticating
#store_location_for(:user, request.fullpath)
path = extract_path_from_location(request.fullpath)
session[:user_return_to] = path if path
end
private def parse_uri(location)
location && URI.parse(location)
rescue URI::InvalidURIError
nil
end
private def extract_path_from_location(location)
uri = parse_uri(location)
if uri
path = remove_domain_from_uri(uri)
path = add_fragment_back_to_path(uri, path)
path
end
end
private def remove_domain_from_uri(uri)
[uri.path.sub(/\A\/+/, '/'), uri.query].compact.join('?')
end
private def add_fragment_back_to_path(uri, path)
[path, uri.fragment].compact.join('#')
end
private def after_sign_in_path_for(resource_or_scope)
if is_navigational_format?
session.delete(:user_return_to) || root_url
else
session[:user_return_to] || root_url
end
end
(It all came from Devise.)
The Password Reset workflow
For password reset, you need 4 actions.
-
new
shows the form where the user would input their email. - It would then be submitted to
create
where the app would generate apassword_reset_token
and email it to the incoming email. - The user would then click the link in the email which would take him to a
password edit
page where the user is found by thepassword_reset_token
from the link. - Once the user fills the form with the new password and submits, it will go to the
update
action which saves the new password in the database.
Here's the controller code:
# password_resets_controller.rb
def new
end
def create
user = User.find_by(email: params[:email])
user&.generate_token_and_send_instructions!(token_type: :password_reset)
redirect_to root_url, notice: "If you had registered, you'd receive password reset email shortly"
end
def edit
set_user
redirect_to root_url, alert: "Cannot find user!" unless @user
end
def update
set_user
if (Time.now.utc - @user.password_reset_sent_at) > 2.hours
redirect_to new_password_reset_path, alert: "Password reset has expired!"
elsif @user.update(password_update_params)
redirect_to root_url, notice: "Password has been reset!"
else
render :edit
end
end
private def set_user
@user = User.find_by(password_reset_token: params[:id])
end
private def password_update_params
params
.require(:user)
.permit(:password, :password_confirmation)
end
Note that when the update form is submitted, we make sure the password_reset email was sent very recently. We don't want users to abuse this functionality.
The email_password_reset.html.erb
template looks like this:
To reset your password, click the URL below.
<%= link_to edit_password_reset_url(@user.password_reset_token), edit_password_reset_url(@user.password_reset_token) %>
If you did not request your password to be reset, just ignore this email and your password will continue to stay the same.
Conclusion
It's great to spend time looking under the hood of any library. With the help of example codes and test cases in the Devise repo, I was able to put pieces together and find out how some of the main functionalities work. I also came across many samples of succint and beautiful code, especially their test cases. Just by using minitest and mocha they've written short but easily readable test cases.
Nothing is mysterious if you take the time to explore it with curiosity.
Top comments (0)