User Authorization

As I mentioned in Creating a RESTful API Server, we intend on creating the following /users endpoints.

POST /users         // create a new user and generate an authentication token
POST /users/login   // validate a user's credentials, generate an auth token
GET /users/me       // get user's account information
PATCH /users/me     // modify a user's account information
DELETE /users/me    // delete a user's account
POST /users/logout  // log a user out

Since we don’t want arbitrary users to be able to log a user out, or worse, modify or delete a user’s profile, we need to create a process to authenticate a user and a way for a user to prove that they are authorized to perform certain actions like modify and delete their own profile.

In this chapter, we’ll learn how to generate a JSON Web Token (token) when a user creates a new account.  This token will be saved in the user’s document and passed back to the client.  When the client wishes to perform some action that requires authorization, the client will send the token along with the request.  In the next chapter we’ll use the same process to generate an JWT token when a user logs in.

A JWT is either signed or encrypted.  If it is signed it is a JSON Web Signature (JWS).  If it is encrypted, it is a JSON Web Encryption (JWE).  We will be using signed tokens.

A JWS is a string that has 3 parts: a header, a payload, and a signature; each separated by a period.  The header specifies how the token is signed.  The payload is a JSON object that holds some claim.  For example, your API will require the client to present a token to prove the user’s identity.  Thus the claim inside the payload of the token will have something that establishes the user’s identity (e.g. the user’s document id).  The signature is a representation of the header + payload which is encrypted using a secret key.  Only those who possess the secret key (just your server) can validate the signature of tokens produced with that same secret key.  Therefore malicious actors cannot create forged tokens, because they won’t have a signature that will pass your validation process.  Malicious actors can however masquerade as another user if they possess a valid token for the user, but we can avoid this by using HTTPS which encrypts all communications between the client and server.  If you want to learn more about JWTs, I’ve found this website to be helpful.

JWTs will serve as an authorization token for our API server.  When we build out the client we’ll have the client store the token in a cookie.  Then when the client wants to perform an action like modify or delete a user’s profile, the client will retrieve the token from the cookie and send the token to the endpoint as proof of identity.

When the server receives a request for an endpoint that requires an authorization token (such as /users/logout), the server will call a middleware function (before the endpoint router code is executed) that searches the database for the user to whom the token belongs.  If the token belongs to a user, then the user’s document will then be passed to the router and the router can act accordingly.

In this chapter, we’ll create a /users/logout endpoint which will require an authorization token.  When the router receives a user document from the middleware, the router will log the user out by removing the auth token from the user’s document.

Install the jsonwebtoken npm Module

We are going to create JSON Web Tokens using the jsonwebtoken npm module.  Let’s begin by installing the module.

$ npm install jsonwebtoken

Add a Tokens Array to the User Schema

It makes sense to allow each user to be able to utilize the API server from multiple devices at the same time.  Each time a user creates an account or logs in we’ll create a separate JWT and store the token in the user’s document.  So, in the user’s schema, let’s add a property to hold an array of token objects as shown below.

tokens: [{
    token: {
        type: String,
        required: true
    }
}]

Here, the tokens property is mapped to an array of objects where each object has a property named token which holds a string.

Update toJSON

Since we don’t want to send the tokens array to the client when we send the user document back in a response, remove the tokens array from the object that is returned from userSchema.methods.toJSON().

Set JSON_WEB_TOKEN_SECRET

We will be creating signed JWSs using the jsonwebtoken module’s sign() method.  We’ll be passing to sign() an object containing the user’s id (the document id) along with a secret string that will be used to create the token’s signature.

Add the following line to your .env file, replacing <your-secret-phrase> with a secret phrase that you want to use to create your token signatures.  Make the phrase at least 32 characters long.

JSON_WEB_TOKEN_SECRET=<your-secret-phrase>

Also, add JSON_WEB_TOKEN_SECRET to heroku’s configuration set using the following command, replacing <your-secret-phrase> with the secret phrase you added to your .env file; and if you are on a Windows machine, replace the single quotes with double quotes.

$ heroko config:set JSON_WEB_TOKEN_SECRET='<your-secret-phrase>'

Generate and Send a Token

Since we need to generate a token when a user creates an account and when they log in, let’s create an instance method to hold the code that generates a JSON Web Token so it can be called in multiple endpoint handlers.  After we’ve created the instance method to generate tokens, we’ll update the POST /users endpoint handler to call the method.

Create an Instance Method to Generate Tokens

We saw earlier that we can create instance methods by adding functions to a schema’s methods property.   Let’s add a new instance method, that when invoked on a User document, creates a signed token, adds the token to the array of tokens in the user’s document, saves the document in the database, and returns the token to the caller.

Since we’ll be using the jsonwebtoken module to create the token, import the module at the top of src/models/user.js.

const jwt = require('jsonwebtoken')

Add the following instance method in …/src/models/user.js and read the explanation below.

userSchema.methods.generateAuthToken = async function () {
  const user = this
 
  const token = jwt.sign({ _id: user._id.toString() }, process.env.JSON_WEB_TOKEN_SECRET)

  user.tokens = user.tokens.concat({ token })
  await user.save()

  return token
}
Explanation of the Code

We use a function on line 1, rather than a lambda expressions because we need access to the instance (this, on line 2) on which generateAuthToken is called.  We also require the async keyword because we have to wait for user.save() to return before returning from the method.

On line 4 we call the jsonwebtoken module’s sign() method, passing to it, an object containing value of the document’s id property (as a string), and the secret string.  The object that we passed becomes the payload of the token.

On line 6 we add a new object containing the new token to the document’s tokens array using the array’s concat() method.  The concat() method returns a new array so we set users.tokens equal to the new array created by concat() and then save the  modified user document to the database on line 7.

On line 9 we return the token to the caller.

Update the /users Endpoint

Now in the handler for the POST /users endpoint, after we create a new User document and save it to the database, we’ll call generateAuthToken() on the new document instance.

Update your POST /users endpoint handler so that it looks like the code below.

router.post('/users', async (req, res) => {
  const user = new User(req.body)

  try {
    await user.save()
    const token = await user.generateAuthToken()

    sendWelcomeEmail(user.email, user.name)
    res.status(201).send({user, token})
  } 
  catch(error) {
    res.status(400).send(error)
  }
})

Note that we are now sending the client an object containing both the user document and the token.  Note also that we must use the await keyword when calling generateAuthToken(), since we have to wait for the token to be generated before we can send the response to the client.

Establish a Logout Endpoint

So far if a user successfully creates a new account using the /users endpoint, a new document that includes a new JSON Web Token is created and added in the database and a user object and token are sent back to the client in the response.

To test out the functionality of the authorization token, let’s add an endpoint that allows a user to log out. In our app, logging out will entail, verifying that the user has permission to log out (has valid auth token) and removing the  auth token from the user’s list of valid auth tokens.

Create Middleware to Validate Tokens

Express middleware is code that is executed by express after a request has been received, but before the endpoint code in the router is executed.

A middleware function is async and has 3 parameters (req, res, next). The req parameter is the Request object that is received from the client and res is the Response object that will be sent to the client.   next is a method that is called within the middleware if and when it has been decided that the next middleware routine, or perhaps the router’s endpoint handler, should be invoked.

We are going to create a middleware function named auth() that will be called before the /users/logout endpoint code is invoked.  auth() will validate the JSON Web Token sent from the client, and if valid, will call next() so that the /users/logout endpoint code is executed.  If the token is not present or is invalid, auth() will throw an error rather than calling next().

Create the following directory and file to store the middleware function named auth().

.../src/middleware
.../src/middleware/auth.js

Add the following auth() function definition to …/src/middleware/auth.js, then read the explanation of the code below.

const jwt = require('jsonwebtoken')
const User = require('../models/user')

const auth = async (req, res, next) => {
  try {
    let token = req.header('Authorization')
    //console.log(token)
    token = token.replace('Bearer ', '')
    const decoded = jwt.verify(token, process.env.JSON_WEB_TOKEN_SECRET)
    const user = await User.findOne({_id: decoded._id, 'tokens.token': token})

    if (!user) {
      throw new Error()
    }

    req.token = token
    req.user = user
    next()
 
  } catch (e) {
    res.status(401).send({error: 'Please authenticate.'})
  }
}

module.exports = auth
Explanation of the Code

Lines 4-23 create an asynchronous function that the router will call when directed to.  We will see in the next section how to direct a router to execute middleware.

The arguments to the function, specified on line 4, contain the request, the response, and a function named next that is called when we want the router to continue processing the request.

Line 6 creates a variable named token which we will modify later (hence the use of let instead of const).  We initialize the variable with the Authorization string (JWT) retrieved from the request sent from the client. And on line 8, we strip the word ‘Bearer ‘ from the beginning of the Authorization string.

We call jwt.verify() on line 9, passing to it, the JWT (token) and the JWT secret.  verify() returns the decoded payload (a JSON object containing the user’s document id) if the signature in the token is valid.

We then, on line 10, pass an object containing the user’s id and the token to User.findOne().  This is a mongoose method that searches the User collection for a document containing both the specified id and token.  If found, it returns the user document.

On lines 12-14 we throw an error if the user was not found.

Otherwise, on lines 16 and 17, we add the token and user properties to the request.  Recall that router has still not called the endpoint handler.  When it does, the token and user objects will be included in the request available for the handler to use.

On line 18, we call next() to notify the router to continue processing the request.

If any of the above code throws an error, on line 21, we send the client a 401 status and an error message.

Add a /users/logout Endpoint to the Router

The following code establishes a new endpoint that will allow users to log out.  When a request is made to the endpoint, the code in auth() is executed first.  If the JSON Web Token sent from the client is valid, the token is simply removed from user’s tokens array.

Add the following code to src/routers/users.js then read the description of the code below.

const auth = require('../middleware/auth')
...

router.post('/users/logout', auth, async (req, res) => {
  try {
    req.user.tokens = req.user.tokens.filter((token) => {
      return token.token !== req.token
    })
    await req.user.save()
    
    res.send()
  }
  catch (e) {
    res.status(500).send()
  }
})
Explanation of the Code

Notice on line 4 the auth method name is between the first and third arguments.  This informs the router to execute the auth() middleware function before executing this handler.

On lines 6-8 we simply remove the token from the user’s tokens array by calling the array’s filter() method and save the revised document to the database on line 9.

One line 11, we send the default response to the client.  Note that the default response has a 200 status code.

If there is a problem saving the document, we run line 14, returning a 500 status code to the user.

Test With Postman

When we use the Create User request in Postman, Postman receives a new JSON Web Token in the response from the server.   Other requests, like to log a user out, require a JWT in the Authorization header of the request.  Postman allows us to configure a collection so that a token received by one request can be automatically set in the Authorization header of another request.

Set Up Postman to Store and Use Auth Tokens

In the Tests tab of a request,  we can provide JavaScript code that will be run after a response has been received.  For the Create User request, we’ll extract the token from the body of the response and store the token in an environmental variable named authToken.  We’ll then use the environmental variable inside the Authorization header for other requests in the collection.

In the Tests tab for the Create User request enter the following JavaScript code, then press Save.

if (pm.response.code === 201) {
    pm.environment.set('authToken', pm.response.json().token)
}

Now click on the elipsis next to the Web App API collection, and choose Edit.  Click on the Authorization tab, and choose Bearer Token for Type, and set the Token field to {{authToken}}.  Press the ellipsis in the middle pane and press Save.

Now all Postman requests that have their Authorization Type set to Inherit auth from parent will have their authorization header set to the token received by the last successful Create User request.  For requests that don’t require authorization (e.g. Create User), set the Authorization Type to No Auth and Save.

Create a Logout Request

Let’s now create a Postman request for the /users/logout endpoint.

In your /users folder, click the ellipsis, and select Add Request.  Name the request Logout User, edit the method to POST, set the endpoint to {{url}}/users/logout, and press Save.  The Auth tab should have the Type set to Inherit auth from parent.  If not, set it to Inherit auth from parent and press Save.

Test the Create User and Logout Endpoints

It might be helpful to delete the documents in your users collection in your databases before proceeding.  To test that everything is working property, do the following.

  1. Use Postman to send a Create User request to your API server.
  2. Use Compass to verify that the token was added to the tokens array in the user document.
  3. Use Postman to send a Logout User request to your API server.
  4. Use Compass to verify that the token was removed from the tokens array in the user document.

If you run into problems, it might be helpful to print to the console the error objects that you catch in the try-catch blocks.

Deploy to Heroku

Deploy to heroku and test.

 

© 2022 – 2024, Eric McGregor. All rights reserved. Unauthorized use, distribution and/or duplication of this material without express and written permission from the author is strictly prohibited.