Adding login feature

Search icon
Search Book


In this chapter, we are going to implement a simple token-based authentication mechanism in our application. By the end of the chapter, our application will have the following changes:

  • We will have a login form requesting email and password in /login route. The page will look like this:

    User login

  • Users once logged in will remain logged in until they log out manually. i.e. the session will never expire.

  • Only the logged-in users will be able to see the list of tasks. Unauthenticated users will be redirected to the login page when trying to access the tasks list.

  • We will display the list of users in / route instead of at /dashboard for easiness of accessing.

  • We will display the logged-in user's name in our navigation bar as circled in this image:

    User name in navbar

Technical design

This is how the overall design looks like:

  • We will be generating a unique random token for users at the time of sign-up. We will use this token to verify the authenticity of requests.

  • This token will be stored in a column named authentication_token in the users table.

  • We will use the has_secure_token method provided by Rails for generating unique random alphanumeric tokens. This method is explained in detail in this blog.

  • We will create a new controller for managing sessions. Let's call it SessionsController. Its create action will be called by login API.

  • We will add a Jbuilder template for create action in the SessionsController. Using Jbuilder, we will create and send a JSON response comprising of user's name, email and authentication_token if the request is valid.

  • We will create a new Login page, which will be rendered at the route /login. As we did earlier with the sign-up page, we manage user inputs from a separate component named Login inside Form folder.

  • Upon form submission, the Login component will send the credentials to SessionsController through create API. The response data received from server will be saved to the browser's localStorage in case of successful authentication.

  • Since we have already configured our axios object to include authentication token, email, etc from localStorage to the headers of every request, we can now identify the users in the backend.

  • We will make use of the Redirect component from the react-router-dom package to redirect unauthenticated users to /login route when they try to access the tasks list through forced browsing.

  • Disabling this from the frontend solely isn't enough. We need to update our backend too to ensure that we don't retrieve the tasks list for unauthenticated requests.

  • We will add a method called authenticate_user_using_x_auth_token in the backend which will extract email and token from request headers, find a user possessing the given email from the database, and then check whether the token matches that of the request.

  • If the authentication token is verified then the request will be processed further otherwise the request will be terminated there and an :unauthorized response will be sent from the authenticate_user_using_x_auth_token.

  • If email or token is not present then also authenticate_user_using_x_auth_token method will terminate the request with an :unauthorized response.

  • Since we will need the same authenticity verification logic on several controllers, we will define the authenticate_user_using_x_auth_token method inside our ApplicationController and use a before_action callback to invoke it before the controller action.

    This way we can ensure that the requests are authenticated before they are processed irrespective of the controller.

  • To display logged in user's name on NavBar, we can use the name we stored in the localStorage during login.

This sums up what we are going to do in this chapter. Let us start coding.

Creating token for new users

As discussed earlier, whenever we create a new user, we will auto-generate a unique authentication token for that user and use it to identify the user later on.

This is an alternative approach to default session management provided by Rails.

In most scenarios this approach is spiced up by using a JWT token to improve scalability, support multiple device logins etc. But, for simplicity, we won't be using a JWT token, in this project.

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

1bundle exec rails generate migration add_authentication_token_to_users

Update the migration file with this code:

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

Execute the migration script:

1bundle exec rails db:migrate

Let's update our User model to use has_secure_token method to generate a random alphanumeric token for the users.

To do that, add the following line to app/models/user.rb:

1# frozen_string_literal: true
3class User < ApplicationRecord
4  VALID_EMAIL_REGEX = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i
7  has_many :assigned_tasks, foreign_key: :assigned_user_id, class_name: "Task"
8  has_secure_password
9  has_secure_token :authentication_token
11  validates :name, presence: true, length: { maximum: 35 }
12  validates :email, presence: true,
13                    uniqueness: { case_sensitive: false },
14                    length: { maximum: MAX_EMAIL_LENGTH },
15                    format: { with: VALID_EMAIL_REGEX }
16  validates :password, length: { minimum: 6 }, if: -> { password.present? }
17  validates :password_confirmation, presence: true, on: :create
19  before_save :to_lowercase
21  private
23    def to_lowercase
24      email.downcase!
25    end

To verify this change, we need to create a new user and save it to the database.

We can do it from the Rails console in sandbox mode so that all these changes will be rolled back on exit. This will let us keep our database clean from junk data. Refer our previous chapter on rails console for more details.

Open the Rails console in the sandbox mode:

1bundle exec rails console --sandbox

Now, execute this command:

1user =
2  name:"alex", email:"",
3  password:"welcome", password_confirmation:"welcome",

We will get an output similar to this:

1   (0.6ms)  SELECT sqlite_version(*)
2=> #<User id: nil, name: "alex", created_at: nil, updated_at: nil, email: "", password_digest: [FILTERED], authentication_token: nil>

Now let's save the user to the database:

authentication_token is auto-generated when user is created and saved to database. Since the authentication_token is unique, the generated value will always be a new one:

1irb(main):006:0> user.authentication_token
2=> "s4XUM7YboHazNzpnqftquJ6T"

Session controller

Now, let us create the session controller to authenticate users and send the authentication_token along with user's id and name as response if the credentials provided by the user are correct:

1bundle exec rails g controller sessions

Add the following lines of code to sessions_controller.rb:

1class SessionsController < ApplicationController
2  def create
3    @user = User.find_by!(email: login_params[:email].downcase)
4    unless @user.authenticate(login_params[:password])
5      respond_with_error("Incorrect credentials, try again.", :unauthorized)
6    end
7  end
9  private
11    def login_params
12      params.require(:login).permit(:email, :password)
13    end

Note that we have only called the respond_with_error method inside the guard clause. Don't let this confuse you. This doesn't mean that an HTTP response will only be sent if the code enters the unless block of the create action.

If the control doesn't go inside the guard clause, then Rails will render the view template associated with the create action.

A guard clause is a conditional check that immediately exits the function or method, either with a return statement or an exception.

In our code, unless statement is the guard clause since it will exit the create method returning a JSON response and status if the condition inside unless statement holds false.

There is no difference between the following create method and the create action method we have in our application's SessionsController.

1def create
2  @user = User.find_by!(email: login_params[:email].downcase)
3  unless @user.authenticate(login_params[:password])
4    respond_with_error("Incorrect credentials, try again.", :unauthorized)
5  else
6    render
7  end

The lesson here is that, we should avoid calling render whenever possible to avoid redundancy since Rails takes care of it for us.

Note that, Rails, by convention, expects a view file for the create action in the views directory. If that can't be found, then Rails will point out that the relevant template file is missing.

Now, add a Jbuilder view template for rendering the JSON response for session controller's create action.

To do so, run the following command:

1touch ./app/views/sessions/create.json.jbuilder

In app/views/sessions/create.json.jbuilder, add the following lines of code:

1json.extract! @user,
2  :id,
3  :name,
4  :authentication_token

Let's add session routes by modifying config/routes.rb so that it can be called through API:

1Rails.application.routes.draw do
2  constraints(lambda { |req| req.format == :json }) do
3    resources :tasks, except: %i[new edit], param: :slug
4    resources :users, only: %i[create index]
5    resource :session, only: :create
6  end
8  root "home#index"
9  get '*path', to: 'home#index', via: :all

Login page

As discussed earlier, let us move the dashboard component to / route.

We will be using our PrivateRoute component to disallow unauthenticated users accessing that page by redirecting them to /login route.

Now while importing Login component in App.jsx, we will be using same namespace as we had done while importing the Signup component. So this is a good time to use index.js so that we don’t have to add multiple imports but rather keep it down to a single import.

These conventions are documented in this chapter in our React Best Practices Book.

Create a new file index.js in app/javascript/src/components/Authentication and add the following lines.

1import Login from "./Login";
2import Signup from "./Signup";
4export { Login, Signup };

then 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, ShowTask, EditTask } from "components/Tasks";
9import { Login, Signup } from "components/Authentication";
10import PrivateRoute from "components/Common/PrivateRoute";
11import { getFromLocalStorage } from "utils/storage";
12import PageLoader from "components/PageLoader";
14const App = () => {
15  const [loading, setLoading] = useState(true);
16  const authToken = getFromLocalStorage("authToken");
17  const isLoggedIn = !either(isNil, isEmpty)(authToken);
19  useEffect(() => {
20    registerIntercepts();
21    initializeLogger();
22    setAuthHeaders(setLoading);
23  }, []);
25  if (loading) {
26    return (
27      <div className="h-screen">
28        <PageLoader />
29      </div>
30    );
31  }
33  return (
34    <Router>
35      <ToastContainer />
36      <Switch>
37        <Route exact path="/tasks/:slug/show" component={ShowTask} />
38        <Route exact path="/tasks/:slug/edit" component={EditTask} />
39        <Route exact path="/tasks/create" component={CreateTask} />
40        <Route exact path="/signup" component={Signup} />
41        <Route exact path="/login" component={Login} />
42        <PrivateRoute
43          path="/"
44          redirectRoute="/login"
45          condition={isLoggedIn}
46          component={Dashboard}
47        />
48      </Switch>
49    </Router>
50  );
53export default App;

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

1/* previous code */
2const Create = ({ history }) => {
3  /* previous code */
4  const handleSubmit = async event => {
5    event.preventDefault();
6    setLoading(true);
7    try {
8      /* previous code */
9      history.push("/");
10    } catch (error) {
11      logger.error(error);
12      setLoading(false);
13    }
14  };
15  /* previous code */
18export default Create;

Now make the same change in the Edit component as well, like so:

1/* previous code */
2const Edit = ({ history }) => {
3  /* previous code */
4  const handleSubmit = async event => {
5    event.preventDefault();
6    try {
7      /* previous code */
8      history.push("/");
9    } catch (error) {
10      setLoading(false);
11      logger.error(error);
12    }
13  };
14  /* previous code */

Now, let's define app/javascript/src/components/Common/PrivateRoute.jsx. It should redirect unauthenticated users to the login screen, if they try to access any private route.

Create the file 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";
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} />;
25PrivateRoute.propTypes = {
26  component: PropTypes.func,
27  condition: PropTypes.bool,
28  path: PropTypes.string,
29  redirectRoute: PropTypes.string,
30  location: PropTypes.object,
33export default PrivateRoute;

Open app/javascript/src/apis/auth.js and replace it with the following content:

1import axios from "axios";
3const login = payload =>
4"/session", {
5    login: payload,
6  });
8const signup = payload =>
9"/users", {
10    user: payload,
11  });
13const authApi = {
14  login,
15  signup,
18export default authApi;

As with sign-up page, we will abstract the form logic from login to a different component. For that, create a new file, Login.jsx inside Form folder by running the following command:

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

Add the following content into Form/Login.jsx:

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

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 Login inside Form folder:

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

Add the following content to Login.jsx:

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

Here, we are storing the tokens from login API response in localstorage of the browser. These tokens will be attached to the request headers as X-Auth-Token and X-Auth-Email in every request.

Note: Make sure that axios headers are set as mentioned in the previous chapter.

Validating request authenticity in backend

Until now, the users were able to get the list of tasks through API even if they weren't authenticated. To restrict this behavior, we will make the following changes:

  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 Login page.

For handling the 2nd case, 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_action 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. We will create a method for authentication and pass this method to the before_action filter.

Update the application_controller.rb file like so:

1class ApplicationController < ActionController::Base
2  before_action :authenticate_user_using_x_auth_token
4  # previous code
6  private
8    # previous code
10    def authenticate_user_using_x_auth_token
11      user_email = request.headers["X-Auth-Email"].presence
12      auth_token = request.headers["X-Auth-Token"].presence
13      user = user_email && User.find_by!(email: user_email)
14      is_valid_token = user && auth_token && ActiveSupport::SecurityUtils.secure_compare(user.authentication_token, auth_token)
15      if is_valid_token
16        @current_user = user
17      else
18        respond_with_error("Could not authenticate with the provided credentials", :unauthorized)
19      end
20    end
22    def current_user
23      @current_user
24    end

Let's observe what's going on here.

We will be receiving 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.

Calling the presence method on request.headers["X-Auth-Email"] will return the value of X-Auth-Email if it is not nil otherwise it will return nil. The to_s method returns a string equivalent of the value it is called upon if the value is present. If to_s is called on nil then it returns an empty string.

We are checking if the auth_token is present using the to_s method because, the presence method would return nil if request.headers["X-Auth-Token"] is not present and that will cause secure_compare method to throw an error.

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.

We have also added a method called current_user which returns the current user details.

Order of the filters in which they are invoked matters a lot. Controller executes filters in the order in which they are defined. We want the authentication filter to run before any other filters.

That's why it's important that authenticate_user_using_x_auth_token is the first filter. We have ensured this by declaring it as the first filter inside the ApplicationController. All controllers inheriting from the ApplicationController will inherit this filter and it will be the first filter to be invoked.

Keep in mind that the authenticate_user_using_x_auth_token method should only be invoked to authenticate API requests. It should not be invoked for any other requests. Because for non-API routes authentication will fail as X-Auth-Email and X-Auth-Token will not be present in the request header and the request will fail.

The entry point into the application is not an API request and it is processed by the index action of the HomeController which inherits from the ApplicationController. We ought to skip the authenticate_user_using_x_auth_token filter in this case. Add a skip_before_action callback in the home_controller.rb file like so:

1class HomeController < ApplicationController
2  skip_before_action :authenticate_user_using_x_auth_token
4  def index
5    render
6  end

Skipping authentication when not required

Suppose the user is signing up or logging in. In such a case, performing authentication doesn't make sense because the auth headers are not yet set.

We have declared the authenticate_user_using_x_auth_token in such a way that it will be called before any other filters and controller actions. Our application should not invoke this method when the user sends a login request.

To do so, we can use the skip_before_action filter inside the SessionsController like so:

1class SessionsController < ApplicationController
2  skip_before_action :authenticate_user_using_x_auth_token
4  def create
5    @user = User.find_by!(email: login_params[:email].downcase)
6    unless @user.authenticate(login_params[:password])
7      respond_with_error("Incorrect credentials, try again.", :unauthorized)
8    end
9  end
11  private
13    def login_params
14      params.require(:login).permit(:email, :password)
15    end

We should also skip authentication during signup. Update the UsersController like so:

1class UsersController < ApplicationController
2  skip_before_action :authenticate_user_using_x_auth_token, only: :create
4  # previous code

Moving response messages to i18n en.locales

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

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!"
6  successfully_updated: "%{entity} was successfully updated!"
7  task:
8    slug:
9      immutable: "is immutable!"

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    unless @user.authenticate(login_params[:password])
4      respond_with_error(t("session.incorrect_credentials"), :unauthorized)
5  end

And similarly, for the case where we can't authenticate user using auth token, in authenticate_user_using_x_auth_token method declared inside the ApplicationController class we can send the following response:

1def authenticate_user_using_x_auth_token
2  user_email = request.headers["X-Auth-Email"].presence
3  auth_token = request.headers["X-Auth-Token"].presence
4  user = user_email && User.find_by!(email: user_email)
5  is_valid_token = user && auth_token && ActiveSupport::SecurityUtils.secure_compare(user.authentication_token, auth_token)
6  if is_valid_token
7    @current_user = user
8  else
9    respond_with_error(t("session.could_not_auth"), :unauthorized)
10  end

Showing logged in user

Now, to show the logged in user's name in our NavBar, add these changes our app/javascript/src/components/NavBar/index.jsx file:

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

Now, to validate these changes, we need some user accounts. We might already have created some users while testing the sign-up feature on our previous chapter.

To ensure uniformity, let us clear all of them, and create some users.

Start the Rails console:

1bundle exec rails console

Clear all users:


Now, run this snippet to create our default users:

2  email: '', name: 'Oliver',
3  password: 'welcome', password_confirmation: 'welcome'
6  email: '', name: 'Sam',
7  password: 'welcome', password_confirmation: 'welcome'

Start Rails server and go to our application. We should be able to login to any one of these demo accounts.

Now, let's commit the changes:

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