#100DaysOfMERN - Day 39

#100DaysOfMERN - Day 39

·

10 min read

✏ How to add a Login Function to your App (1/4)

In order to allow users to create an account, first of all your application needs a backend, and, unless you want to store the user data in a file on the server, a connection to a database.

I'll include the starter code for the server and database connection below, but this article assumes that you're already familiar with:

  • setting up a server with Node/Express

  • connecting to MongoDB

If you're not yet comfortable with the above, I invite you to read some of my previous posts:


✏ Starter Code

There's not much to say about the frontend, it's an App component rendering a Login component with a form, and within two inputs for email/password and a login/register button. It's not even necessary to have a frontend, the functionality can easily be tested with tools like Postman, so I won't clutter up this post with frontend code but concentrate on the parts that are essential for processing a Login function on the backend.

Backend

I'll start with the folder structure and the package.json so you can see what's required, it's basically the same as in previous posts - express and cors for the server, mongoose and dotenv for the database (and hiding the connection string), and nodemon for development.

backend
|
|- db
|  |-connect.js
|
|- models
|  |-usermodel.js
|
|- .env
|- package-lock.json
|- package.json
|- server.js
{
    "name": "backend",
    "version": "1.0.0",
    "description": "",
    "main": "server.js",
    "scripts": {
        "start": "node server.js",
        "dev": "nodemon server.js"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
        "cors": "^2.8.5",
        "dotenv": "^8.2.0",
        "express": "^4.17.1",
        "mongoose": "^5.11.15"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    }
}

The server imports the database connection and the User model (see below), and has a so far nonfunctional route to /api/users/login:

server.js

const dotenv = require('dotenv');
dotenv.config();

const express = require('express');
const cors = require('cors');
const connect = require('./db/connect.js');
const User = require('./models/usermodel.js');

connect();

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());

app.get('/ping', (req, res) => {
    res.send('API is running');
});

app.post('/api/users/login', (req, res) => {
    // login code
});

const PORT = process.env.PORT || 5000;

app.listen(PORT, () =>
    console.log(
        `server running in ${process.env.NODE_ENV} mode on port ${PORT}`
    )
);

The User model is as simple as it could be, I'm only storing an email and a password. To check the validity of the email, I'm using an npm package validator (additionally to HTML5 validation on the frontend).

npm i validator

The second item in the arrays where I define the options are my custom error messages:

models/usermodel.js

const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { isEmail } = require('validator');

const userSchema = new Schema(
    {
        email: {
            type: String,
            unique: true,
            required: [true, 'please enter an email address'],
            lowercase: true,
            validate: [isEmail, 'please enter a valid email']
        },
        name: String,
        password: {
            type: String,
            required: [true, 'please enter a password']
        }
    },
    { timestamps: true }
);

const User = mongoose.model('user', userSchema);

module.exports = User;

And the database connection:

db/connect.js

const mongoose = require('mongoose');

const connStr = process.env.MONGO_URI;

const connOptions = {
    useNewUrlParser: true,
    useUnifiedTopology: true,
    useFindAndModify: false,
    useCreateIndex: true
};

async function connect() {
    try {
        await mongoose.connect(connStr, connOptions);
        console.log('Connected to Database');
    } catch (error) {
        console.error(`Error in connect.js: ${error.message}`);
        process.exit(1);
    }
}

module.exports = connect;

✏ Creating some Dummy Data

In order to test the Login functionality, I'll first add a few records to the users collection. I could probably just enter some user objects like

{email: 'test@test.com', password: 'test1234'}

manually, using MongoDB Compass or the Shell, but I'd like to write a small script for this instead. The reason is that although this is dummy data, you would never store passwords as plain text in a database, and always encrypt them first.

I'm using a package bcryptjs for this (make sure to npm install it first), so after creating a file seedDB.js within the db folder: importing the package, also bringing in the database connection and the User model, and adding a small function to save the user to the database:

db/seedDB.js

const dotenv = require('dotenv');
dotenv.config();

const bcrypt = require('bcryptjs');
const connect = require('./connect.js');

const User = require('../models/usermodel.js');

const newUser = new User({
    email: 'test@test.com',
    password: bcrypt.hashSync('test1234')
});

async function seed() {
    await connect();
    await newUser.save();
    console.log('user saved to db');
    process.exit(0);
}

seed();

Now adding a script to the package.json:

    "scripts": {
        "start": "node server.js",
        "dev": "nodemon server.js",
        "seed": "node db/seedDB.js"
    },

And running it with:

npm run seed

That should add the user to the database, with the password now being encrypted to a 64 character string.


✏ Error Handling

Before I proceed, I'd like to set up some (basic) error handling middleware, to avoid getting unhandled rejection warnings.

What is Middleware?

Middleware is a function that has access to the request and response objects, and to a function commonly called next, to pass the request on to the next middleware. It acts as a layer between request and the (final) response - to decide what the response should be for a certain request. You can have a whole chain of middleware functions:

express-middleware.jpg

(image credit:Build and Understand Express Middleware)

You can write middleware that gets called on every request to the server (with app.use(yourMiddleware), or middleware for specific routes. For example, app.use(express.json()); is one of Express's own middlewares.

Error Handler Middleware

First of all, create a folder middleware, and within it, a file errorMiddleware.js. There'll be two functions in it, one to provide a fallback for a 404 Not Found error, and one to handle any errors that we might throw later in our authentication code. It will replace/override the default Express error handler at the end of the pipeline.

const notFoundHandler = (req, res, next) => {

    // include the URL of the request in the error message
    const error = new Error(`Not Found - ${req.originalUrl}`);

    res.status(404);

    // proceed to the next middleware, and pass the error
    next(error);
};

const errorHandler = (err, req, res, next) => {

    // if status code is 200 OK despite an error occuring, 
    // change status code to 500 Server Error
    const statusCode = res.statusCode === 200 ? 500 : res.statusCode;

    res.status(statusCode);

    // respond with a json object instead of the default status code message HTML
    // if the app is not in production, also include the stack trace
    res.json({
        message: err.message,
        stack: process.env.NODE_ENV === 'production' ? null : err.stack
    });

    next();
};

module.exports = { notFoundHandler, errorHandler };

To use these now, import them in the server file and initialise them below all routes:

server.js

const { notFoundHandler, errorHandler} = require('./middleware/errorMiddleware.js');

/* routes  */

app.use(notFoundHandler);
app.use(errorHandler);

const PORT = process.env.PORT || 5000;

app.listen(PORT, () =>
    console.log(
        `server running in ${process.env.NODE_ENV} mode on port ${PORT}`
    )
);

With that out of the way, let's proceed:


✏ User Authentication

Now that I have a record in my database, I can work on the login route to authenticate the user, which boils down to finding the user by email, and comparing the plain text password that comes from the POST request against the hashed password from the database. To do so, I'll use a method compare from bcryptjs, so importing that here too, and adding a function checkUser.

server.js

const bcrypt = require('bcryptjs');

app.post('/api/users/login', (req, res) => {

    const { email, password } = req.body;

    checkUser(email, password);


    async function checkUser(email, password) {
        try {
            const user = await User.findOne({ email });

            //  check if user exists in the database and compare password
            if (user && (await bcrypt.compare(password, user.password))) {
                // send back the users's id and a token placeholder
                res.json({
                    _id: user._id,
                    token: null
                });

            } else {
                res.status(401);
                throw new Error('invalid email or password');
            }
        } catch (err) {
            next(err);
        }
    }
});

If the user exists and the .compare method returns true, the server will respond with the user's database id and a token for authorisation (which is currently set to null, we'll do that later). Otherwise, we'll set the response status to 401 Unauthorised and throw an error with an error message.


✏ Testing the route with Postman

If you test the route with Postman now by making a POST request to http://localhost:5000/api/users/login (or whatever your port/route is), and add a valid email and password in the request body, you can see in the response that the server answers with the user id. If we send invalid data, we get our 401 error. If we try a route that doesn't exist, we get a 404.

(If you haven't used Postman before, I have written a brief introduction on Day 24)


✏ User Authorisation

A login function generally consists of two steps:

  • authenticate the user by matching a unique identifier (email) against a password

  • authorise the user to visit protected routes on the website

I guess there's more than one way to authorise a user, but a common one is to send back a unique token, once they're successfully authenticated. The token can be used to verify that a request to the server comes from a logged-in, valid, identifiable user. If someone tries to access a protected route, the server will first check if the token is present in the request header.

An example for a protected route would be the profile page of a registered user, where they can edit personal data or change their settings. This page will/must only be accessible to a specific user, not to anyone who's logged in.

✏ JSON Web Tokens

JSON Web Tokens or JWT is an open standard to securely exchange information between two parties as a JSON object. If you're interested in reading more, the link gives a good introduction. Screenshot from their website:

json-webtoken.jpg

The left side is the encoded token that gets sent back to the user. It consists of three parts:

  • header: describes the name of the token (JWT) and which algorithm was used to encrypt it

  • payload: whatever we want our server to send back to the client, typically some unique identifier like their database id (but certainly not their credit card details as plain text, see below). The key iat is a timestamp for when the token was created. If we give the token an expiration date, there'll be another key exp with the corresponding timestamp.

  • signature: the signature is created by taking the encoded header, the encoded payload, and a secret string of our choice. This combination of all three elements makes sure that nobody can modify our token and "fake" their identity. The secret we use in our server code should therefore be kept hidden in an environment variable, and must never be exposed

IMPORTANT: Keep in mind that the payload of the token can be read by anyone. A token doesn't guarantee that the data stays secret, it is a guarantee that no one has tampered with the data while it was sent back and forth. If the payload contains sensitive information, it must itself be encrypted.

The database id of a user is less of a security risk. You cannot access the user's data in the database, because you'd need the connection string. You cannot fake a token with the id alone, because you'd need the token secret. Both are hidden in our environment variables.


That's it for part 1. In the next post, I'll continue with

  • user authorisation (generating and sending the token)

  • setting up protected routes with custom authorisation middleware

  • registering new users


✏ Resources

Most of this article closely follows a few chapters from a udemy course (MERN-ecommerce) by Brad Traversy.

Other resources:

Build and Understand Express Middleware

Introduction to JSON Web Tokens


✏ Recap

This post covered:

  • the setup for a Node/Express backend with connection to MongoDB

  • seeding the database with dummy data

  • password encryption with bcryptjs

  • Express middleware

  • creating custom error middleware

  • user authentication

  • JSON Web Tokens


✏ Thanks for reading!

I do my best to thoroughly research the things I learn, but if you find any errors or have additions, please leave a comment below, or @ me on Twitter. If you liked this post, I invite you to subscribe to my newsletter. Until next time 👋


✏ Previous Posts

You can find an overview of all previous posts with tags and tag search here:

#100DaysOfMERN - The App