Search
⌘K
    to navigateEnterto select Escto close

    Adding login feature

    Features

    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 LoginForm.

    • 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 use before_action filter on our TasksController to ensure the authenticity of requests before fetching the tasks from DB.

    • Since we will need the same authenticity verification logic on several other controllers too, we will define the method in our ApplicationController with the name authenticate_user_using_x_auth_token.

    • authenticate_user_using_x_auth_token method should 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.

    • 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 in order 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[6.1]
    2  def change
    3    add_column :users, :authentication_token, :string
    4  end
    5end

    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:

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

    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 = User.new(
    2  name:"alex", email:"alex@example.com",
    3  password:"welcome", password_confirmation:"welcome",
    4)

    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: "alex@example.com", password_digest: [FILTERED], authentication_token: nil>

    Now let's save the user to the database:

    1user.save

    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 code to sessions_controller.rb:

    1class SessionsController < ApplicationController
    2  def create
    3    @user = User.find_by(email: login_params[:email].downcase)
    4    unless @user.present? && @user.authenticate(login_params[:password])
    5      render status: :unauthorized, json: { error: "Incorrect credentials, try again." }
    6    end
    7  end
    8
    9  private
    10
    11    def login_params
    12      params.require(:login).permit(:email, :password)
    13    end
    14end

    Note that we have only called the render 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.present? && @user.authenticate(login_params[:password])
    4    render status: :unauthorized, json: { error: "Incorrect credentials, try again." }
    5  else
    6    render
    7  end
    8end

    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
    7
    8  root "home#index"
    9  get '*path', to: 'home#index', via: :all
    10end

    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.

    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 CreateTask component, so as to redirect users to the correct URL from the handleSubmit function, like so:

    1/* previous code */
    2const CreateTask = ({ 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 */
    16};
    17
    18export default CreateTask;

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

    1/* previous code */
    2const EditTask = ({ 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 */
    15};

    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";
    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 replace it with the following content:

    1import axios from "axios";
    2
    3const login = payload => axios.post("/session", payload);
    4
    5const signup = payload => axios.post("/users", payload);
    6
    7const authApi = {
    8  login,
    9  signup
    10};
    11
    12export 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, 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
    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="oliver@example.com"
    35            onChange={e => setEmail(e.target.value)}
    36          />
    37          <Input
    38            label="Password"
    39            type="password"
    40            placeholder="********"
    41            onChange={e => setPassword(e.target.value)}
    42          />
    43          <Button type="submit" buttonText="Sign In" loading={loading} />
    44        </form>
    45      </div>
    46    </div>
    47  );
    48};
    49
    50export default LoginForm;

    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    setLoading(true);
    16    try {
    17      const response = await authApi.login({ login: { email, password } });
    18      setToLocalStorage({
    19        authToken: response.data.authentication_token,
    20        email,
    21        userId: response.data.id,
    22        userName: response.data.name
    23      });
    24      setAuthHeaders();
    25      setLoading(false);
    26      window.location.href = "/";
    27    } catch (error) {
    28      logger.error(error);
    29      setLoading(false);
    30    }
    31  };
    32
    33  return (
    34    <LoginForm
    35      setEmail={setEmail}
    36      setPassword={setPassword}
    37      loading={loading}
    38      handleSubmit={handleSubmit}
    39    />
    40  );
    41};
    42
    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 Log In 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 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.

    Now, let's define authenticate_user_using_x_auth_token. For that, open application_controller.rb and replace its whole contents 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        error: "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 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.

    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  unless @user.present? && @user.authenticate(login_params[:password])
    4    render status: :unauthorized, json: { error: t("session.incorrect_credentials") }
    5  end
    6end

    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: { error: t('session.could_not_auth') }
    13  end
    14end

    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.js";
    5import { getFromLocalStorage } from "helpers/storage";
    6
    7const NavBar = () => {
    8  const userName = getFromLocalStorage("authUserName");
    9
    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="/" />
    17              <NavItem
    18                name="Create"
    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  );
    37};
    38
    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:

    1User.destroy_all

    Now, run this snippet to create our default users:

    1User.create!(
    2  email: 'oliver@example.com', name: 'Oliver',
    3  password: 'welcome', password_confirmation: 'welcome'
    4)
    5User.create!(
    6  email: 'sam@example.com', name: 'Sam',
    7  password: 'welcome', password_confirmation: 'welcome'
    8)

    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"
    Previous
    Next