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:
-
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:
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:
Update the migration file with this code:
Execute the migration script:
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
:
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:
Now, execute this command:
We will get an output similar to this:
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:
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:
Add the following lines of code to sessions_controller.rb
:
Note that we have only called the render_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.
When not to use guard clause
Guard clauses are incredibly useful for making code clearer and avoiding deep nesting by handling edge cases or invalid conditions up front.
However, they are not always the best choice, particularly in situations that involve complex logic or several linked conditions, where using guard clauses can make the code harder to follow.
For instance, consider a scenario where we need to update the user's city.
In the case above, readability is somewhat reduced. An if-else
structure might be a better choice here:
Another case where guard clauses aren't ideal is when multiple actions in a Rails controller share common requirements. Here, using callbacks such as before_action
is more efficient, helping to keep the code DRY and organized by centralizing preconditions and avoiding repetitive guard clauses across actions.
But 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
.
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:
In app/views/sessions/create.json.jbuilder
, add the following lines of code:
Let's add session routes by modifying config/routes.rb
so that it can be
called through API:
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.
then open app/javascript/src/App.jsx
file and add the following lines:
Now that our dashboard component is rendered at /
path instead of
/dashboard
, replace the occurrences of history.push("/dashboard")
with history.push("/")
. Also replace the value of to
prop of <Link/>
components from "/dashboard"
to "/"
.
Now, let's define app/javascript/src/components/commons/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:
Now, export PrivateRoute
from commons/index.js
file:
Open app/javascript/src/apis/auth.js
and replace it with the following
content:
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:
Add the following content into Form/Login.jsx
:
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:
Add the following content to Login.jsx
:
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:
- If user is logged in, we do nothing and allow the controller to carry out its
job.
- 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 this:
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
.
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 this:
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 this:
We should also skip authentication during signup. Update the UsersController
like this:
Moving response messages to i18n en.locales
Let's move the response messages to en.yml
:
We can use this as session.incorrect_credentials
as error response message in
session_controller.rb
:
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:
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:
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:
Now, run this snippet to create our default users:
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: