Learn Ruby on Rails Book

Adding login feature

Implementing Token Based Authentication

Let's make use of a simple token based authentication mechanism to identify the subject and authenticate the user. The idea is that, whenever we create a new user, we will auto-generate a unique authentication token for that user. This token will be stored in the local storage of the browser, and it will be used in the headers of all subsequent api requests using Axios and in server the header details will be verified. This is an alternative approach to default session management provided by rails and in most scenarios this approach is spiced up by using a JWT token in order to have a much more improved scalability, support for multiple device logins etc. For simplicity, we won't be using a JWT token, but rather a simple generated token and we will persist it in the database.

Let's create a migration to add column authentication_token to users table.

1bundle exec rails generate migration add_authentication_token_to_users

The migration file will look like this

1class AddAuthenticationTokenToUsers < ActiveRecord::Migration[6.0]
2  def change
3    add_column :users, :authentication_token, :string
4  end
5end

We are going to use has_secure_token method to generate a random alphanumeric token for the users.

Let's update app/models/user.rb with the following content:

1class User < ApplicationRecord
2  VALID_EMAIL_REGEX = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i.freeze
3
4  has_many :tasks, dependent: :destroy, foreign_key: :user_id
5  has_secure_password
6  has_secure_token :authentication_token
7
8  validates :email, presence: true,
9                    uniqueness: true,
10                    length: { maximum: 50 },
11                    format: { with: VALID_EMAIL_REGEX }
12  validates :password, presence: true, confirmation: true, length: { minimum: 6 }
13  validates :password_confirmation, presence: true, on: :create
14
15  before_save :to_lowercase
16
17  private
18
19    def to_lowercase
20      email.downcase!
21    end
22end

Open Rails console and check the following.

1bundle exec rails console
2Running via Spring preloader in process 18583
3Loading development environment (Rails 6.0.3.4)
4irb(main):001:0> user = User.new(name:"raj",
5                        password:"abcd1234",
6                        password_confirmation:"abcd1234",
7                        email:"raj@r.com")

Executing the above command should give an output similar to the one given below

1   (0.3ms)  SELECT sqlite_version(*)
2=> #<User id: nil, name: "raj", created_at: nil, updated_at: nil,
3   email: "raj@r.com", password_digest: [FILTERED],
4   authentication_token: nil>

Now let's save the user to the database

1irb(main):002:0> user.save

As you can see, user.save returns true or false signifying whether the record was saved successfully or not

1   (0.1ms)  begin transaction
2  User Exists? (0.2ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ?
3                        [["email", "raj@r.com"], ["LIMIT", 1]]
4  User Create (0.4ms)  INSERT INTO "users"
5  ("name", "created_at", "updated_at", "email", "password_digest", "authentication_token")
6  VALUES (?, ?, ?, ?, ?, ?)  [["name", "raj"], ["created_at", "2020-11-08 19:26:25.334092"],
7  ["updated_at", "2020-11-08 19:26:25.334092"], ["email", "raj@r.com"],
8  ["password_digest", "$2a$12$sAe4fph9au782/S39hrfFuVE9..C2Ls56TaAupU27xdIIKAMo36cO"],
9  ["authentication_token", "CicoGPFTJCc4M8wcP2dwE876"]]
10   (5.0ms)  commit transaction
11=> true

When we create a new User, the authentication_token associated with it will be a new one since that field is unique

1irb(main):003:0> user.authentication_token
2=> "CicoGPFTJCc4M8wcP2dwE876"
3irb(main):004:0>

We can see that the authentication_code has been automatically generated when the user was created. You can read this blog to learn more about has_secure_token

Session controller

1bundle exec rails g controller sessions

We will be using sessions_controller to authenticate users and send the authentication_token as response if the credentials provided by the user is correct.

Add the following code to sessions_controller.rb.

1class SessionsController < ApplicationController
2   def create
3    user = User.find_by(email: login_params[:email].downcase)
4    if user.present? && user.authenticate(login_params[:password])
5      render status: :ok, json: { auth_token: user.authentication_token, userId: user.id }
6    else
7      render status: :unauthorized, json: {
8        notice: 'Incorrect credentials, try again.'
9      }
10    end
11   end
12
13  private
14
15    def login_params
16      params.require(:login).permit(:email, :password)
17    end
18end

Session routes

Let's add session routes by modifying config/routes.rb.

1Rails.application.routes.draw do
2  resources :tasks, except: %i[new edit]
3  resources :users, only: %i[create index]
4  resource :sessions, only: :create
5  root "home#index"
6  get '*path', to: 'home#index', via: :all
7end

Storing data in local storage

Let's create a helper to store required data in local storage and another helper to fetch the stored data.

First we can create a directory for helper files and a file called storage.js inside it:

1mkdir -p app/javascript/src/helpers/
2touch app/javascript/src/helpers/storage.js

Add the following to app/javascript/src/helpers/storage.js:

1const setToLocalStorage = ({ authToken, email, userId }) => {
2  localStorage.setItem("authToken", authToken);
3  localStorage.setItem("authEmail", email);
4  localStorage.setItem("authUserId", userId);
5};
6
7const getFromLocalStorage = key => {
8  return localStorage.getItem(key);
9};
10
11export { setToLocalStorage, getFromLocalStorage };

Now let's add a webpack alias for these helpers. Append the following into the alias key in config/webpack/custom.js:

1module.exports = {
2  resolve: {
3    alias: {
4      apis: "src/apis",
5      helpers: "src/helpers",
6      common: "src/common",
7      components: "src/components",
8    },
9  },
10};
11

Session views

Since routing is handled using react-router-dom, we will create a route to render the Login component. In addition, we will also handle the case to prevent non-logged in users from accessing the dashboard or any other private routes. Another important change is that, we will be rendering our DashBoard component in / rather than /dashboard. This redirection happens in PrivateRoute component.

Open app/javascript/src/App.jsx file and add the following lines.

1import React, { useEffect, useState } from "react";
2import { Route, Switch, BrowserRouter as Router } from "react-router-dom";
3import { either, isEmpty, isNil } from "ramda";
4import { ToastContainer } from "react-toastify";
5import { registerIntercepts, setAuthHeaders } from "apis/axios";
6import { initializeLogger } from "common/logger";
7import Dashboard from "components/Dashboard";
8import CreateTask from "components/Tasks/CreateTask";
9import ShowTask from "components/Tasks/ShowTask";
10import EditTask from "components/Tasks/EditTask";
11import Login from "components/Authentication/Login";
12import Signup from "components/Authentication/Signup";
13import PrivateRoute from "components/Common/PrivateRoute";
14import { getFromLocalStorage } from "helpers/storage";
15import PageLoader from "components/PageLoader";
16
17const App = () => {
18  const [loading, setLoading] = useState(true);
19  const authToken = getFromLocalStorage("authToken");
20  const isLoggedIn = !either(isNil, isEmpty)(authToken) && authToken !== "null";
21
22  useEffect(() => {
23    registerIntercepts();
24    initializeLogger();
25    setAuthHeaders(setLoading);
26  }, []);
27
28  if (loading) {
29    return (
30      <div className="h-screen">
31        <PageLoader />
32      </div>
33    );
34  }
35
36  return (
37    <Router>
38      <ToastContainer />
39      <Switch>
40        <Route exact path="/tasks/:slug/show" component={ShowTask} />
41        <Route exact path="/tasks/:slug/edit" component={EditTask} />
42        <Route exact path="/tasks/create" component={CreateTask} />
43        <Route exact path="/signup" component={Signup} />
44        <Route exact path="/login" component={Login} />
45        <PrivateRoute
46          path="/"
47          redirectRoute="/login"
48          condition={isLoggedIn}
49          component={Dashboard}
50        />
51      </Switch>
52    </Router>
53  );
54};
55
56export default App;

Now that our dashboard component is rendered at / path instead of /dashboard, replace history.push("/dashboard") with history.push("/") in EditTask.jsx component, so as to redirect users to the correct url from the handleSubmit function, like so:

1/* previous code */
2const EditTask = ({ history }) => {
3  /* previous code */
4  const handleSubmit = async event => {
5    event.preventDefault();
6    try {
7      await tasksApi.update({
8        slug,
9        payload: { task: { title, user_id: userId } },
10      });
11      Toastr.success("Successfully updated task.");
12      history.push("/");
13    } catch (error) {
14      logger.error(error);
15    } finally {
16      setLoading(false);
17    }
18  };
19/* previous code */
20}

Let's add a new component app/javascript/src/components/Common/PrivateRoute.jsx which will redirect non-authenticated users to the login screen, if they try to access any private route and add the following lines of code to it:

1import React from "react";
2import { Redirect, Route } from "react-router-dom";
3import PropTypes from "prop-types";
4
5const PrivateRoute = ({
6  component: Component,
7  condition,
8  path,
9  redirectRoute,
10  ...props
11}) => {
12  if (!condition) {
13    return (
14      <Redirect
15        to={{
16          pathname: redirectRoute,
17          from: props.location,
18        }}
19      />
20    );
21  }
22  return <Route path={path} component={Component} {...props} />;
23};
24
25PrivateRoute.propTypes = {
26  component: PropTypes.func,
27  condition: PropTypes.bool,
28  path: PropTypes.string,
29  redirectRoute: PropTypes.string,
30  location: PropTypes.object,
31};
32
33export default PrivateRoute;

Open app/javascript/src/apis/auth.js and paste the following to create an API route to create a user session.

1import axios from "axios";
2
3const login = payload => axios.post("/sessions", payload);
4
5const authApi = {
6   login,
7};
8
9export default authApi;

We will abstract the form logic from login to a different component. For that, create a new file, LoginForm.jsx by running the following command:

1mkdir -p app/javascript/src/components/Authentication/Form/
2touch app/javascript/src/components/Authentication/Form/LoginForm.jsx

Add the following content into LoginForm.jsx:

1import React from "react";
2import { Link } from "react-router-dom";
3
4import Input from "components/Input";
5import Button from "components/Button";
6
7const LoginForm = ({ handleSubmit, setEmail, setPassword, loading }) => {
8  return (
9    <div className="flex items-center justify-center min-h-screen
10    px-4 py-12 lg:px-8 bg-gray-50 sm:px-6">
11      <div className="w-full max-w-md">
12         <h2 className="mt-6 text-3xl font-extrabold leading-9
13         text-center text-bb-gray-700">
14          Sign In
15        </h2>
16        <div className="text-center">
17          <Link
18            to="/signup"
19            className="mt-2 text-sm font-medium text-bb-purple
20            transition duration-150 ease-in-out focus:outline-none
21            focus:underline"
22          >
23            Or Register Now
24          </Link>
25        </div>
26        <form className="mt-8" onSubmit={handleSubmit}>
27          <Input
28            label="Email"
29            type="email"
30            placeholder="oliver@example.com"
31            onChange={e => setEmail(e.target.value)}
32          />
33          <Input
34            label="Password"
35            type="password"
36            placeholder="********"
37            onChange={e => setPassword(e.target.value)}
38          />
39          <Button type="submit" buttonText="Sign In" loading={loading} />
40        </form>
41      </div>
42    </div>
43  );
44};
45
46export default LoginForm;

Here, we are storing the X-Auth-Token and X-Auth-Email in localstorage of the browser. These tokens will then be sent with every request using request headers to verify the authenticity of the request. Make sure that axios headers are set as mentioned in section 13.

On a successful login, the user will be redirected to dashboard and authToken and authEmail will be stored in the local storage.

Login component will be responsible for making the API call to create a user session. For that, create a new file, Login.jsx by running the command and let's make use of our reusable component LoginForm:

1touch ./app/javascript/src/components/Authentication/Login.jsx

Add the following content to Login.jsx:

1import React, { useState } from "react";
2
3import LoginForm from "components/Authentication/Form/LoginForm";
4import authApi from "apis/auth";
5import { setAuthHeaders } from "apis/axios";
6import { setToLocalStorage } from "helpers/storage";
7
8const Login = () => {
9  const [email, setEmail] = useState("");
10  const [password, setPassword] = useState("");
11  const [loading, setLoading] = useState(false);
12
13  const handleSubmit = async event => {
14    event.preventDefault();
15    try {
16      const response = await authApi.login({ login: { email, password } });
17      setToLocalStorage({
18        authToken: response.data.auth_token,
19        email,
20        userId: response.data.userId,
21        userName: response.data.user_name,
22      });
23      setAuthHeaders();
24      setLoading(false);
25      window.location.href = "/";
26    } catch (error) {
27      logger.error(error);
28      setLoading(false);
29    }
30  };
31
32  return (
33    <LoginForm
34      setEmail={setEmail}
35      setPassword={setPassword}
36      loading={loading}
37      handleSubmit={handleSubmit}
38    />
39  );
40};
41
42export default Login;

Allowing user to view Tasks only if Logged In

Until now, the users were able to see tasks even without logging in. Now let's restrict this behavior and enable viewing tasks only if user is logged in. We have two simple business requirements.

  1. If user is logged in, we do nothing and allow the controller to carry out its job.
  2. If user is not logged in, we stop the application flow and redirect the user to the Log In page.

We can use Filters provided by Rails. Filters are methods that are run "before", "after" or "around" a controller action. We would be using a before filter here as we want to check if the user is logged in or not before letting the user view the tasks list or access any data within the database.

Let's go to our Tasks Controller and add the following code:

1class TasksController < ApplicationController
2  before_action :authenticate_user_using_x_auth_token, except: [:new, :edit]
3  before_action :load_task, only: [:show, :update, :destroy]
4  # previous code...
5end

Above we have declared two filters for the controller. Controller executes those filters in the order in which they are defined. That's why it's important that authenticate_user_using_x_auth_token is the first filter.

In future other controllers also need this feature of allowing only logged in user to see the content. Since all controllers inherit from ApplicationController let's add our authenticate_user_using_x_auth_token filter in ApplicationController.

Let's open application_controller.rb and replace the content with the following:

1class ApplicationController < ActionController::Base
2
3  def authenticate_user_using_x_auth_token
4    user_email = request.headers["X-Auth-Email"]
5    auth_token = request.headers["X-Auth-Token"].presence
6    user = user_email && User.find_by_email(user_email)
7
8    if user && auth_token &&
9      ActiveSupport::SecurityUtils.secure_compare(
10        user.authentication_token, auth_token
11      )
12      @current_user = user
13    else
14      render status: :unauthorized, json: {
15        errors: ["Could not authenticate with the provided credentials"]
16      }
17    end
18  end
19
20  private
21    def current_user
22      @current_user
23    end
24end

Let's observe what's going on here.

We will be sending the authentication_token and email_id of the user in the request headers as X-Auth-Token and X-Auth-Email respectively, with all the API requests which needs to be authenticated.

Please note that we have also added a method called current_user to fetch current user details.

When the method authenticate_user_using_x_auth_token is invoked, at first the user is retrieved from database based on the email_id passed in the header. We then check if the auth_token passed in the request header matches with the authentication_token stored in database for that particular user. If the credentials are correct, we set @current_user as user. This is similar to how gems like Devise use sign_in method. Since @current_user is an instance variable, it will be available in all the classes inheriting from ApplicationController.

Moving response messages to i18n en.locales

Let's move the response messages to en.yml:

1en:
2  session:
3    could_not_auth: "Could not authenticate with the provided credentials."
4    incorrect_credentials: "Incorrect credentials, try again."
5  successfully_created: "%{entity} was successfully created!"

We can use this as session.incorrect_credentials as error response message in session_controller.rb:

1def create
2  user = User.find_by(email: login_params[:email].downcase)
3  if user.present? && user.authenticate(login_params[:password])
4    render status: :ok, json: { auth_token: user.authentication_token, userId: user.id }
5  else
6    render status: :unauthorized, json: { notice: t('session.incorrect_credentials') }
7  end
8end

And similarly, for the case where we can't authenticate user using auth token , in application_controller.rb we can send the following response:

1def authenticate_user_using_x_auth_token
2  user_email = request.headers["X-Auth-Email"]
3  auth_token = request.headers["X-Auth-Token"].presence
4  user = user_email && User.find_by_email(user_email)
5
6  if user && auth_token &&
7    ActiveSupport::SecurityUtils.secure_compare(
8      user.authentication_token, auth_token
9    )
10    @current_user = user
11  else
12    render status: :unauthorized, json: { errors: [t('session.could_not_auth')] }
13  end
14end

Now, let's commit the changes:

Setting up rake tasks

Rake stands for Ruby Make. It's a standalone Ruby utility that "replaces the Unix utility 'make', and uses a 'Rakefile' and .rake files to build up a list of tasks".

Basically, it is a task runner for Ruby. Rails uses Rake Proxy to delegate some of its tasks to Rake.

You might've used rails db:migrate in the previous chapters. When rails db:migrate is run, what happens internally is that Rails checks if db:migrate is supported natively. In this case db:migrate is not natively supported by Rails, so Rails delegates the execution to Rake via Rake Proxy.

We can also write our custom rake tasks in rails environment by creating files with .rake extension in ./lib/tasks. Often when creating a new project, we need to setup some defaults, like say populating the user database with default users etc. For such cases we can write those tasks in ./lib/tasks/setup.rake. Let's add the code below in our setup.rake:

1task :populate_with_sample_data do
2  puts 'Seeding with sample data...'
3  user_details = { name: 'Oliver',
4                   email: 'oliver@example.com',
5                   password: 'welcome',
6                   password_confirmation: 'welcome' }
7  User.create! user_details
8  puts 'Done! Now you can login with "oliver@example.com" using password "welcome"'
9end

It's a common practice after cloning a new repository to run ./bin/setup, in order to automatically fetch all the libraries, create db, seed data etc. Therefore it makes sense to invoke our setup.rake from ./bin/setup since it also plays a role in bootstrapping the project. Add the following lines to ./bin/setup under APP_ROOT block which already exists:

1puts "\n== Setting up the app =="
2system! 'bundle exec rake setup'

Let's modify our setup.rake and add a few more tasks:

1desc 'drops the db, creates db, migrates db and populates sample data'
2task setup: [:environment, 'db:drop', 'db:create', 'db:migrate'] do
3  Rake::Task['populate_with_sample_data'].invoke if Rails.env.development?
4end
5
6task :populate_with_sample_data do
7  create_sample_data!
8end
9
10def create_sample_data!
11  puts 'Seeding with sample data...'
12  create_user! email: 'oliver@example.com', name: 'Oliver'
13  create_user! email: 'sam@example.com', name: 'Sam'
14  puts 'Done! Now you can login with either "oliver@example.com" or "sam@example.com", using password "welcome"'
15end
16
17def create_user!(options = {})
18  user_attributes = { password: 'welcome', password_confirmation: 'welcome' }
19  attributes = user_attributes.merge options
20  User.create! attributes
21end

Let's restart our rails server:

1bundle exec rails server

Now let's run this rake setup task to have some default login credentials and task assignment users:

1bundle exec rake populate_with_sample_data

which outputs in the console:

1Seeding with sample data...
2Done! Now you can login with either "oliver@example.com" or "sam@example.com",
3using password "welcome"

Showing Logged in user

Right now, our Navbar has just one Logout button. We need to show the currently logged-user, also. Let's modify our SessionController to send the user in create action:

1def create
2  user = User.find_by(email: login_params[:email].downcase)
3  if user.present? && user.authenticate(login_params[:password])
4    render status: :ok, json: { auth_token: user.authentication_token,
5                                userId: user.id,
6                                user_name: user.name }
7  else
8    render status: :unauthorized, json: { notice: t('session.incorrect_credentials') }
9  end
10end

And also, lets set userName to the localStorage after getting response, in our Login.jsx:

1//------previous code -------
2const handleSubmit = async event => {
3    event.preventDefault();
4    try {
5      const response = await authApi.login({ login: { email, password } });
6      setToLocalStorage({
7        authToken: response.data.auth_token,
8        email,
9        userId: response.data.userId,
10        userName: response.data.user_name,
11      });
12    }
13}
14//------previous code -------

Now, in our app/javascript/src/components/NavBar/index.jsx file:

1import React from "react";
2import NavItem from "./NavItem";
3import authApi from "apis/auth";
4import Toastr from "components/Common/Toastr";
5import { resetAuthTokens } from "src/apis/axios.js";
6import { getFromLocalStorage } from "helpers/storage";
7
8const NavBar = () => {
9  const userName = getFromLocalStorage("authUserName");
10
11  return (
12    <nav className="bg-white shadow">
13      <div className="px-2 mx-auto max-w-7xl sm:px-4 lg:px-8">
14        <div className="flex justify-between h-16">
15          <div className="flex px-2 lg:px-0">
16            <div className="hidden lg:flex">
17              <NavItem name="Todos" path="/" />
18              <NavItem
19                name="Create"
20                iconClass="ri-add-fill"
21                path="/tasks/create"
22              />
23            </div>
24          </div>
25          <div className="flex items-center justify-end gap-x-4">
26            <span
27              className="inline-flex items-center px-2 pt-1 text-sm font-regular leading-5 text-bb-gray-600
28              text-opacity-50 transition duration-150 ease-in-out border-b-2 border-transparent focus:outline-none
29              focus:text-bb-gray-700"
30            >
31              {userName}
32            </span>
33          </div>
34        </div>
35      </div>
36    </nav>
37  );
38};
39
40export default NavBar;

Now, let's commit the changes:

1git add -A
2git commit -m "Added login feature"