DEV Community

Prasanna Natarajan
Prasanna Natarajan

Posted on • Originally published at npras.in

Rails Authentication From Scratch. Going Beyond Railscasts

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 a password_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 the password_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)