DEV Community

Cover image for BUILD A LOGIN AND LOGOUT API USING EXPRESSJS (NODEJS)
Joshua M
Joshua M

Posted on • Updated on

BUILD A LOGIN AND LOGOUT API USING EXPRESSJS (NODEJS)

One of the first challenge I met when l started my first project on backend was actually the authentication and authorization system. I came from a background of cybersecurity and networking. Therefore, when I decided to jump back to building I was looking for a way to do a simple yet secured login and logout system. Thank God for the tech community and platform such as StackOverflow from which I was exposed.

In this article, I am leveraging on my previous challenge and breakthrough to expose newbies to the basics of authentication and authorization using ExpressJS. I have chosen ExpressJS for this tutorial because it is flexible and has an easy learning curve.

Before we dive into practical, let’s review some theory. Doesn’t sound good to you? Yes, I am a fan of practical too. However, let’s define few concepts.

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:

  1. What a person knows (password or passphrase).
  2. What a person has (one-time token or physical device).
  3. What a person is (biometrics, fingerprint reader, facial recognition).

Authorization follows authentication, it is ensuring 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 see any 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 https://learn.microsoft.com/en-us/aspnet/web-api/overview/security/authentication-and-authorization-in-aspnet-web-api

That’s enough theory to get us started.
Prerequisites:

  • Basic knowledge of ExpressJS
  • Basic knowledge of Mongoose or MongoDB
  • Basic knowledge of Json Web Tokens (JWT)
  • Basic knowledge of how cookies work

Create a folder for your project and open it with your favorite code editor, I will be using VSCode. Assuming you have node installed on your machine, in your terminal, enter npm init. Respond appropriately to the prompts. Make sure you add a dev and/or start command to enable you start your API. Also, add "type": "modules" to your package.json file, so as to use import statements rather than require. You can skip this part if you already know or clone the starter files https://github.com/mjosh51/authentication-authorization-expressjs-tutorial-starterfiles

After that, go ahead and install these packages,
npm install express mongoose bcrypt cookie-parser dotenv cors express-validator
Install jwt npm i jsonwebtoken
Install nodemon as dev dependency, npm install --save-dev nodemon.
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 made changes. Another powerful tool used mostly in production is pm2. You can check it out https://pm2.keymetrics.io/.

Now you have installed the necessary tools for the tutorial.

A quick one

ExpressJS is a minimal and flexible Node.js web application framework that provides a robust set of features for web and mobile applications. https://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. https://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 password.
Cookie-parser is a middleware used to parse 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 sanitizer functions. You may want to check for empty request body or validate or even sanitize request body. This package is very useful for that. You will add one or two of its functions into your code later.

Having understood to this point, make sure you have your URI string ready from MongoDB or your database of choice. I am using Mongoose in this particular tutorial. Create an account on MongoDB - https://www.mongodb.com/docs/atlas/ and create a database. Once you have created a database, it should look like this,

Database created
Click connect, then connect your application, copy the connection string into your .env file as the value for URI.

Now, you should start coding.

First, you should write an index or home route, which you can test to see that your API is working. To do that, create a file inside your routes’ folder, name it index.js, after that copy and paste the following code into it. Check the github repository for the starter files to confirm that you have same number of folders. Note: You may ignore the utils folder for now.

v1/routes/index.js

import express from 'express';

const app = express();

app.disable('x-powered-by'); //Reduce fingerprinting
app.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 app;

Enter fullscreen mode Exit fullscreen mode

Now, head to your config folder, inside it, create a new file and name it index.js, copy and paste the following there, then save.

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 };
Enter fullscreen mode Exit fullscreen mode

Grab the following code into your server.js file,

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 App from './routes/index.js';

// === 1 - CREATE SERVER ===
const server = express();
// 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 - CREATE 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 ===
// Configure Route
server.use(App);

// === 5 - START UP SERVER ===
server.listen(PORT, () =>
  console.log(`Server running on http://localhost:${PORT}`),
);
Enter fullscreen mode Exit fullscreen mode

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,
server startedUsing Postman or your favorite API testing platform, send a GET request to http://localhost:5005/v1. I am using REST Client extension for VSCode. If everything goes well, you should get a similar response like in the image below,
Server homepageMoving to our main goal – Authentication – Authorization
First, create your user model, you will want users to signup first so as to have their credentials stored into your database. Thereafter, you can authenticate who they say they are using those credentials supplied at registration. If they decide to change any of their details later then you will have to not only authenticate them but also authorized them to do so. This you should do bearing in mind the safety of your users and integrity of your business or API.

Create a file with the name User.js inside your models folder. Get the following code into it,

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);
Enter fullscreen mode Exit fullscreen mode

So, you added a user model and wrote a function that hashes user’s password before saving them into the database. With that you can go ahead to write the registration logic. To do that, you have created a folder name controllers to handle your app logic. Create a file inside it and name it auth.js, copy and paste the following code into it,

v1/controllers/auth.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 } = req.body;
  try {
    // create an instance of a user
    const newUser = new User({
      first_name,
      last_name,
      email,
      password: req.body.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 { password, 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();
}
Enter fullscreen mode Exit fullscreen mode

So simple right? Yes, that’s it.
Head to your routes folder, you need to include the registration route. Create a new file there and name it auth.js. After that, copy and paste the following code into it,

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;
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, you have used the express-validator functions to check if a user is truly sending the required request body. One more thing you will notice is the Validate function. You need to create that for the other express-validator functions to work. Inside your middleware folder, create a file and name it validate.js, grab the code below into it,

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;
Enter fullscreen mode Exit fullscreen mode

Now, head to your routes folder one more time, and inside index.js file, add the following code,
import Auth from './auth.js';
Then add
app.use('/v1/auth', Auth);
below the previous code.
Test it by sending a POST request to http://localhost:5005/v1/auth/register

signup responseYour API has successfully registered a user. If you don’t want to return the password, you can adjust your auth.js file (controller) –

const savedUser = await newUser.save(); // save new user into the database
    const { password, ...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.',
    });
Enter fullscreen mode Exit fullscreen mode

Now, assuming your user wants to login, you will want to authenticate him/her, to do that, add the following code into your auth.js file (controller),

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: 'Account does not exist',
      });
    // if user exists
    // validate password
    const isPasswordValid = 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();
}
Enter fullscreen mode Exit fullscreen mode

By default mongoose returns user’s password anytime a query is made on that document, but I have disabled that inside the User.js model by ‘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 login with. If both are the same, you can say that you know that user and allow him in. Inside your routes’ auth.js file, add,

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,
);
Enter fullscreen mode Exit fullscreen mode

Make sure you import the Login function.
Then, test your login functionality by sending a POST request to http://localhost:5005/v1/auth/login
login responseNow you can authenticate your users.

Let’s implement a simple authorization flow. I have created another user, called Admin. I will manually upgrade his role to an admin (an admin will have the code 0x88 for his role), and as you rightly guessed that is 136. You should use complex code for role assignment in production.
admin login responseYou will be adding some codes to make the authorization logic work as it ought to but let’s think for a while. What do we actually need? We need a way to know if a user is logged-in or not, and for what duration will he have access to resources. HTTP is stateless; it treats all requests as new. We want to tell our app to give access to a user for a period so far the user’s request has a signed token. For this, we will be using jwt, to help our server recognize users’ requests without necessary treating them as new on every request.
Json Web Token are an open, industry standard RFC 7519 method for representing claims securely between two parties.

Next, get a secured secret, which only your server knows to be able to correctly decode the token. You can type node in your terminal and then type crypto.randomBytes(20).toString(‘hex’)
Just make sure that you have a secured secret - that cannot be easily guessed. Put that value into your SECRET_ACCESS_TOKEN in your .env file.
For me, I have something like,
access tokenNow, add the following code to your User.js model,

import jwt from 'jsonwebtoken';
import { SECRET_ACCESS_TOKEN } from '../config/index.js';
Enter fullscreen mode Exit fullscreen mode

then, add this after the pre hook function,

UserSchema.methods.generateAccessJWT = function () {
  let payload = {
    id: this._id,
  };
  return jwt.sign(payload, SECRET_ACCESS_TOKEN, {
    expiresIn: '20m',
  });
};
Enter fullscreen mode Exit fullscreen mode

Now, inside your login logic (controller – auth.js), adjust it like so,

/**
 * @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 = 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();
}
Enter fullscreen mode Exit fullscreen mode

I have added some comments should you be a beginner. From the code above, you have instructed your API to generate access token for users on login and set a cookie header for subsequent request. So that, as long as the token and cookie remains valid, the user is not only authenticated but also have access accordingly.

Next, you want to add middleware functions to tell your API to verify session and verify role or access. You will be adding some couple of codes, explanation will follow. Create a file inside middleware folder and name it verify.js, grab the code below and paste it there,

v1/middleware/verify.js

import User from '../models/User.js';
import jwt from 'jsonwebtoken';
import Blacklist from '../models/Blacklist.js';

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 checkIfBlacklisted = await Blacklist.findOne({ token: cookie }); // 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 re-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 cookie
  jwt.verify(cookie, config.SECRET_ACCESS_TOKEN, async (err, decoded) => {
    if (err) {
      // if token has been altered, return a forbidden error
      return res.sendStatus(403);
    }

    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();
  });
}

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.',
      });
    }
    next(); // continue to the next middleware or function
  } catch (err) {
    res.status(500).json({
      status: 'error',
      code: 500,
      data: [],
      message: 'Internal Server Error',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Add inside your index.js file (routes),

app.get('/v1/admin', Verify, VerifyRole, (req, res) => {
  res.status(200).json({
    status: 'success',
    message: 'Welcome to the Admin portal!',
  });
});
Enter fullscreen mode Exit fullscreen mode

Don’t forget to import Verify and VerifyRole functions from the middleware folder.
Now, login using a user with low privilege,
loginCopy the cookie from response headers and paste into your next request header, like so,
unauthorizedSend a GET request to /v1/admin with that cookie, you should see an unauthorized response. Why? The cookie representing that user with low privilege can’t access the admin portal. Try to login using an admin credentials, copy the cookie as at first, paste into the request header,
adminand send a GET request to the admin route /v1/admin, if everything goes well, you should see a Welcome message.
admin portalNow you know how authentication and authorization works in Express API.

Finally, you want to add a logout functionality. For that, you have two basic options. One is to blacklist request cookie on logout, the other is to invalidate the cookie by sending an invalid cookie to the client. The latter is not advisable because if the previous cookie was kept somewhere before logout, it can still be used to login. Here, you will be implementing the first.

Noticed, you have created another file inside models folder named Blacklist.js, that is the document that will store any blacklisted token.

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);
Enter fullscreen mode Exit fullscreen mode

So this is it - on logout, user’s token will be blacklisted i.e. added to the blacklist document. Now, whenever the user comes back to access protected routes, your API first checks to see if his token is on the blacklist or not, if it has been, he gets an unauthorized response, otherwise he given allowed access. One more thing you can do is to look for a way to clear the cookie from the client. Here is a simple code, add it to your auth.js file (controllers).

/**
 * @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", "storage"');
    res.status(200).json({ message: 'You are logged out!' });
  } catch (err) {
    res.status(500).json({
      status: 'error',
      message: 'Internal Server Error',
    });
  }
  res.end();
}
Enter fullscreen mode Exit fullscreen mode

You should import Blacklist from models/Blacklist.js
The code above checks if there is a request cookie, if true, cookie is blacklisted. In addition, it clears cookies or any other parameter set by the server such as storage. Don’t forget to add the function to auth route, like so,

// Logout route ==
router.get('/logout', Logout);
Enter fullscreen mode Exit fullscreen mode

Additionally, if seeing jwt in cookies irritates you like myself, you can encrypt it or use express-session as an alternative to jwt.

There you have your fully functional authentication and authorization system. Thank you for reading, and don’t forget to follow me for more.

Latest comments (0)