Learn Ruby on Rails Book

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 and it should respond with the 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, replace 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

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 as response if the credentials provided by the user is 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    if user.present? && user.authenticate(login_params[:password])
5      render status: :ok, json: {
6        auth_token: user.authentication_token,
7        userId: user.id,
8        user_name: user.name
9      }
10    else
11      render status: :unauthorized, json: {
12        notice: 'Incorrect credentials, try again.'
13      }
14    end
15  end
16
17  private
18
19    def login_params
20      params.require(:login).permit(:email, :password)
21    end
22end

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

1Rails.application.routes.draw do
2  resources :tasks, except: %i[new edit], param: :slug
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

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 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      history.push("/");
12    } catch (error) {
13      logger.error(error);
14    } finally {
15      setLoading(false);
16    }
17  };
18  /* previous code */
19};

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("/sessions", 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    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;

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        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 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  if user.present? && user.authenticate(login_params[:password])
4    render status: :ok, json: {
5      auth_token: user.authentication_token,
6      userId: user.id,
7      user_name: user.name
8    }
9  else
10    render status: :unauthorized, json: { notice: t('session.incorrect_credentials') }
11  end
12end

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

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"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next