Skip to content

Instantly share code, notes, and snippets.

@cklanac
Last active December 3, 2018 18:02
Show Gist options
  • Save cklanac/804a12b1533777234d4e6a16b1c0984f to your computer and use it in GitHub Desktop.
Save cklanac/804a12b1533777234d4e6a16b1c0984f to your computer and use it in GitHub Desktop.
Authentication and access control

Passport Local Strategy Auth

In the first lesson, you'll learn all about user management and access control.

We'll use bcryptjs to encrypt user passwords so we don't have to save them as raw text strings.

Note that there are two separate libraries available on NPM bcryptjs (the one we want) and bcrypt (not what we want). Be sure to install bcryptjs, otherwise you may encounter problems on TravisCI.

We'll use Passport to control access to an endpoint at /users/me that only authenticated users can access.

Along the way you will learn about one-way hashing, and how this is used to keep user's details secure.

You are going to be using passport to protect an API endpoint at /users/me from users who aren't logged in.

To follow along, clone node-local-auth, then cd into it and run npm install. When installation completes, make sure your local Mongo server is running, then run npm start.

Try out the app

We'll walk through implementation details in a moment, but first let's create a user

Make a POST request to /api/users, making sure to set a content type to application/json. The request body should contain a JSON object with properties for username, firstName, lastName, and password. You should get a 201 status code in response, along with a JSON object with properties for username, firstName and lastName, but not password. (You can also check your database to view the newly saved user)

create-new-user.gif

Let's see how an endpoint with access control works. In Postman, make a POST request to /api/login. Since /api/login requires a message body with a username and password and we didn't provide one, yet, we get back a 400 "Bad Request".

Let's add a username and password to the HTTP POST message body. In Postman, click the "Body" tab in the request section, then select "raw" and "JSON (application/json)". Enter the username and password you just created and click "Send". This time you should get back a 200 along with a JSON object with a secret message.

login-user.gif

Creating users at POST /users

Let's see how the user creation flow is implemented. New users can make a POST request to /api/users, supplying an object with values for firstName, lastName, username, and password.

Looking at the POST endpoint for /api/users in /users/router.js, the first several lines of that block validate and cleanup the values supplied for username and password

  const requiredFields = ['username', 'password'];
  const missingField = requiredFields.find(field => !(field in req.body));

  if (missingField) {
    return res.status(422).json({
      code: 422,
      reason: 'ValidationError',
      message: 'Missing field',
      location: missingField
    });
  }

  const stringFields = ['username', 'password', 'firstName', 'lastName'];
  const nonStringField = stringFields.find(
    field => field in req.body && typeof req.body[field] !== 'string'
  );

  if (nonStringField) {
    return res.status(422).json({
      code: 422,
      reason: 'ValidationError',
      message: 'Incorrect field type: expected string',
      location: nonStringField
    });
  }

  const explicityTrimmedFields = ['username', 'password'];
  const nonTrimmedField = explicityTrimmedFields.find(
    field => req.body[field].trim() !== req.body[field]
  );

  if (nonTrimmedField) {
    return res.status(422).json({
      code: 422,
      reason: 'ValidationError',
      message: 'Cannot start or end with whitespace',
      location: nonTrimmedField
    });
  }

  const sizedFields = {
    username: {
      min: 1
    },
    password: {
      min: 8,
      max: 72
    }
  };
  const tooSmallField = Object.keys(sizedFields).find(
    field =>
      'min' in sizedFields[field] &&
            req.body[field].trim().length < sizedFields[field].min
  );
  const tooLargeField = Object.keys(sizedFields).find(
    field =>
      'max' in sizedFields[field] &&
            req.body[field].trim().length > sizedFields[field].max
  );

  if (tooSmallField || tooLargeField) {
    return res.status(422).json({
      code: 422,
      reason: 'ValidationError',
      message: tooSmallField
        ? `Must be at least ${sizedFields[tooSmallField]
          .min} characters long`
        : `Must be at most ${sizedFields[tooLargeField]
          .max} characters long`,
      location: tooSmallField || tooLargeField
    });
  }

For both username and password, we ensure that they're defined, that they're strings, and that they have a minimum length of 8 and maximum length of 72. We also trim any leading or trailing whitespace.

Next, because usernames are unique in our system, we check if there is an existing user with the requested name:

// check for existing user
return User.find({username})
  .count()
  .then(count => {
    if (count > 0) {
      return Promise.reject({
        code: 422,
        reason: 'ValidationError',
        message: 'Username already taken',
        location: 'username'
      });
    }

    return User.hashPassword(password);
  })

If a user with the requested name already exists, we reject the promise with an error object containing a message. This is handled in the catch block at the bottom of the promise chain by responding with a 422 status and the message:

.catch(err => {
  if (err.reason === 'ValidationError') {
    return res.status(err.code).json(err);
  }
  res.status(500).json({code: 500, message: 'Internal server error'});
});

Otherwise, we hash the password using a static method .hashPassword that we've created on our user model. We'll see how this is implemented in a moment, but for now, know that hashing is a process that converts a raw, plain text password to a string of (in principle) unguessable characters. For example, the string "baseball" is converted to the hash "a2c901c8c6dea98958c219f6f2d038c44dc5d362" using the SHA1 hashing algorithm.

Once we've got our hash, we save a new user, setting the password to the hash value.

.then(digest => {
  return User.create({
    username,
    password: digest,
    firstName,
    lastName
  });
})
.then(user => {
  return res.status(201).location(`/api/users/${user.id}`).json(user.serialize());
})

Encrypting passwords

To encrypt passwords, we'll use one-way hashes, where the original value is converted to a seemingly random value that would be nearly impossible to guess the original from. For instance, the string "baseball" might yield a long hash beginning with "a2c901...". Even if an attacker gets hold of the hash value, it would be difficult to get back to "baseball".

The benefit of hashing passwords is that if a malicious user obtains access to your database then they only know the user's hashes, and cannot obtain the passwords easily. Additionally, these are salted hashes, which means that some additional random data is mixed in with the password; generating several hashes for the same password will generate several distinct hashes.

Let's have a look at the hashPassword method in ./users/models.js. Here's the code:

UserSchema.statics.hashPassword = function(password) {
  return bcrypt.hash(password, 10);
}

As you can see, we're using the bcryptjs library (which is a JavaScript implementation of a popular password hashing function. see here) to handle encrypting user passwords.

We call the bcrypt hash method with the raw password and an integer value indicating how many rounds of the salting algorithm should be used. The higher this number is, the more computationally difficult it is to compare two passwords. A value between 10 and 12 provides a nice balance between taking long enough so brute-force cracking is difficult, and being quick enough that your app is responsive to your users.

Protecting endpoints with passport

Now that we are able to generate and store relatively secure passwords, we can require users to provide their credentials in order to access endpoints.

To do this we use passport.js to set up a local authentication strategy.

For the endpoint /api/login, we inject a piece of middleware that handles local authentication. To access this endpoint, users must provide a valid username/password combination in the request body.

Passport will look for the credentials in the request body. Here's what it does with them:

const localStrategy = new LocalStrategy((username, password, done) => {
  let user;
  User
    .findOne({ username })
    .then(results => {
      user = results;    
      
      if (!user) {
        return Promise.reject({
          reason: 'LoginError',
          message: 'Incorrect username',
          location: 'username'
        });
      }        
      return user.validatePassword(password);
    })
    .then(isValid => {
      if (!isValid) {
        return Promise.reject({
          reason: 'LoginError',
          message: 'Incorrect password',
          location: 'password'
        });
      }
      return done(null, user);    
    })
    .catch(err => {
      if (err.reason === 'LoginError') {
        return done(null, false);
      }
      return done(err);
    });
});

We look for a user with the supplied name. If the user is found, we then call the validatePassword instance method with the password from the request body. We'll look at that function in a moment, but note that it returns a promise, which resolves with a boolean value indicating whether or not the password is valid. If the password is valid, the user object will be added to the request object at req.user. If not, we'll return an error message.

passport.use(localStrategy);

// ...

const localAuth = passport.authenticate('local', { session: false });

// ===== Protected endpoint =====
router.post('/login', localAuth, function (req, res) {
  console.log(`${req.user.username} successfully logged in.`);
  return res.json({ data: 'rosebud' });
}); 

To use our local auth strategy in a route, we create a variable localAuth that holds the middleware function created by passport.authenticate('local', {session: false}) and pass it as the second argument to endpoint. When session is set to true, the user only has to authenticate once to make successive requests. Otherwise, they'll need to supply credentials on each request.

Finally, here's how the password validation function works:

UserSchema.methods.validatePassword = function (password) {
  return bcrypt.compare(password, this.password);
};

We use bcrypt to compare the plain text value passed to the function (password) with the hashed value stored on the user object (this.password), ultimately resolving with a boolean value indicating if the password is valid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment