Phase 5: Validating the Request Body

← Back to Main Tutorial

Understanding the Problem

In the previous phase, we implemented the core authentication routes for our application. However, these routes currently accept any data provided in the request body without validation. This can lead to several issues:

To address these issues, we need to implement request body validation for our authentication routes, particularly for login and signup operations where user input is critical.

Think of validation as a security guard at the entrance of a building. The guard checks everyone's ID and ensures they meet certain criteria before letting them in. Similarly, validation middleware examines incoming requests and ensures they contain valid data before letting them reach our route handlers.

Planning the Solution

We'll use the express-validator package to validate request bodies in our application. This package provides middleware functions that check request data against specified rules and collect validation errors.

Our validation plan includes:

  1. Installing the express-validator package
  2. Creating a reusable validation error handler
  3. Implementing login validation
  4. Implementing signup validation
  5. Testing both validations

For each validation implementation, we'll follow our feature-branch Git workflow to keep our code organized and easier to test.

Creating Validation Middleware

Setting Up the Feature Branch

Let's start by creating a new feature branch for the login validation:

git checkout dev
git checkout -b validate-login-inputs

Installing Dependencies

First, we need to install the express-validator package:

cd backend
npm install express-validator

Creating a Validation Utility

Now, let's create a utility file to handle validation errors. Create a new file at backend/utils/validation.js:

// backend/utils/validation.js
const { validationResult } = require('express-validator');

// middleware for formatting errors from express-validator middleware
// (to customize, see express-validator's documentation)
const handleValidationErrors = (req, _res, next) => {
  const validationErrors = validationResult(req);

  if (!validationErrors.isEmpty()) { 
    const errors = {};
    validationErrors
      .array()
      .forEach(error => errors[error.path] = error.msg);

    const err = Error("Bad request.");
    err.errors = errors;
    err.status = 400;
    err.title = "Bad request.";
    next(err);
  }
  next();
};

module.exports = {
  handleValidationErrors
};

This utility function does the following:

  1. Uses validationResult to gather validation errors from previous middleware.
  2. Checks if there are any validation errors.
  3. If errors exist, it formats them into a user-friendly object where keys are field names and values are error messages.
  4. Creates a standardized error object with a 400 status code (Bad Request).
  5. Passes the error to the next error-handling middleware.
  6. If no errors exist, it simply calls next() to proceed to the next middleware or route handler.

This pattern of separating the error-handling logic into a dedicated function follows the principle of separation of concerns, making our code more modular and easier to maintain.

Implementing Login Validation

Creating Login Validation Middleware

Next, let's modify the backend/routes/api/session.js file to add validation for the login route:

// backend/routes/api/session.js
// ... existing imports
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');

// ... existing code

const validateLogin = [
  check('credential')
    .exists({ checkFalsy: true })
    .notEmpty()
    .withMessage('Please provide a valid email or username.'),
  check('password')
    .exists({ checkFalsy: true })
    .withMessage('Please provide a password.'),
  handleValidationErrors
];

// Log in
router.post(
  '/',
  validateLogin,
  async (req, res, next) => {
    // ... existing login route handler
  }
);

// ... rest of the file

Let's break down the validation middleware we've created:

This validation approach is similar to how form validation works in the real world. When you fill out a paper form, there might be requirements for each field (e.g., "must be filled in," "must be a valid email format"). If you submit the form without meeting these requirements, it gets returned to you with error messages. Our validation middleware functions in the same way, checking each field against requirements and returning errors if needed.

Testing Login Validation

Let's test our login validation to ensure it's working correctly. Start your server if it's not already running:

npm start

First, test with an empty credential:

fetch('/api/session', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({ credential: '', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));

You should receive a "Bad request" error with a message about providing a valid email or username.

Next, test with an empty password:

fetch('/api/session', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({ credential: 'Demo-lition', password: '' })
}).then(res => res.json()).then(data => console.log(data));

You should receive a "Bad request" error with a message about providing a password.

Committing and Merging the Login Validation Branch

Once you've confirmed that the login validation is working, commit your changes and merge them into the dev branch:

git add .
git commit -m "Add User input validation on user login backend endpoint"
git checkout dev
git pull origin dev
git merge validate-login-inputs
git push origin dev

Implementing Signup Validation

Setting Up the Feature Branch

Let's create a new feature branch for the signup validation:

git checkout dev
git checkout -b validate-signup-inputs

Creating Signup Validation Middleware

Now, let's modify the backend/routes/api/users.js file to add validation for the signup route:

// backend/routes/api/users.js
// ... existing imports
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');

// ... existing code

const validateSignup = [
  check('email')
    .exists({ checkFalsy: true })
    .isEmail()
    .withMessage('Please provide a valid email.'),
  check('username')
    .exists({ checkFalsy: true })
    .isLength({ min: 4 })
    .withMessage('Please provide a username with at least 4 characters.'),
  check('username')
    .not()
    .isEmail()
    .withMessage('Username cannot be an email.'),
  check('password')
    .exists({ checkFalsy: true })
    .isLength({ min: 6 })
    .withMessage('Password must be 6 characters or more.'),
  handleValidationErrors
];

// Sign up
router.post(
  '/',
  validateSignup,
  async (req, res) => {
    // ... existing signup route handler
  }
);

// ... rest of the file

Let's break down the validation middleware we've created for signup:

These validation rules represent best practices for user registration. For example, requiring a minimum password length helps ensure security, validating email formats helps ensure communications can be delivered, and preventing usernames from being emails helps maintain a clear distinction between these identifiers.

Testing Signup Validation

Let's test our signup validation to ensure it's working correctly:

Test with an empty password:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'firestar@spider.man',
    username: 'Firestar',
    password: ''
  })
}).then(res => res.json()).then(data => console.log(data));

You should receive a "Bad request" error with a message about the password being too short.

Test with an invalid email format:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'not-an-email',
    username: 'Firestar',
    password: 'password'
  })
}).then(res => res.json()).then(data => console.log(data));

You should receive a "Bad request" error with a message about providing a valid email.

Test with a short username:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'firestar@spider.man',
    username: 'Fir',
    password: 'password'
  })
}).then(res => res.json()).then(data => console.log(data));

You should receive a "Bad request" error with a message about providing a username with at least 4 characters.

Test with a username that's an email:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'firestar@spider.man',
    username: 'user@example.com',
    password: 'password'
  })
}).then(res => res.json()).then(data => console.log(data));

You should receive a "Bad request" error with a message about the username not being an email.

Committing and Merging the Signup Validation Branch

Once you've confirmed that the signup validation is working, commit your changes and merge them into the dev branch:

git add .
git commit -m "Add User input validation on user signup backend endpoint"
git checkout dev
git pull origin dev
git merge validate-signup-inputs
git push origin dev

Understanding Request Validation

The Importance of Validation

Request validation is a critical aspect of web application security and user experience. Here's why it matters:

  1. Security: Validation helps prevent injection attacks and other security vulnerabilities by ensuring that inputs conform to expected formats.
  2. Data Integrity: It ensures that the data stored in your database is consistent and valid, preventing corruption or inconsistencies.
  3. User Experience: By validating inputs early and providing clear error messages, you help users understand and correct their mistakes quickly.
  4. Server Efficiency: It's more efficient to validate inputs before performing database operations or complex processing.

Validation Layers

In a robust application, validation typically occurs at multiple layers:

  1. Client-side validation: Provides immediate feedback to users and improves the user experience.
  2. API validation: Ensures that all incoming requests meet your requirements, regardless of the client (which we've implemented in this phase).
  3. Database constraints: Provides a final layer of protection for your data.

By implementing validation at each layer, you create a defense-in-depth approach that ensures data quality and security.

Real-world Examples

Request validation is used extensively in real-world applications:

In each case, validation helps protect both the application and its users from errors, security issues, and poor user experiences.

Enhancing the User Model

Adding firstName and lastName Attributes

Now that we have validation in place, let's enhance our User model by adding firstName and lastName attributes. This requires changes to our migration files, model definitions, and route handlers.

Updating the Migration File

First, let's create a new migration to add these columns to the Users table:

npx sequelize migration:generate --name add-first-last-name-to-users

Edit the newly created migration file to add the new columns:

// backend/db/migrations/XXXXXXXXXXXXXX-add-first-last-name-to-users.js
'use strict';

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA;  // define your schema in options object
}

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.addColumn('Users', 'firstName', {
      type: Sequelize.STRING,
      allowNull: true
    }, options);
    
    await queryInterface.addColumn('Users', 'lastName', {
      type: Sequelize.STRING,
      allowNull: true
    }, options);
  },

  async down(queryInterface, Sequelize) {
    options.tableName = "Users";
    await queryInterface.removeColumn(options, 'firstName');
    await queryInterface.removeColumn(options, 'lastName');
  }
};

Run the migration to add these columns to the database:

npx dotenv sequelize db:migrate

Updating the User Model

Now, let's update the User model to include these new attributes:

// backend/db/models/user.js
'use strict';

const { Model, Validator } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class User extends Model {
    static associate(models) {
      // define association here
    }
  }

  User.init(
    {
      username: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: true,
        validate: {
          len: [4, 30],
          isNotEmail(value) {
            if (Validator.isEmail(value)) {
              throw new Error('Cannot be an email.');
            }
          },
        },
      },
      email: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: true,
        validate: {
          len: [3, 256],
          isEmail: true,
        },
      },
      hashedPassword: {
        type: DataTypes.STRING.BINARY,
        allowNull: false,
        validate: {
          len: [60, 60],
        },
      },
      firstName: {
        type: DataTypes.STRING,
        allowNull: true,
      },
      lastName: {
        type: DataTypes.STRING,
        allowNull: true,
      },
    },
    {
      sequelize,
      modelName: 'User',
      defaultScope: {
        attributes: {
          exclude: ['hashedPassword', 'createdAt', 'updatedAt'],
        },
      },
    }
  );
  return User;
};

Updating the Route Handlers

Finally, let's update our route handlers to include these new attributes. First, update the signup route in backend/routes/api/users.js:

// backend/routes/api/users.js
// ... existing code

// Sign up
router.post(
  '/',
  validateSignup,
  async (req, res) => {
    const { email, password, username, firstName, lastName } = req.body;
    const hashedPassword = bcrypt.hashSync(password);
    const user = await User.create({ 
      email, 
      username, 
      hashedPassword,
      firstName,
      lastName
    });

    const safeUser = {
      id: user.id,
      email: user.email,
      username: user.username,
      firstName: user.firstName,
      lastName: user.lastName
    };

    await setTokenCookie(res, safeUser);

    return res.json({
      user: safeUser
    });
  }
);

// ... rest of the file

Next, update the login route in backend/routes/api/session.js:

// backend/routes/api/session.js
// ... existing code

// Log in
router.post(
  '/',
  validateLogin,
  async (req, res, next) => {
    // ... existing code to find the user

    const safeUser = {
      id: user.id,
      email: user.email,
      username: user.username,
      firstName: user.firstName,
      lastName: user.lastName
    };

    await setTokenCookie(res, safeUser);

    return res.json({
      user: safeUser
    });
  }
);

// ... existing code

// Restore session user
router.get(
  '/',
  (req, res) => {
    const { user } = req;
    if (user) {
      const safeUser = {
        id: user.id,
        email: user.email,
        username: user.username,
        firstName: user.firstName,
        lastName: user.lastName
      };
      return res.json({
        user: safeUser
      });
    } else return res.json({ user: null });
  }
);

// ... rest of the file

Remember to also update the setTokenCookie function in backend/utils/auth.js to include these new attributes in the JWT payload:

// backend/utils/auth.js
// ... existing code

// Sends a JWT Cookie
const setTokenCookie = (res, user) => {
  // Create the token.
  const safeUser = {
    id: user.id,
    email: user.email,
    username: user.username,
    firstName: user.firstName,
    lastName: user.lastName
  };
  
  // ... rest of the function
};

// ... rest of the file

Testing the Changes

Let's test our changes to ensure they're working correctly. First, try signing up a new user with first and last names:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'thor@avengers.com',
    username: 'ThunderGod',
    password: 'mjolnir',
    firstName: 'Thor',
    lastName: 'Odinson'
  })
}).then(res => res.json()).then(data => console.log(data));

Then, check if you can retrieve the user's information including the first and last names:

fetch('/api/session')
  .then(res => res.json())
  .then(data => console.log(data));

You should see the user's information including the firstName and lastName attributes.

Wrapping Up the Backend

Congratulations! You've now completed the backend implementation of your authentication system. Let's summarize what you've accomplished:

Your backend now provides a solid foundation for building the frontend of your application. It includes all the necessary endpoints for user authentication and incorporates best practices for security and data validation.

Next Steps

With the backend complete, here are some possible next steps:

Remember to keep the POST /api/test route in your backend/routes/api/index.js file for now. You'll use it later when setting up your frontend.

Final Thoughts

Building a robust authentication system is a significant accomplishment. The concepts and patterns you've learned in this tutorial are widely used in professional web development and will serve as a foundation for more complex applications in the future.

Remember that authentication is just one aspect of application security. As you continue to develop your application, be mindful of other security considerations such as authorization (controlling what authenticated users can do), data protection, and secure communication.