In this tutorial, we'll learn how to build an authentication system for a Nodejs & Express application using JWT.
We'll be working on the project of this tutorial Build an API using Node, Express, MongoDB, and Docker . You can find the code source for this tutorial here.
What is Authentication and Authorization?
Simply, authentication is the process of verifying the identity of someone.
Authorization is the process of verifying what data the user can have access to.
And authorization only occurs when you've been authenticated. Then, the system will grant you access to the files you need.
Setup the project
First of all, clone the project.
git clone https://github.com/koladev32/node-docker-tutorial.git
Once it's done, go inside the project and run.
yarn install
Start the project using :
yarn start
Inside the root of the project, create a .env
file.
// .env
JWT_SECRET_KEY=)a(s3eihu+iir-_3@##ha$r$d4p5%!%e1==#b5jwif)z&kmm@7
You can easily generate a new value for this secret key online here.
Creating the User model
Let's create the User model. But first, we need to define a type for this model.
// src/types/user.ts
import { Document } from "mongoose";
export interface IUser extends Document {
username: string;
password: string;
isAdmin: boolean;
}
Great, then we can write the User model.
// src/models/user.ts
import { IUser } from "../types/user";
import { model, Schema } from "mongoose";
const userSchema: Schema = new Schema(
{
username: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
},
isAdmin: {
type: Boolean,
required: false,
default: false,
},
},
{ timestamps: true }
);
export default model<IUser>("user", userSchema);
The User model is created. We can go and start writing the Login and Register controllers.
Registration
Go to the controllers
directory and create a new directory users
which will contain a new index.ts
file.
Let write the registerUser
controller.
// src/controllers/users/index.ts
import { Response, Request } from "express";
import { IUser } from "../../types/user";
import User from "../../models/user"
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
let refreshTokens: string[] = [];
const registerUser = async (
req: Request,
res: Response
): Promise<e.Response<any, Record<string, any>>> => {
try {
const { username, password } = req.body;
if (!(username && password)) {
return res.status(400).send("All inputs are required");
}
// Checking if the user already exists
const oldUser = await User.findOne({ username });
if (oldUser) {
return res.status(400).send("User Already Exist. Please Login");
}
const user: IUser = new User({
username: username,
});
const salt = await bcrypt.genSalt(10);
// now we set user password to hashed password
user.password = await bcrypt.hash(password, salt);
user.save().then((doc) => {
// Generating Access and refresh token
const token = jwt.sign(
{ user_id: doc._id, username: username },
process.env.JWT_SECRET_KEY,
{
expiresIn: "5min",
}
);
const refreshToken = jwt.sign(
{ user_id: doc._id, username: username },
process.env.JWT_SECRET_KEY
);
refreshTokens.push(refreshToken);
return res.status(201).json({
user: doc,
token: token,
refresh: refreshToken,
});
});
return res.status(400).send("Unable to create user");
} catch (error) {
throw error;
}
};
export {registerUser};
What are we doing here?
- Check that the required fields have been provided
- Check that there is no existing user with the same username
- Creating the user and encrypting the password
- Generating refresh and access tokens
- Send responses
But why a refresh and an access token?
When the token expires, the intuitive way to claim a new access token will be to log in again. But this is not effective at all for the experience of possible users.
Then instead of login in again, the client can claim a new access token by making a request with the refresh token obtained at login or registration.
We'll write the routes for this later.
Now, let's add this controller to the routes and register the new routes in our application.
// src/routes/index.ts
import { Router } from "express";
import {
getMenus,
addMenu,
updateMenu,
deleteMenu,
retrieveMenu,
} from "../controllers/menus";
import {
registerUser
} from "../controllers/users";
const menuRoutes: Router = Router();
const userRoutes: Router = Router();
// Menu Routes
menuRoutes.get("/menu", getMenus);
menuRoutes.post("/menu", addMenu);
menuRoutes.put("/menu/:id", updateMenu);
menuRoutes.delete("/menu/:id", deleteMenu);
menuRoutes.get("/menu/:id", retrieveMenu);
// User Routes
userRoutes.post("/user/register", registerUser);
export { menuRoutes, userRoutes };
And inside the app.ts
file, let's use the new route.
// src/app.ts
import { menuRoutes, userRoutes } from "./routes";
...
app.use(cors());
app.use(express.json());
app.use(userRoutes);
...
The endpoint is available at localhost:4000/user/register.
Login
Inside the index.ts
file of users controllers, let's write the login function.
// src/controllers/users/index.ts
const loginUser = async (
req: Request,
res: Response
): Promise<e.Response<any, Record<string, any>>> => {
try {
const { username, password } = req.body;
if (!(username && password)) {
return res.status(400).send("All inputs are required");
}
// Checking if the user exists
const user: IUser | null = await User.findOne({ username });
if (user && (await bcrypt.compare(password, user.password))) {
// Create token
const token = jwt.sign(
{ user_id: user._id, username: username },
process.env.JWT_SECRET_KEY,
{
expiresIn: "5min",
}
);
const refreshToken = jwt.sign(
{ user_id: user._id, username: username },
process.env.JWT_SECRET_KEY
);
refreshTokens.push(refreshToken);
// user
return res.status(200).json({
user: user,
token: token,
refresh: refreshToken,
});
}
return res.status(400).send("Invalid Credentials");
} catch (error) {
throw error;
}
};
export { registerUser, loginUser };
So what are we doing here?
- Check that the required fields have been provided
- Check that the user exists
- Compare the password and create new tokens if everything is right
- Then send responses
If these validations are not done, we send error messages as well.
Add it to the routes and log in using https://localhost:4500/user/login.
// src/routes/index.ts
...
userRoutes.post("/user/login", loginUser);
...
Protecting the Menu resources
Ah great. The Login endpoint is done, the registering endpoint also is done. But the resources are not protected.
You can still access them and because we need to write a middleware.
A middleware is a function that is used to that acts as a bridge between a request and a function to execute the requests.
Create a new directory named middleware
inside src
and create a file index.ts
.
Great, let's write our middleware.
// src/middleware/index.ts
import e, { Response, Request, NextFunction } from "express";
import { IUser } from "../types/user";
const jwt = require("jsonwebtoken");
const authenticateJWT = async (
req: Request,
res: Response,
next: NextFunction
): Promise<e.Response<any, Record<string, any>>> => {
const authHeader = req.headers.authorization;
if (authHeader) {
const [header, token] = authHeader.split(" ");
if (!(header && token)) {
return res.status(401).send("Authentication credentials are required.");
}
jwt.verify(token, process.env.JWT_SECRET_KEY, (err: Error, user: IUser) => {
if (err) {
return res.sendStatus(403);
}
req.user = user;
next();
});
}
return res.sendStatus(401);
};
export default authenticateJWT;
What are we doing here?
- Making sure there are authorization headers. We actually want the values of this header to this format: 'Bearer Token'.
- Verifying the token and then creating a new key with
user
as value.req.user = user
- And finally using
next()
to execute the next function.
Now, let's use the middleware in our application.
// src/app.ts
import authenticateJWT from "./middleware";
...
app.use(userRoutes);
app.use(authenticateJWT);
app.use(menuRoutes);
...
Did you notice something? The middleware is placed after the userRoutes
and before menuRoutes
.
Well, going like this, node & express will understand that the userRoutes
are not protected and also that all the routes after the authenticateJWT
will require an access token.
To test this, make a GET
request to http://localhost:4000/menus without authorization header. You'll receive a 401
error.
Then use the access token from your previous login and add it to the authorization header.
You should retrieve the menus.
Refresh token
It's time now to write the refresh token controller.
// src/controllers/users/index.ts
const retrieveToken = async (
req: Request,
res: Response
): Promise<e.Response<any, Record<string, any>>> => {
try {
const { refresh } = req.body;
if (!refresh) {
return res.status(400).send("A refresh token is required");
}
if (!refreshTokens.includes(refresh)) {
return res.status(403).send("Refresh Invalid. Please login.");
}
jwt.verify(
refresh,
process.env.JWT_SECRET_KEY,
(err: Error, user: IUser) => {
if (err) {
return res.sendStatus(403);
}
const token = jwt.sign(
{ user_id: user._id, username: user.username },
")a(s3eihu+iir-_3@##ha$r$d4p5%!%e1==#b5jwif)z&kmm@7",
{
expiresIn: "5min",
}
);
return res.status(201).send({
token: token,
});
}
);
return res.status(400).send("Invalid Credentials");
} catch (error) {
throw error;
}
};
So what are we doing here?
- Making sure that the refresh token exists in the body
- Making sure that the refresh token exists in the memory of the server
- And finally verifying the refresh token then sending a new access token.
Add this new controller to the userRoutes
.
// src/routes/index.ts
...
userRoutes.post("/user/refresh", retrieveToken);
...
You can hit http://localhost:4000/user/refresh to retrieve a new access token.
Logout
But there is a problem. If the refresh token is stolen from the user, someone can use it to generate as many new tokens as they'd like. Let's invalidate this.
// src/controllers/users/index.ts
...
const logoutUser = async (
req: Request,
res: Response
): Promise<e.Response<any, Record<string, any>>> => {
try {
const { refresh } = req.body;
refreshTokens = refreshTokens.filter((token) => refresh !== token);
return res.status(200).send("Logout successful");
} catch (error) {
throw error;
}
};
export { registerUser, loginUser, retrieveToken, logoutUser };
And a new route to log out.
// src/routes/index.ts
import {
loginUser,
logoutUser,
registerUser,
retrieveToken,
} from "../controllers/users";
...
userRoutes.post("user/logout", logoutUser);
...
You can hit http://localhost:4000/user/logout to invalidate the token.
And voilà, we're done. 🥳
Conclusion
In this article, we've learned how to build an authentication system for our Node & Express application using JWT.
And as every article can be made better so your suggestion or questions are welcome in the comment section. 😉
Check the code of this tutorial here.