visit
One of the first challenges I encountered when I started writing server code was how to build a secured yet simple authentication and authorization (AA) flow without dependence on third-party providers. Thank God for the tech community and platform such as
Prerequisites:
Clone the project repository -
(optional)
Authentication
Authentication is the process of verifying a user’s identity. Essentially, it means making sure that a user is who they say they are. You can use one or more of the following methods while implementing authentication:
Authorization
Authorization follows authentication. It ensures that a logged-in user has the right to perform specific actions or view certain data. For example, a user may have access to view his personal information through a web interface, but shouldn’t be permitted to view other user’s data. He also shouldn’t have access to administrative functions if he is just a regular user. In cases where a user was able to access another user’s account either by changing parameters or ID, then there is a flaw in the authentication/authorization flow. You can read more here
You may skip Step One and Two if you already cloned the starter files from npm i
to install the required packages.
Step One
In your terminal, enter npm init
. (Assuming you have node installed on your machine)
Add "dev": "nodemon v1/server.js",
under "scripts" in your package.json file
Add "type": "modules"
to your package.json file, to use import statements rather than require.
Step Two
Installing required packages:
In your project directory terminal, enter,
npm install express mongoose bcrypt cookie-parser dotenv cors express-validator jsonwebtoken
npm install --save-dev nodemon
. (To install Nodemon as dev dependency)
A quick explanation of some of the tools you just installed
- ExpressJS is a minimal and flexible Node.js web application framework that provides a robust set of features for web and mobile applications. //expressjs.com
- Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It manages relationships between data, provides schema validation, and is used to translate between objects in code and the representation of those objects in MongoDB. //mongoosejs.com/docs/index.html
- Bcrypt is a library to help you hash passwords. It uses a password-hashing function that is based on the Blowfish cipher. We will use this to hash sensitive things like passwords.
- Cookie-parser is a middleware used to parse the Cookie header and populate req.cookies with an object keyed by the cookie names. Optionally you may enable signed cookie support by passing a secret string, which assigns req.secret so it may be used by other middleware.
- Dotenv is a zero-dependency module that loads environment variables from a .env file into process.env.
- CORS is a node.js package for providing a Connect/Express middleware that can be used to enable CORS with various options. We don’t necessarily need this as we are developing just the API, however, good that you know in case you want to implement one.
- Express-Validator is a set of express.js middlewares that wraps validator.js validator and sanitiser functions. You may want to check for an empty request body or validate or even sanitize the request body. This package is very useful for that. You will add one or two of its functions in your code later.
Nodemon is a tool that helps develop Node.js based applications by automatically restarting the node application when file changes in the directory are detected. With this, you don’t have to manually restart your application each time you make changes. Another powerful tool used mostly in production is pm2. You can check it out
.
Step Three
Get your URI string from MongoDB or your database of choice. In this particular tutorial, I am using Mongoose.
A route is a part of your code that handles an HTTP request e.g. GET, POST, DELETE, PUT, and the associated function. The home route is like a welcome page for your API. As you might have known, an API is the middleman between a client app and the database.
Check the GitHub repository for the starter files to confirm that you have the same number of folders. Note: You may ignore the utils folder for now.
File Path- v1/routes/index.js
const Router = (server) => {
// home route with the get method and a handler
server.get("/v1", (req, res) => {
try {
res.status(200).json({
status: "success",
data: [],
message: "Welcome to our API homepage!",
});
} catch (err) {
res.status(500).json({
status: "error",
message: "Internal Server Error",
});
}
})
};
export default Router;
Note that, in the preceding code, we have created an handler that will handle various routing logic in the app. It takes the server instance as an argument. You shall see where we are getting that instance from in a bit.
Create an env and configuration file
Another important file to create is the env file. The env file stores a key-value string for your secret strings which you would not want to be exposed. You may store API keys, secrets, and tokens in it or use a more secured solution.
Run the command npm run env
to create an env file with the .env.example file template in the starter files.
#DATABASE_STRING
URI=xxxxxxx
#SERVER_PORT
PORT=5005
#TOKEN
SECRET_ACCESS_TOKEN=xxxxx
You will change some of the values later.
File Path- v1/config/index.js
import * as dotenv from "dotenv";
dotenv.config();
const { URI, PORT, SECRET_ACCESS_TOKEN } = process.env;
export { URI, PORT, SECRET_ACCESS_TOKEN };
const server = express()
File Path- v1/server.js
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import mongoose from "mongoose";
import { PORT, URI } from "./config/index.js";
import Router from "./routes/index.js";
// === 1 - CREATE SERVER ===
const server = express();
// CONFIGURE HEADER INFORMATION
// Allow request from any source. In real production, this should be limited to allowed origins only
server.use(cors());
server.disable("x-powered-by"); //Reduce fingerprinting
server.use(cookieParser());
server.use(express.urlencoded({ extended: false }));
server.use(express.json());
// === 2 - CONNECT DATABASE ===
// Set up mongoose's promise to global promise
mongoose.promise = global.Promise;
mongoose.set("strictQuery", false);
mongoose
.connect(URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(console.log("Connected to database"))
.catch((err) => console.log(err));
// === 4 - CONFIGURE ROUTES ===
// Connect Route handler to server
Router(server);
// === 5 - START UP SERVER ===
server.listen(PORT, () =>
console.log(`Server running on //localhost:${PORT}`)
);
It is time to start your API for testing. First, inside your terminal, run this command - npm run dev
. Your server should be started like so,
Using Postman or your favourite API testing platform, send a GET request to
A controller is a logic that handles a particular task. The router receives a request and executes the controller logic associated with it.
File Path- v1/models/User.js
import mongoose from "mongoose";
import bcrypt from "bcrypt";
const UserSchema = new mongoose.Schema(
{
first_name: {
type: String,
required: "Your firstname is required",
max: 25,
},
last_name: {
type: String,
required: "Your lastname is required",
max: 25,
},
email: {
type: String,
required: "Your email is required",
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: "Your password is required",
select: false,
max: 25,
},
role: {
type: String,
required: true,
default: "0x01",
},
},
{ timestamps: true }
);
UserSchema.pre("save", function (next) {
const user = this;
if (!user.isModified("password")) return next();
bcrypt.genSalt(10, (err, salt) => {
if (err) return next(err);
bcrypt.hash(user.password, salt, (err, hash) => {
if (err) return next(err);
user.password = hash;
next();
});
});
});
export default mongoose.model("users", UserSchema);
validationResult
from express-validator. The validationResult returns an array of errors, if any.
File Path- v1/middleware/validate.js
import { validationResult } from "express-validator";
const Validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
let error = {};
errors.array().map((err) => (error[err.param] = err.msg));
return res.status(422).json({ error });
}
next();
};
export default Validate;
Create a Registration logic
To create an auth logic that handles user registration, complete the following tasks,
File Path- v1/controllers/auth.js
import User from "../models/User.js";
/**
* @route POST v1/auth/register
* @desc Registers a user
* @access Public
*/
export async function Register(req, res) {
// get required variables from request body
// using es6 object destructing
const { first_name, last_name, email, password } = req.body;
try {
// create an instance of a user
const newUser = new User({
first_name,
last_name,
email,
password,
});
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser)
return res.status(400).json({
status: "failed",
data: [],
message: "It seems you already have an account, please log in instead.",
});
const savedUser = await newUser.save(); // save new user into the database
const { role, ...user_data } = savedUser._doc;
res.status(200).json({
status: "success",
data: [user_data],
message:
"Thank you for registering with us. Your account has been successfully created.",
});
} catch (err) {
res.status(500).json({
status: "error",
code: 500,
data: [],
message: "Internal Server Error",
});
}
res.end();
}
Add a Registration route
File Path- v1/routes/auth.js
import express from "express";
import { Register } from "../controllers/auth.js";
import Validate from "../middleware/validate.js";
import { check } from "express-validator";
const router = express.Router();
// Register route -- POST request
router.post(
"/register",
check("email")
.isEmail()
.withMessage("Enter a valid email address")
.normalizeEmail(),
check("first_name")
.not()
.isEmpty()
.withMessage("You first name is required")
.trim()
.escape(),
check("last_name")
.not()
.isEmpty()
.withMessage("You last name is required")
.trim()
.escape(),
check("password")
.notEmpty()
.isLength({ min: 8 })
.withMessage("Must be at least 8 chars long"),
Validate,
Register
);
export default router;
Now, head to your routes folder one more time, and inside the index.js
file, do the following,
import Auth from './auth.js';
use()
method on the app object and pass a defined route pathapp.use('/v1/auth', Auth);
Send a POST request with email, first name, last name, and password to
File Path- v1/controllers/auth.js
...
const savedUser = await newUser.save(); // save new user into the database
const { password, role, ...user_data } = savedUser; // Return user's details but password
res.status(200).json({
status: 'success',
data: [user_data],
message:
'Thank you for registering with us. Your account has been successfully created.',
});
...
Next, you will learn how to write the login logic for your app.
Create a Simple Login logic
File Path- v1/controllers/auth.js
import bcrypt from "bcrypt";
/**
* @route POST v1/auth/login
* @desc logs in a user
* @access Public
*/
export async function Login(req, res) {
// Get variables for the login process
const { email } = req.body;
try {
// Check if user exists
const user = await User.findOne({ email }).select("+password");
if (!user)
return res.status(401).json({
status: "failed",
data: [],
message:
"Invalid email or password. Please try again with the correct credentials.",
});
// if user exists
// validate password
const isPasswordValid = await bcrypt.compare(
`${req.body.password}`,
user.password
);
// if not valid, return unathorized response
if (!isPasswordValid)
return res.status(401).json({
status: "failed",
data: [],
message:
"Invalid email or password. Please try again with the correct credentials.",
});
// return user info except password
const { password, ...user_data } = user._doc;
res.status(200).json({
status: "success",
data: [user_data],
message: "You have successfully logged in.",
});
} catch (err) {
res.status(500).json({
status: "error",
code: 500,
data: [],
message: "Internal Server Error",
});
}
res.end();
}
By default, mongoose returns the user’s password anytime a query is made on that document, but I have disabled that inside the User.js model by using
‘select: false’
. So that I only need to call for the password when I need it. In your login logic, you need the user’s password in your database to compare it with the password the user is attempting to log in with. If both are the same, you can say that you know that user and allow him in.
Create a Login route
Inside your routes’ auth.js file,
File Path- v1/routes/auth.js
// Login route == POST request
router.post(
"/login",
check("email")
.isEmail()
.withMessage("Enter a valid email address")
.normalizeEmail(),
check("password").not().isEmpty(),
Validate,
Login
);
Send a POST request to
Add Session to Login logic
So far you can log users into your app. But wait, will you have to re-authenticate a user on every request? HTTP is stateless, so the server treats each request as a new one. To solve this, you have to look for a way to let the server know that a particular request is coming from the same client. Fortunately, the request headers come to the rescue. The request headers contain information about the client or resources being requested.
can serve different purposes. They can be used for authentication. Cookies also allow you to specify the duration of the authentication.
are an open, industry-standard RFC 7519 method for representing claims securely between two parties.
Create a secret key
The JWT technology allows you to generate, decode, and verify tokens. It digitally signs tokens so that they can be trusted. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. You may learn more about JWTs
Type node
in your terminal, press the return key,
Type crypto.randomBytes(20).toString(‘hex’)
Create a function to generate tokens at login
File Path- v1/models/User.js
...
import jwt from 'jsonwebtoken';
import { SECRET_ACCESS_TOKEN } from '../config/index.js';
...
...
UserSchema.methods.generateAccessJWT = function () {
let payload = {
id: this._id,
};
return jwt.sign(payload, SECRET_ACCESS_TOKEN, {
expiresIn: '20m',
});
};
Now you have to use the generateAccessJWT
function (which is now one of the UserSchema methods) in the login function.
Add the generate-token function to the login logic
Once, a user provides an email and password, and the API confirms the details to be correct, we will then generate a unique token for that user, and send the token generated to the client.
File Path- v1/controllers/auth.js
/**
* @route POST v1/auth/login
* @desc logs in a user
* @access Public
*/
export async function Login(req, res) {
// Get variables for the login process
const { email } = req.body;
try {
// Check if user exists
const user = await User.findOne({ email }).select("+password");
if (!user)
return res.status(401).json({
status: "failed",
data: [],
message: "Account does not exist",
});
// if user exists
// validate password
const isPasswordValid = await bcrypt.compare(
`${req.body.password}`,
user.password
);
// if not valid, return unathorized response
if (!isPasswordValid)
return res.status(401).json({
status: "failed",
data: [],
message:
"Invalid email or password. Please try again with the correct credentials.",
});
let options = {
maxAge: 20 * 60 * 1000, // would expire in 20minutes
httpOnly: true, // The cookie is only accessible by the web server
secure: true,
sameSite: "None",
};
const token = user.generateAccessJWT(); // generate session token for user
res.cookie("SessionID", token, options); // set the token to response header, so that the client sends it back on each subsequent request
res.status(200).json({
status: "success",
message: "You have successfully logged in.",
});
} catch (err) {
res.status(500).json({
status: "error",
code: 500,
data: [],
message: "Internal Server Error",
});
}
res.end();
}
Next, you want to add middleware functions to tell your API to verify the session and verify role or access.
Verify Session
Your API will have to determine if a session is valid or not. For security purposes, it must do that on every request to a protected route.
Examine the following code:
import User from "../models/User.js";
import jwt from "jsonwebtoken";
export async function Verify(req, res, next) {
try {
const authHeader = req.headers["cookie"]; // get the session cookie from request header
if (!authHeader) return res.sendStatus(401); // if there is no cookie from request header, send an unauthorized response.
const cookie = authHeader.split("=")[1]; // If there is, split the cookie string to get the actual jwt
// Verify using jwt to see if token has been tampered with or if it has expired.
// that's like checking the integrity of the cookie
jwt.verify(cookie, config.SECRET_ACCESS_TOKEN, async (err, decoded) => {
if (err) {
// if token has been altered or has expired, return an unauthorized error
return res
.status(401)
.json({ message: "This session has expired. Please login" });
}
const { id } = decoded; // get user id from the decoded token
const user = await User.findById(id); // find user by that `id`
const { password, ...data } = user._doc; // return user object without the password
req.user = data; // put the data object into req.user
next();
});
} catch (err) {
res.status(500).json({
status: "error",
code: 500,
data: [],
message: "Internal Server Error",
});
}
}
File Path- v1/routes/index.js
app.get("/v1/user", Verify, (req, res) => {
res.status(200).json({
status: "success",
message: "Welcome to the your Dashboard!",
});
});
File Path- v1/middleware/verify.js
export function VerifyRole(req, res, next) {
try {
const user = req.user; // we have access to the user object from the request
const { role } = user; // extract the user role
// check if user has no advance privileges
// return an unathorized response
if (role !== "0x88") {
return res.status(401).json({
status: "failed",
message: "You are not authorized to view this page.",
});
}
next(); // continue to the next middleware or function
} catch (err) {
res.status(500).json({
status: "error",
code: 500,
data: [],
message: "Internal Server Error",
});
}
}
File Path- v1/routes/index.js
import { Verify, VerifyRole } from "../middleware/verify.js";
app.get("/v1/admin", Verify, VerifyRole, (req, res) => {
res.status(200).json({
status: "success",
message: "Welcome to the Admin portal!",
});
});
Notice, that the preceding code includes the Verify middleware, then the VerifyRole middleware. The API first verifies the user's session and returns a user object which is accessed by the req.user
object. The VerifyRole middleware checks the user object to determine if the user is an admin or not. If a user is an admin, the admin portal is opened, else the user is not allowed to view the page.
You got an unauthorized response.
Why? The cookie for a user with low privilege can’t access the admin portal.
File Path- v1/models/Blacklist.js
import mongoose from "mongoose";
const BlacklistSchema = new mongoose.Schema(
{
token: {
type: String,
required: true,
ref: "User",
},
},
{ timestamps: true }
);
export default mongoose.model("blacklist", BlacklistSchema);
File Path- v1/controllers/auth.js
...
import Blacklist from '../models/Blacklist.js';
...
/**
* @route POST /auth/logout
* @desc Logout user
* @access Public
*/
export async function Logout(req, res) {
try {
const authHeader = req.headers['cookie']; // get the session cookie from request header
if (!authHeader) return res.sendStatus(204); // No content
const cookie = authHeader.split('=')[1]; // If there is, split the cookie string to get the actual jwt token
const accessToken = cookie.split(';')[0];
const checkIfBlacklisted = await Blacklist.findOne({ token: accessToken }); // Check if that token is blacklisted
// if true, send a no content response.
if (checkIfBlacklisted) return res.sendStatus(204);
// otherwise blacklist token
const newBlacklist = new Blacklist({
token: accessToken,
});
await newBlacklist.save();
// Also clear request cookie on client
res.setHeader('Clear-Site-Data', '"cookies"');
res.status(200).json({ message: 'You are logged out!' });
} catch (err) {
res.status(500).json({
status: 'error',
message: 'Internal Server Error',
});
}
res.end();
}
Compare your code with the following:
export async function Verify(req, res, next) {
const authHeader = req.headers["cookie"]; // get the session cookie from request header
if (!authHeader) return res.sendStatus(401); // if there is no cookie from request header, send an unauthorized response.
const cookie = authHeader.split("=")[1]; // If there is, split the cookie string to get the actual jwt token
const accessToken = cookie.split(";")[0];
const checkIfBlacklisted = await Blacklist.findOne({ token: accessToken }); // Check if that token is blacklisted
// if true, send an unathorized message, asking for a re-authentication.
if (checkIfBlacklisted)
return res
.status(401)
.json({ message: "This session has expired. Please login" });
// if token has not been blacklisted, verify with jwt to see if it has been tampered with or not.
// that's like checking the integrity of the accessToken
jwt.verify(accessToken, SECRET_ACCESS_TOKEN, async (err, decoded) => {
if (err) {
// if token has been altered, return a forbidden error
return res
.status(401)
.json({ message: "This session has expired. Please login" });
}
const { id } = decoded; // get user id from the decoded token
const user = await User.findById(id); // find user by that `id`
const { password, ...data } = user._doc; // return user object but the password
req.user = data; // put the data object into req.user
next();
});
}
Don’t forget to add the logout function to auth route, like so,
File Path- v1/routes/auth.js
...
// Logout route ==
router.get('/logout', Logout);
If you don't like seeing JWT in cookies, you can encrypt it or use express-session as an alternative to ordinary JWT.