Rails Authentication From Scratch. Going Beyond Railscasts
Tags: rails, ruby, security
Published: 2019-09-13 05:34:11

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](http://railscasts.com/episodes/250-authentication-from-scratch-revised). But the actual motivation came from Justin Searls, who in his recent talk ["Selfish Programmer"](http://blog.testdouble.com/posts/2019-05-08-the-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](https://github.com/plataformatec/devise) 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.

```rb
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:

```rb
  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.

```rb
    # 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.

```rb
  # 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:

```rb
  # 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:

```erb
<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.

```rb
# 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.

```rb
    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](https://softwareengineering.stackexchange.com/questions/99894/why-doesnt-http-have-post-redirect) 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.

```rb
# 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:

```rb
# 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:

```erb
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.