Basic TypeScript Express Auth

Tutorials Oct 8, 2021

This tutorial will walk through creating a NodeJS REST server with Express and a basic but secure authentication system. The basic idea is to enable a user to create an account, sign in to an account and sign out of an account. We will also cover some extra security steps that you can use throughout your services such as rate limiting, sessions, password requirements and more!

I had an interview recently where the interviewer said that they don't handle user authentication internally and my first thought was "why?" it's not that hard to make a system that's reasonably secure for something like basic user authentication so I wanted to write an article explaining this and demonstrating how to do it. Hopefully this will encourage conversations within businesses that are not comfortable rolling their own authentication systems. Now if you're handling sensitive data like credit card information you should not be handling it yourself but you can offload that specific part to a third party service like Stripe that complies with standardization such as PCI Compliance.

Getting Started

First create your project directory mkdir Auth and then initialize a new NodeJS project npm init. Fill out the steps that it asks with your information and it will generate a package.json for us to use. Now we have to install some dependencies and configure TypeScript, TSLint to our needs.

# Install TypeScript & TSLint
npm i -D typescript tslint 

# Create empty config files
touch tslint.json
touch tsconfig.json

# Make source directory for our ts files
mkdir src

# Make our first source file
touch src/app.ts

# Make our .env file where we will store the database uri
touch .env

Open tslint.json in your editor and paste in this configuration (or use your own)

{
  "defaultSeverity": "error",
  "extends": ["tslint:recommended"],
  "jsRules": {},
  "rules": {
    "quotemark": false,
    "no-console": false,
    "trailing-comma": false,
    "no-trailing-whitespace": false,
    "space-before-function-paren": false
  },
  "rulesDirectory": []
}

This disables some annoying default settings like trailing comma and perfered use of double quotes. These may not be your preferences so adjust them accordingly.

Open tsconfig.json in your editor and paste in this configuration (or use your own)

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "target": "es6",
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist"
  },
  "lib": ["es2015"]
}

This is pretty standard, note that we're setting our output directory to dist/

Now adjust your package.json to update our main file to the compiled output at dist/app.js that's not yet generated.

"main": "dist/app.js",
"scripts": {
  "start": "tsc && node dist/app.js",
  "test": "echo \"Error: no test specified\" && exit 1"
},

Installing Dependencies

Now that we have a basic project setup let's install the dependencies we will use, you can do that in a couple commands below but here's an explanation of each library and what we're using it for.

  • dotenv So that we can store sensitive variables in a non-git tracked file .env
  • helmet Sets some good secure defaults for any ExpressJS service
  • express REST API library that we will use for our server
  • mongoose Database adapter so that we can easily setup and communicate with MongoDB
  • passport Library for user authentication, we won't interact with this directly
  • passport-local-mongoose A small and powerful library allowing us to easily setup authentication with Passport using MongoDB as our database this provides some really awesome and useful functions that we will use to make development much faster
  • express-rate-limit Rate limit endpoints, very important for secure endpoints like authentication endpoints so that we don't allow clients to spam signin requests trying to guess a password
  • rate-limit-mongo A MongoDB storage adapter for express-rate-limit so that we can store all information within MongoDB to make things easier
  • cookie-session Allows us to easily define a session for users when they login to track them across endpoints. There are many alternitives to this including a database storage alternative but for simplicity sake we will just use this
# Install Packages
npm i dotenv helmet express mongoose passport passport-local-mongoose express-rate-limit rate-limit-mongo cookie-session

# Install Type Definitions
npm i -D @types/express @types/passport @types/express-rate-limit 

Configuring Dependencies

First let's make some more files and directories that we will need later

# Make the folder for our MongoDB models
mkdir src/models

# Make the folder for our API routes
mkdir src/routes

# Make the folder for utility functions
mkdir src/utils

# Make default files
touch src/models/user.ts
touch src/routes/index.ts
touch src/routes/user.ts
touch src/utils/auth.ts

our directory structure should now look something like this

├── package-lock.json
├── package.json
├── src
│  ├── app.ts
│  ├── models
│  │  └── user.ts
│  ├── routes
│  │  ├── index.ts
│  │  └── user.ts
│  └── utils
│     └── auth.ts
├── tsconfig.json
└── tslint.json

Open up src/app.ts and let's start with a good default

import express from 'express';

const app = express();

app.listen(3000, () => {
  return console.log(`Server is Listening!`, {
    root: 'http://localhost:3000'
  });
});

Open the .env file and paste in a connection URI for the MongoDB instance, for most use cases this will be a locally running instance of MongoDB so the connection URI should look similar

DATABASE='mongodb://localhost:27017/Auth'

Next open .gitignore and add some defaults so that we can be sure if we init a Git repository here we won't accidentally upload our secure .env file or any useless files such as node_modules/ we can also exclude the compiled dist/ directory since it's not important for our source code

.env
dist/
node_modules/

Now let's import some more libraries and configure a couple of the easy items

import cookieSession from 'cookie-session';
import dotenv from 'dotenv';
import express from 'express';
import helmet from 'helmet';
import mongoose from 'mongoose';
import passport from 'passport';

// Load our .env file 
dotenv.config();

// Create an ExpressJS app instance
const app = express();

// Initialize Helmet with all defaults
app.use(helmet());

// Setup express to accept JSON body data
app.use(express.json());

// Setup cookie sessions
app.use(cookieSession({
  // Expire in 24 hours
  maxAge: 24 * 60 * 60 * 1000,

  // Standard name for session cookie
  name: 'session',

  // Make sure this is random
  secret: ''
}));

// Setup Passport 
app.use(passport.initialize());
app.use(passport.session());

// Mount our routes at /api
app.use('/api', require('./routes'));

// Connect to MongoDB
mongoose.connect(process.env.DATABASE);
mongoose.connection.on('connected', () => {
  // Wait to make the ExpressJS app live until MongoDB is connected
  app.listen(3000, () => {
    return console.log(`Server is Listening!`, {
      root: 'http://localhost:3000'
    });
  });
});

// Listen for MongoDB errors
mongoose.connection.on('error', (err) => {
  console.error('Database Connection Error', err);
});

// Listen for MongoDB disconnections
mongoose.connection.on('disconnected', () => {
  console.error('Database Disconnected');
});

This code now loads our .env file, connects to the MongoDB database and initializes a bunch of dependencies so that we're ready to use them. Note the cookie session secret has to be set to something unique, I recommend using an online UUID generator to make a session secret. The reason we're not including it in the .env file is to remind developers that it should not be modified once in production or it will invalidate all previous sessions!

The User Model

The user model in src/models/user.ts will be responsible for a few things related to user signin and signup. The more we can put in this file the better because it provides one space to define our user model as much as possible. The first step is to set up some basics, with TypeScript we can also define an interface so that we can use type checking outside of the file which is also super useful.

import { Document, model, Model, Schema } from 'mongoose';
import passportLocalMongoose from 'passport-local-mongoose';

export interface IUser extends Document {
  _id?: string;
  username: string;
  password: string;
  hash: string;
  salt: string;
}

const UserSchema: Schema = new Schema({
  password: String,
  username: {
    required: true,
    type: String,
    unique: true
  }
});

UserSchema.plugin(passportLocalMongoose, {
  selectFields: '_id username'
});

const User: Model<IUser> = model('User', UserSchema);

export default User;

Now, passport-local-mongoose can handle hashing for us which saves us a lot of time! It's extremely important to make sure that you hash passwords stored on your service. This has become basic practice now and you should never under any circumstances store plain text passwords. If your database is compromised you could leak sensitive information and users have a tendency to use the same passwords on multiple services so we must protect them from themselves as much as we can 😇

"Programming today is a race between software engineers striving to build bigger and better idiot-proof programs, and the Universe trying to produce bigger and better idiots. So far, the Universe is winning." - Rick Cook, The Wizardry Compiled

Now lets go back to src/app.ts and add these lines to invoke the passport-local-mongoose library and setup defaults.

import User from './models/user';

// etc

// Setup Passport 
app.use(passport.initialize());
app.use(passport.session());

// New code..
passport.use(User.createStrategy());
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());

// etc

You will notice that createStrategy(), serializeUser() and deserializeUser() cause TSLint errors. This is a known issue with these libraries so we can just resolve them (for now) by casting as any which is not the ideal solution but it works.

import User from './models/user';

// etc

// Setup Passport 
app.use(passport.initialize());
app.use(passport.session());

// New code..
passport.use((User as any).createStrategy());
passport.serializeUser((User as any).serializeUser());
passport.deserializeUser((User as any).deserializeUser());

// etc

Defining Routes

Finally we're getting to the good part, defining our routes! Notice that in the structure we setup initially we're using a directory with an index file, so we will have to setup the index and then the user routes afterwards. The index file is pretty simple for now as we're only importing our API routes but the intention is to be able to add routes mounted at api/ if we want to, for example api/ping which we will use to check authentication status.

import express, { NextFunction, Request, Response } from 'express';

// Define an Express Router
const router = express.Router();

/**
 * GET /ping
 * 
 * Returns some useful information about the server 
 * such as the server version and if there is an 
 * authenticated user on the current session.
 */
router.get('/ping', (req: Request, res: Response, next: NextFunction) => {
  return res.status(200).send({
    authenticated: req.isAuthenticated()
  });
});

// Import our user routes
router.use('/user', require('./user'));

module.exports = router;

Now let's go into src/routes/user.ts and define the routes we will use for user authentication, we won't implement any logic yet except for the signout route because that's easy.

import express, { NextFunction, Request, Response } from 'express';

// Define an Express Router
const router = express.Router();

router.post('/signup', async(req: Request, res: Response, next: NextFunction) => {

});

router.post('/signin', async(req: Request, res: Response, next: NextFunction) => {
  
});

/**
 * Allow the user to signout of their account
 * this is just a standard GET request to make 
 * it easier to use, we could use POST but it's 
 * not really necessary here
 */
router.get('/signout', (req: Request, res: Response, next: NextFunction) => {
  req.logout();

  return res.status(200).send();
});

module.exports = router;

Now looking at what we have created so far we can think about what we need to do with our signup and signin routes. A basic outline would be something like

  • /signup - Create a user object, save it to the database, sign in the user.
  • /signin - Sign in the user, passport-local-mongoose handles password hashing for us!

We can also observe the current setup to see some security limitations that we will discuss in the next section

  1. Routes have no rate limiting
  2. Routes have no verification to ensure that the body contains the correct fields

It's important to take a step back and think about what you're writing at moments like this, think as if you were an attacker what you would do to gain unauthorized access? If you were a user how would you accidentally break this? Once you have a list of things you can search out solutions. The good thing about NodeJS is that many things already have smart and well thought out solutions in libraries that you can implement but be cautious when choosing what you need.

Moving forward, let's define our route logic with our steps in mind. First the signup route since that will contain some reusable logic we can use in the signin route.

import express, { NextFunction, Request, Response } from 'express';
import passport from 'passport';
import User from '../models/user';

router.post('/signup', async(req: Request, res: Response, next: NextFunction) => {
  // Create a user object defined within our model in `src/models/user.ts`
  const user = new User({
    username: req.body.username
  });

  // Call the function register() from `passport-local-mongoose` to register the user
  (User as any).register(user, req.body.password, (err: Error, _: any) => {
    if ((err) || !(user)) {
      console.log((err) ? err : 'No User');
      return res.status(500).send({
        error: ((err) && (err.message)) ? err.message : 
          'Internal Server error.'
      });
    }

    // Save the database object
    user.save((uerr) => {
      if (uerr) {
        console.log(uerr);
        return res.status(500).send({
          error: ((uerr) && (uerr.message)) ? uerr.message : 
            'Internal Server error.'
        });
      }

      /*
       * Call passport.authenticate to authenticate the session
       * if you don't include this your user will have to be 
       * authenticated with /signin again before their session is 
       * valid
       */
      passport.authenticate('local', (perr: Error, auth: any) => {
        if (perr || !auth) {
          console.log(perr);
          return res.status(500).send({
            error: ((perr) && (perr.message)) ? perr.message : 
              'Internal Server error.'
          });
        }

        // Login the session with `passport`
        req.logIn(auth, async(lerr: Error) => {
          if (lerr) {
            console.log(lerr);
            return res.status(500).send({
              error: ((lerr) && (lerr.message)) ? lerr.message : 
                'Internal Server error.'
            });
          }
          
          // We don't need to send these to the client!
          user.hash = undefined;
          user.salt = undefined;

          // Finally, return the user object
          return res.status(200).send({ user });
        });

      })(req, res, next); // WARNING: DO NOT FORGET THIS PART or your auth will not work!
    });
  });
});

Now we can see that in the signup route we're also logging the user in. We don't have to worry about unique usernames because the mongoose library will throw errors if it's not unique since we defined the username field as unique which is very convienent! Now that we already have a method for sign in in our sign up route the sign in route is much easier to implement. Here's how we can implement user sign in using the same code in our sign up route.

router.post('/signin', async(req: Request, res: Response, next: NextFunction) => {
  // Call passport.authenticate to authenticate the session
  passport.authenticate('local', (err: Error, user: any, info: any) => {
    if ((err) || (!user)) {
      console.log((err) ? err : 'No User');
      return res.status(500).send({
        error: ((err) && (err.message)) ? err.message : 
          'Internal Server error.'
      });
    }

    // Find the user object to provide more information to the client
    User.findById(user._id, (uerr: Error, _: any) => {
      if ((uerr) || (!user)) {
        console.log((uerr) ? uerr : 'No User');
        return res.status(500).send({
          error: ((uerr) && (uerr.message)) ? uerr.message : 
            'Incorrect username or password'
        });
      }

      // Login the session with `passport`
      req.logIn(user, async(lerr: Error) => {
        // We don't need to send these to the client!
        user.hash = undefined;
        user.salt = undefined;
          
        // Finally, return the user object
        return res.status(200).send({ user });
      });
    });

  })(req, res, next); // WARNING: DO NOT FORGET THIS PART or your auth will not work!
});

Initial Results

Now that we have configured our basic authentication system we can run it and see how it works! To run it simply invoke the start command like so

npm start

After tsc compiles the TypeScript into dist/ you should see this message

Server is Listening! { root: 'http://localhost:3000' }

Now fire up an HTTP client like Postman and send your first request like this

POST http://localhost:3000/api/user/signup
{
  "username": "admin",
  "password": "admin"
}

You should get a response like this

{
    "user": {
        "username": "admin",
        "_id": "615e5c63711bf591ca60f0e4",
        "__v": 0
    }
}

Now you can use MongoDB Compass to check your database to see the new user object as well.

Hardening

Now that we've implemented basic user authentication it's time to harden it against potential threats. As we discussed before we should take a step back and consider what using this service would look like from a user perspective and an attacker perspective to gain some insight into what we are lacking. Since we've discussed the shortcomings already we know that we have to implement some sort of body verification and rate limiting system to close up some potential issues from both view points.

Validating POST request bodies is actually a fairly complex topic and there are a number of utilities around that help with this but so far I haven't found one that meets the cost vs benefits mark with the time involved to implement. With that in mind we will roll a quick solution of our own but first we have to do some research to figure out exactly what we have to worry about. Let's outline some concerns that we can gather from the way that sign in and sign up work:

  1. We're sending user provided data into a database query without sanitation.
  2. We're not validating that the user provided data exists or is the correct type.

The first question is "do we need to santize inputs with MongoDB?" well as it turns out we do and there's this handy little library that helps us understand how that can be done. We'll want to avoid installing a small dependency like this and maintain our own solution as there is no real benefit to using the library at this point so we can simply utilize the code there in our project by creating a new utility function. Note that we can only do this because this library is licensed with an MIT license that allows us to use the code in this way. Be cautious when using external code from libraries and make sure to understand their licensing. First let's create a new file to keep things organized at src/utils/sec.ts and modify the code from the library to fit TypeScript.

export const sanitize = (v: any): any => {
  if (v instanceof Object) {
    for (const key in v) {
      if (/^\$/.test(key)) {
        delete v[key];

      } else {
        sanitize(v[key]);
      }
    }
  }

  return v;
};

Now we can import this file into our src/routes/user.ts file so we can use it later.

import { sanitize } from '../utils/sec';

Before we implement this utility function we should check to ensure that the property exists on the user provided data, we can do this with a simple null check and then add our utility function as well.

router.post('/signup', async(req: Request, res: Response, next: NextFunction) => {
  // Check if username exists, return if not
  if ((!req.body) || (!req.body.username)) {
    return res.status(400).json({
      message: 'username is required'
    });
  }

  // Check if password exists, return if not
  if ((!req.body) || (!req.body.password)) {
    return res.status(400).json({
      message: 'password is required'
    });
  }

  // Sanitize user inputted data
  const username = sanitize(req.body.username);
  const password = sanitize(req.body.password);

  // Create a user object defined within our model in `src/models/user.ts`
  const user = new User({
    username
  });

  // Call the function register() from `passport-local-mongoose` to register the user
  (User as any).register(user, password, (err: Error, _: any) => {
  
//...

for our sign in function we can't really use this because we don't manually pass the auth information into Passport via passport-local-mongoose we can however check for the fields to ensure that they exist.

router.post('/signin', async(req: Request, res: Response, next: NextFunction) => {
  // Check if username exists, return if not
  if ((!req.body) || (!req.body.username)) {
    return res.status(400).json({
      message: 'username is required'
    });
  }

  // Check if password exists, return if not
  if ((!req.body) || (!req.body.password)) {
    return res.status(400).json({
      message: 'password is required'
    });
  }
  
  //...

Now that we have these potential issues covered we can move on to rate limiting using the libraries we installed in the beginning express-rate-limit and rate-limit-mongo. Rate limiting your API protects against many things such as attacks by bots to create accounts and brute forcing passwords. This is an important step that's often not discussed in tutorials like this but it should be implemented for all public facing web services as a basic form of protection. Since we're intentionally not using the passport-local-mongoose login attempts system - because it would expand this article significantly 😅 - this is even more important. We can use our src/utils/sec.ts library to define our rate limits so that we can reuse them in our routes later. First, the imports and a common storage we can use in each limit.

import rateLimit from 'express-rate-limit';
import mongoStore from 'rate-limit-mongo';

// Common rate limit storage
const store = new mongoStore({
  collectionName: 'rates',
  
  // Note: We have to put a default here or `tsc` will fail to compile!
  uri: process.env.DATABASE || 'mongodb://localhost:27017/Auth'
});

We're going to get a little creative when defining these items because we want to make sure that using them is clear so we will nest them within a limits object. With rate limiting we can decide how many requests to allow per minute per IP address. It's fairly difficult to come up with sane defaults for this so you will want to tweak these to your liking.

export const limits = {
  // 450 requests per minute per ip
  default: rateLimit({
    max: 450,
    store,
    windowMs: 1 * 60 * 1000
  }),
  
  // 250 requests per minute per ip
  secure: rateLimit({
    max: 250,
    store,
    windowMs: 1 * 60 * 1000
  })
};

Now let's add this to our import in src/routes/user.ts and also import it in src/app.ts so that we can setup a default limit for all routes within our main /api mount.

import { limits } from './utils/sec';

//...

// Mount our routes at /api
app.use('/api', limits.default, require('./routes'));

//...
src/app.ts
import { limits, sanitize } from '../utils/sec';

//...

router.post('/signup', limits.secure, async(req: Request, res: Response, next: NextFunction) => {
  //...
});

router.post('/signin', limits.secure, async(req: Request, res: Response, next: NextFunction) => {
  //...
});

//...
src/routes/user.ts

We don't need to implement rate limits for the /signout route because it's covered by our main rate limit defined in src/app.ts and it doesn't perform an action that can cause harm by a third party. As such we don't need to implement rate limits on /ping in src/routes/index.ts either however if you are using an API to interface with a client and you're regularly checking if the user is authorized you may have to bump up the values for that specific route. If you do this it's safe to create a new rate limit and apply it to that specific route instead of increasing your default limit because you want to limit your changes to only what's necessary.

Our one last step for hardening our API is to implement password rules. This is actually a pretty debated subject in the programming community largely because we have to draw a line somewhere between protecting the user from themselves and implementing huge amounts of logic that is probably counter intuitive anyways.

For example we could set up these requirements

  • One lowercase char
  • One uppercase char
  • One numeric char
  • One special char

and users would just use things like Passw0rd!. Users will be more likely to create insecure easy to remember passwords if they reuse passwords and their typical password doesn't match the required validation so they are forced to choose a new one they have to remember on the spot.

So for sanity sake and because this article is already nearly 4,000 words long already we will just think about some basic idea of what we should implement to be reasonably secure. First we should implement a sane minimum char limit second we should try to ensure that the user is not using common passwords. So we can define our requirements like this

  • 6 char minimum
  • No common passwords

Pretty simple right? Well storing a continuously updated list of common passwords is not something we want to handle so that part is not so easy. Luckily there's a great library by Mozilla that we can use to handle this for us. Note that there are many other libraries out there but most have not been kept up to date. The top result on Google was last updated in 2015! We need something frequently updated in order to remain secure. Now let's install the library and import it into our user model at src/models/user.ts since we can use passport-local-mongoose to implement the policy at a higher level than implementing it in each route.

npm i fxa-common-password-list
import commonPasswordList from 'fxa-common-password-list';

Now we can define a function for validating passwords and add it to our passport-local-mongoose options.

//...

/**
 * Make sure password matches requirements
 * 
 * - 6 char minimum
 * - uncommon password
 */ 
const passwordValidator = (password, cb) => {
  if (password.length < 8) {
    return cb('Password too short! Must be 6 chars in length');
  }

  if (commonPasswordList.test(password)) {
    return cb('Password is too common');
  }
  
  return cb();
};

UserSchema.plugin(passportLocalMongoose, {
  passwordValidator,
  selectFields: '_id username'
});

//...

Now that we have our password validation in place we are nearly finished!

Extras

The last step is optional but it's a good idea to do this now so when we add more routes later we can use this throughout our route definitions. We're going to create a simple middleware for Express that checks if the user is authorized. What this allows us to do is define routes that only authenticated users can access which is critical functionality for most applications. We can add this to the src/utils/auth.ts file that we created earlier.

export const authenticated = (req: Request, res: Response, next: NextFunction) => {
  /*
   * If the user is authenticated we can allow the 
   * next function to run by manually calling it 
   */
  if (req.isAuthenticated()) {
    return next();
  }

  /*
   * If we get here we know that the user is not 
   * authenticated so we can reject the request 
   * with 401 - Unauthorized
   */
  return res.status(401).send({
    error: 'Authorization Required.'
  });
};

Final Thoughts

Phew this was a long one, let me know what you think! From here we can move on to some manual testing, implementing automatic testing and, if your project is open source like this one, implementing Github actions to help keep things up to date and prevent future vulnerabilities.

Tags

Cryptobyte

Senior Software Developer working with software since age 17 (all the way back in 2008!)

Comments

Sign in or become a Cryptobyte member to join the conversation.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.