Secure Your Node.js API: Implementing Robust User Login with JWT - [Anni]
Introduction In today's web development landscape, securing your APIs is paramount. User authentication is a critical first step in ensuring only authorized users can access your application's resources. In this post, we'll dive into a practical implementation of a secure user login system using Node.js, Express, and JSON Web Tokens (JWT). We'll explore input validation, database interaction, and the generation of access and refresh tokens. Project Structure Before we delve into the code, let's take a quick look at the project structure. userops/ ├── app/ │ ├── config/ │ ├── controller/ │ │ └── appController.js │ │ └── middlewareControl.js │ ├── model/ │ │ └── userModel.js │ ├── routes/ │ │ └── appRoutes.js │ └── service/ │ ├── mailService.js │ └── service.js ├── node_modules/ ├── test/ ├── .env ├── app.js ├── Dockerfile ├── package-lock.json └── package.json Better visualizing it in form of image. Defining the Login Route The entry point for our login functionality is defined in our appRoutes.js file. router.route("/api/v1/userops/login") .post(middleware.validateReqBody("login"), controller.login); Here, we define a POST route at /api/v1/userops/login. Notice that we're using a middleware (validateReqBody) to handle input validation before passing the request to our login controller function. Input Validation Middleware The validateReqBody middleware (likely in middlewareControl.js) is responsible for ensuring the incoming request body adheres to our defined rules. For the "login" case (Image 6), we're validating the email and password fields. const express = require("express"); const { check } = require("express-validator"); const middleware = { validateReqBody: (prop) => { switch (prop) { case "login": { return [ check("email") .isEmail().withMessage("Valid email required.") .trim().normalizeEmail(), check("password") .isLength({ min: 6 }).withMessage("Password must be at least 6 characters."), ] } } } } module.exports = middleware; This ensures the email is in a valid format and the password has a minimum length, preventing basic errors from reaching our core logic. The Login Controller The login function within our appController.js handles the main login process. login: (req, res, next) => { try { const validationCheck = validationResult(req); if (!validationCheck.isEmpty()) { return res.status(422).send({ status: "error", errorType: "validation", response: validationCheck.errors.map((item) => item.msg).join(" | "), }); } return service.login(req, res); } catch (error) { return res.status(500).send({ status: "error", response: "Internal server error", }); } } First, it checks for any validation errors. If errors exist, it returns a 422 status code with the validation messages. Otherwise, it calls the login service function to handle the core authentication logic. The Login Service The login function in our service.js (Image 4) performs the crucial steps of verifying user credentials and generating JWTs. login: async (req, res) => { try { const { email, password } = req.body; const emailExists = await userModel.login(email, password); if (emailExists && Object.keys(emailExists).length > 0) { if (emailExists.isVerified === true) { const authToken = await jwt.sign( { userDetails: emailExists, purpose: "user_login", }, JWT_SECRET_KEY, { expiresIn: "45m" } ); const refreshToken = await jwt.sign( { userDetails: emailExists, purpose: "user_login", }, JWT_SECRET_KEY, { expiresIn: "7d" } ); return res.status(200).send({ status: "success", response: "User logged in successfully.", authToken, refreshToken, }); } else { return res.status(403).send({ status: "error", response: "Your account is not verified. Please verify your email before logging in.", }); } } else { return res.status(404).send({ status: "error", response: "User not found. Please check your email and password.", }); } } catch (error) { return res.status(500).send({ status: "error", response: "Internal server error. " + error.message, }); } } Here's a breakdown: It extracts the email and password from the request body. It calls the userModel.login function (Image 5) to find a user with the provided credentials. If a user is found: It checks if the user's email is verified. If not, i
![Secure Your Node.js API: Implementing Robust User Login with JWT - [Anni]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnuppmi4kktj71gexo0fi.png)
Introduction
In today's web development landscape, securing your APIs is paramount. User authentication is a critical first step in ensuring only authorized users can access your application's resources. In this post, we'll dive into a practical implementation of a secure user login system using Node.js, Express, and JSON Web Tokens (JWT). We'll explore input validation, database interaction, and the generation of access and refresh tokens.
Project Structure
Before we delve into the code, let's take a quick look at the project structure.
userops/
├── app/
│ ├── config/
│ ├── controller/
│ │ └── appController.js
│ │ └── middlewareControl.js
│ ├── model/
│ │ └── userModel.js
│ ├── routes/
│ │ └── appRoutes.js
│ └── service/
│ ├── mailService.js
│ └── service.js
├── node_modules/
├── test/
├── .env
├── app.js
├── Dockerfile
├── package-lock.json
└── package.json
Better visualizing it in form of image.
Defining the Login Route
The entry point for our login functionality is defined in our appRoutes.js file.
router.route("/api/v1/userops/login")
.post(middleware.validateReqBody("login"), controller.login);
Here, we define a POST route at /api/v1/userops/login. Notice that we're using a middleware (validateReqBody) to handle input validation before passing the request to our login controller function.
Input Validation Middleware
The validateReqBody middleware (likely in middlewareControl.js) is responsible for ensuring the incoming request body adheres to our defined rules. For the "login" case (Image 6), we're validating the email and password fields.
const express = require("express");
const { check } = require("express-validator");
const middleware = {
validateReqBody: (prop) => {
switch (prop) {
case "login": {
return [
check("email")
.isEmail().withMessage("Valid email required.")
.trim().normalizeEmail(),
check("password")
.isLength({ min: 6 }).withMessage("Password must be at least 6 characters."),
]
}
}
}
}
module.exports = middleware;
This ensures the email is in a valid format and the password has a minimum length, preventing basic errors from reaching our core logic.
The Login Controller
The login function within our appController.js handles the main login process.
login: (req, res, next) => {
try {
const validationCheck = validationResult(req);
if (!validationCheck.isEmpty()) {
return res.status(422).send({
status: "error",
errorType: "validation",
response: validationCheck.errors.map((item) => item.msg).join(" | "),
});
}
return service.login(req, res);
} catch (error) {
return res.status(500).send({
status: "error",
response: "Internal server error",
});
}
}
First, it checks for any validation errors. If errors exist, it returns a 422 status code with the validation messages. Otherwise, it calls the login service function to handle the core authentication logic.
The Login Service
The login function in our service.js (Image 4) performs the crucial steps of verifying user credentials and generating JWTs.
login: async (req, res) => {
try {
const { email, password } = req.body;
const emailExists = await userModel.login(email, password);
if (emailExists && Object.keys(emailExists).length > 0) {
if (emailExists.isVerified === true) {
const authToken = await jwt.sign(
{
userDetails: emailExists,
purpose: "user_login",
},
JWT_SECRET_KEY,
{ expiresIn: "45m" }
);
const refreshToken = await jwt.sign(
{
userDetails: emailExists,
purpose: "user_login",
},
JWT_SECRET_KEY,
{ expiresIn: "7d" }
);
return res.status(200).send({
status: "success",
response: "User logged in successfully.",
authToken,
refreshToken,
});
} else {
return res.status(403).send({
status: "error",
response: "Your account is not verified. Please verify your email before logging in.",
});
}
} else {
return res.status(404).send({
status: "error",
response: "User not found. Please check your email and password.",
});
}
} catch (error) {
return res.status(500).send({
status: "error",
response: "Internal server error. " + error.message,
});
}
}
Here's a breakdown:
- It extracts the email and password from the request body.
- It calls the userModel.login function (Image 5) to find a user with the provided credentials.
- If a user is found:
- It checks if the user's email is verified. If not, it returns a 403 status.
- If verified, it generates both an authToken (short-lived) and a refreshToken (longer-lived) using JWT. These tokens are signed using a secret key (JWT_SECRET_KEY) and include user details and an expiration time.
- It returns a 200 status with a success message and the generated tokens.
- If no user is found, it returns a 404 status.
- Any errors during this process result in a 500 status.
The User Model
The userModel.js likely interacts with your database (e.g., MongoDB using Mongoose) to find a user by email and password.
login: async (emailAddress, password) => {
return await dbCon.findOne({ email: emailAddress, password: password }, { password: 0 }).exec();
}
This function queries the database for a user matching the provided email and password. It uses findOne and excludes the actual password from the returned document for security reasons.
This example demonstrates a fundamental yet crucial aspect of API security: user authentication. By implementing input validation, securely verifying credentials against a database, and issuing JWTs for authorized access, you can protect your application's resources effectively. Remember to handle your JWT_SECRET_KEY securely and consider implementing token refresh mechanisms in a production environment for a better user experience.