Aller au contenu

Node.js Authentication and Authorization

Introduction

What is the need ?

Our COVID Node.js Express application allows anyone to get COVID related data. This may not be an issue, but it is also very common to restrict access to data so that only registered users can access it. To identify which user is making the request, the backend needs to support user authentication.

Before describing and implementing the different ways for implementing authentication, it is important to define what authentication and authorization mean. They are two inherently different concepts that often get tangled together. Authentication deals with verifying that users are who they claim they are. Authorization specifies what that verified user is allowed to do.

What you’ll build

We will start building the simplest form of authentication, which is submitting a username and a password. While this approach is relatively simple to implement, sending credentials with each HTTP request is not optimal from a security perspective, since an attacker has plenty of opportunities to compromise user credentials and security. For this reason, another way of implementing authentication will be studied, namely Authentication using JSON Web tokens.

What you’ll learn

  • How to implement various authentication mechanisms.
  • You will demonstrate the technologies in consecutive steps. Some code blocks are provided for you to simply copy and paste.

What you’ll need

  • WebStorm.
  • Node.js framework.
  • Prior knowledge of JavaScript and {{ nodehs }} programming, based on lectures, lab and exercise sessions.
  • The implementation of the converter and COVID Express applications must be completed.

HTTP Authentication

HTTP provides a general framework for authentication and thus access control. This is useful if one wants to restrict access to an API to authenticated users.

The HTTP authentication framework is defined in RFC 7235. The principle is that it allows a server to challenge a client request using challenge-response authentication. In this context, a challenge is some data sent to the client by the server in order to generate a different response each time. Challenge-response protocols are one way to fight against replay attacks where an attacker listens to the previous messages and resends them at a later time to get the same credentials as the original message.

The challenge and response mechanism works as described in the figure below:

Challenge-response message flow

Challenge-response message flow

As you can observe in this diagram, the flow of messages is as follows:

  1. The client generates a request without authentication and the server responds with a 401 response status. This means that the request in unauthorized. The server also provides information on how to provide authentication with a WWW-Authenticate response header. This header defines the HTTP authentication methods that might be used and it must contain at least one possible method. In this example, the header specified that Basic Authentication may be used with a realm describing the protected area.
  2. Upon receiving the response, the client may usually ask the user for credentials and generate another request by including an Authorization request header with the credentials.
  3. When the server receives the new request, it must check whether the credentials are correct and generate the appropriate response.

This message flow is the same for all authentication schemes. What makes authentication schemes different is the information contained in the request and response headers, and the way this information is encoded.

Among authentication schemes, the easiest to implement is the Basic authentication scheme, which transmits credentials as a user/password pair, encoded using Base64. This is probably the simplest scheme to implement, but since credentials are transmitted over the network as clear text, it is not secure and should be used exclusively over HTTPS. There exist other schemes that implement more secure authentication.

Since it is simple to implement and allows to demonstrate the HTTP Authentication mechanism, we will start implementing this simple scheme. Note that although the HTTP authentication protocol is challenge-response based, the “Basic” protocol isn’t using a real challenge (the realm is always the same).

Basic Authentication

As explained above, the simplest form of authentication is through the submission of a username and of a password. Despite its misleading name, the conventional way to send credentials with an HTTP request is to include an Authorization header.

HTTP Basic Authentication is a mechanism in which the server challenges any client making a request for data and gets a response in the form of a username and password. When the client makes a request on the server that requires authorization, the server sends a response with a 401 status code. The client will then send another request accompanied with an Authorization header, as the one below:

    Authorization: Basic YmFja2VuZDp3ZWxvdmVpdA==

The Authorization header begins with a word indicating which authentication strategy to use. For the Basic method, everything after the keyword Basic represents the username and password. The credentials are formatted as username:password and then converted from ASCII to base-64. The example above thus represents the string backend:weloveit.

Requiring Authentication in Node.js

Requiring authentication can be implemented using a middleware function as the one shown below:

lib/require-auth.js
let requireAuth = (req, res, next) => {
  if (req.user) {
    next();
  }
  else {
    res.sendStatus(401);
  }
};
module.exports = requireAuth;

This middleware function could be placed at different positions, depending on the application:

  • It can be used globally if authentication is required for all routes.
  • It can be used on a router-by-router basis if authentication is required for specific routers (like the “positives” routes in the case of the COVID application).
  • It can be used on a route-by-route basis if authentication is required only for specific routes (within routers).

You may test to use the middleware function by placing it at different positions in your application. If you submit a GET request without Authorization header to an endpoint that requires authentication, you should then get a response with a 401 Unauthorized status code. For this puspose, you may use the builtin HTTP client of WebStorm. If you use the require-auth middleware function globally and issue a GET request as illustrated below:

GET request (Client)

GET request (Client)

you should get a response with the 401 response code:

Response with 401 code

Response with 401 code

Adding the Authorization Header in the Client

For implementing the client behavior, you may now add an Authorization header to the GET request:

Basic Authorization Header (Client)

Basic Authorization Header (Client)

The content of your Authorization header should be the one given above: Authorization: Basic YmFja2VuZDp3ZWxvdmVpdA==. If you do so, you may observe that the req object in the require-auth function of your Express application handling the GET request contains the additional header:

Basic Authorization Header (Server)

Basic Authorization Header (Server)

Handling Authorization with a Middleware in Node.js

For handling the credentials that have been added in the request, the Express application must be modified. For enabling authorization for all routes or for specific routes, the use of a middleware function is a good choice. We may define the required middleware function in the “lib/basic-auth.js” file as follows:

lib/basic-auth.js
let basicAuth = (req, res, next) => {
  let header = req.headers.authorization || '';
  let [type, payload] = header.split(' ');

  console.log(type, payload);

  next();
};

module.exports = basicAuth;

If you add this middleware in your Express application, you should see the following output on the console

    Basic YmFja2VuZDp3ZWxvdmVpdA==

Remember that the placement of the basicAuth middleware is important, as for any middleware function. In this case, the placement must be before any route that requires authentication and before the require-auth middleware.

For implementing the Basic Authentication strategy, the authentication middleware must check the type and perform specific tasks in this case. The code below shows how to decode the password from the header payload in the basicAuth function:

lib/basic-auth.js
if (type === 'Basic') {
  // decode username and password
  let credentials = Buffer.from(payload, 'base64').toString('ascii');
  let [username, password] = credentials.split(':');
  console.log("username: " + username + " password: " + password);
}

If you add this code in your basicAuth function and run the application again, you should see the following message in the console when performing a GET request:

    Basic YmFja2VuZDp3ZWxvdmVpdA==
    username: backend password: weloveit

The basicAuth middleware function should now validate that the username and password are correct. Usually, usernames and passwords would be stored in a secure database. For our simple example, the usernames and passwords may be stored in a JSON file as plain text, which is of course not what should be done in a secure system. For this example, the JSON file contains the following data:

resources/users.json
[
  {
    "username": "backend",
    "password": "weloveit",
    "id": 1
  }
]

For completing the basicAuth function, you must write the findUserByCredentials function that checks whether the credentials passed as parameters are found in the list of users available from the JSON file. This function must import the users defined in the “resources/users.json” file and search for the corresponding user name. If the user name is found, it must then verify that the password is correct.

Define this function in the “lib/find-user.js” file, import it into your “lib/basic-auth.js” file and call the function from the basicAuth function. If you do so and add logging of the corresponding user info, you should see an output similar to this one on the console when issuing the previous GET request:

    Basic YmFja2VuZDp3ZWxvdmVpdA==
    backend password: weloveit
    Found:
      username = "backend"
      password = "weloveit"

In the case that the client request includes an Authorization header of the Basic type, the basicAuth middleware function should implement the following behaviors:

  • If the credentials are correct, it should store the information about the authenticated user in the request object req and continue to the next middleware.
  • If the credentials are wrong, it should not store information about the identified user. It should then continue to the next middleware. If the requireAuth middleware is used, it will halt the request and respond with a 401 Unauthorized status code, without continuing to the next middleware.

You must modify the basicAuth function so that it implements the behavior specified above. Once implemented, you may test your middleware by issuing GET requests with both correct and wrong credentials.

Splitting the implementation of the authentication process into the basicAuth and requireAuth functions is not only appropriate but also wise, because it allows to support authenticated routes and guest routes. It also supports the implementation of authentication methods different from the Basic one.

Decoupling findUserByCredentials from basicAuth

One of the limitations of the implementation of the basicAuth function described above is that it implements a specific way of checking for user credentials with the findUserByCredentials function. Should we rewrite the basicAuth function if we change the way of checking for users. The answer is obviously no !

So the question is: how can we separate both concerns ? The answer is through the use of the currying mechanism, as presented in the JavaScript codelab.

For using the currying mechanism in our basicAuth function, we need to use the currying principle in the basicAuth function definition, in a way that allows you to use the basicAuth middleware as shown below:

lib/index.js
...
const basicAuth = require("./lib/basic-auth");
const findUserByCredentials = require("./lib/find-user");
...
app.use(basicAuth(findUserByCredentials));
...

As you can read in the code above, the functionality of finding a user by his/her credentials is now separated from the functionality of checking authentication. The

Hashing the User Passwords

In the previous section, usernames and passwords were stored as plain text in a JSON file. Although this was done for demonstration purposes, one should not forget how catastrophically unsafe this approach is and one should invest the time and effort to make password storage safe, even for demonstration purposes.

In this section, we demonstrate how to hash the passwords with a library. For this purpose, you may use the bcrypt Node.js module.

The way to hash the password is demonstrated below:

lib/hash.js
const bcrypt = require('bcrypt');

// Tune how long it takes to hash a password.
// The longer, the more secure.
const saltRounds = 10;

// Generate a hashed version of the password.
// This is what should be stored in the JSON file
let hashed = await bcrypt.hash('weloveit', saltRounds);

Based on this, you need to replace the passwords in the JSON file with their hashed versions. Upon user authentication, you must use the bcrypt.compare method to compare it with the hashed password stored in the JSON file with the await bcrypt.compare(password, hashed) statement. Note that synchronous versions of the hash and compare methods also exist - while asynchronous methods have to be privileged because hashing is time consuming.

After implementing the required changes, test your implementation by issuing different GET requests.

Summary

The way basic authentication was added to the backend has the following advantages:

  1. It uses stateless authentication instead of introducing cookies and sessions.
  2. Authentication support is made using middleware for flexibility.
  3. Authentication check and enforcement are implemented in separated middleware functions, resp. in the basicAuth and the requireAuth functions.
  4. We decoupled user lookup logic by turning basicAuth into a curried function and by choosing the implementation of the findUserByCredentials function as a parameter of the basicAuth middleware function. This allows to separate the two concerns very clearly.
  5. Passwords are not stored as plain text but in their hashed version.

At this stage, you should have implemented a version of user authentication that checks user authentication with hashed password, with your own username and passwords.

The main drawback of the Basic Authentication scheme is that credentials must be sent with each HTTP request. This makes the surface area for security vulnerabilities grow as the backend application grows.

Besides security issues, there are also other disadvantages in sending credentials with each request, that may handicap the backend architecture and scaling options. Among others, we may mention:

  • For each request, the server must verify credentials. That can quickly become a performance bottleneck, since password hashing algorithms like bcrypt used above are secure because they are designed to be time consuming. That will noticeably delay every request and load the backend infrastructure.
  • It is difficult to scale the backend infrastructure across separate servers because each server must support user authentication, creating a central bottleneck.

Proof of Verification

As a citizen of your country of origin, you must prove your identity and citizenship in many different cases (like when creating a secure banking account). One way to do that would be to carry your birth certificate with you at all times. However, it is difficult and time consuming to verify these documents, since it requires access to the country specific citizen database.

Instead, most identity control systems will require an identity card or a passport to prove your identity and citizenship. Both identity cards and passports have built in security features that make them difficult to tamper with and relatively fast to verify - today they can easily be verified online for instance.

Of course, the actual documents still need to be verified, but only at the time you order your identity card or passport. That process can take weeks, but it only needs to happen when your identity card or passport expires. If your passport is stolen, it can be invalidated without compromising your birth certificate and the citizen database.

In other words, an identity card or a passport is proof that your documents and identity were verified.

JSON Web Tokens

Like a passport, a JSON Web Token (JWT) is a tamper resistant document that proves you have verified your identity using credentials like a username and password. To make authenticated HTTP requests, clients submit their username and password once to be issued a JWT. On all subsequent HTTP requests, the client includes the JWT instead of credentials.

To support token authentication, the backend needs to implement two different mechanisms:

  1. A route where a client exchanges credentials for a JWT.
  2. Middleware functions (like basicAuth) that checks for a valid JWT (rather than valid credentials) with every HTTP request.

Route for Issuing Tokens

The first mechanism that must be implemented is to deliver tokens on a specific route. For this purpose, we implement an Express router on the POST "/tokens" end point. Note that the request made by the client for the server to issue a token is a POST request and not a GET request. This respects the RESTful principles since issuing a token means that a resource must be created on the server side.

For implementing the token delivery service, first create a new router in the “routes/tokens.js” file:

lib/tokens.js
const express = require('express');
const bodyParser = require('body-parser');
const findUserByCredentials = require('../lib/find-user');

let createTokenRoute = (req, res) => {
  let credentials = req.body;
  let user = findUserByCredentials(credentials);
  console.log(user);
};

let tokensRouter = express.Router();

tokensRouter.post('/', bodyParser.json(), createTokenRoute);

module.exports = tokensRouter;

This router is used for serving the ‘/tokens’ route and this route must be added in the “index.js” file.

If you generate a POST request on the ‘/tokens’ endpoint, with a JSON request body specifying a username and a password, you should observe the following result in the debugger console:

Object {username: "backend", password: "$2b$10$ZOrjJbU3AVR7gtWNHuZF6ewXW3BMdMNjVzVsNWEDuf..jixG.McAW", id: 1}

Since at this point, the server does not send a response, the request will hang. However, if your credentials are correct, you should observe the correct log in the debugger console.

Once the user proves its identity, the backend should respond with a newly created token. This can be implemented as shown below with a createUnsignedToken() function. Note that for this particular example, you need to add an “id” property for each user in the “resources/users.json” file.

lib/tokens.js
const express = require('express');
const bodyParser = require('body-parser');
const findUserByCredentials = require('../lib/find-user');

let createUnsignedToken = (user) => 'I am user ' + user.id;

let createTokenRoute = (req, res) => {
  let credentials = req.body;
  let user = findUserByCredentials(credentials);
  console.log(user);
  if (user) {
    let token = createUnsignedToken(user);
    res.status(201);
    res.send(token);
  }
  else {
    res.sendStatus(401);
  }
};

let tokensRouter = express.Router();

tokensRouter.post('/', bodyParser.json(), createTokenRoute);

module.exports = tokensRouter;

Important

Given the code above, you need to verify that the token is delivered correctly to the client.

Exercice

Exercice Node.js Authentication and Authorization/1

From the documentation on HTTP status codes, understand why the 201 and 401 status codes are used in the respective cases in the example above.

Naturally, for a token to be useful from a security perspective, it must be difficult to forge. For this purpose, our createTokenRoute function should digitally sign the tokens before sending them to the client. Then, when a client presents this token on a future HTTP request, the backend can quickly check whether the token is authentic.

Signing Tokens

To generate a token that cannot be counterfeited, you must issue a cryptographically signed token. There are several Node.js modules delivering this functionality. We use the jsonwebtoken - npm Node.js package. This library takes care of the tricky security details behind JSON web Tokens. To issue a new token, we use the jwt.sign() method of this library. Note that this method can be used asynchronously or synchronously:

lib/tokens.js
const jwt = require('jsonwebtoken');

const signature = '1m_s3cure';
let createToken = (user) =>
  jwt.sign(
    { userId: user.id },
    signature,
    { expiresIn: '1d' });

Study the parameters of the sign() method in the jsonwebtoken - npm documentation. In the example above, it takes three arguments:

  • payload: A JavaScript object that identifies the user uniquely. An ID unique for each user may be enough, but other unique descriptions could also be used. Note that the payload will be coerced into a string in the sign() method.
  • signature: The secret that is used for signing the token. Only the backend should know this secret. If the secret is compromised, then tokens can be forged by other parties. Note that the signature depends on the algorithm used for signing.
  • options: A JavaScript object containing various configuration options. In our case, we simply specify that the token expires in one day. After expiration, the client will need to request a new JSON Web Token.

If you send again a POST request, you should get an answer similar to the response shown below. Observe the token that looks like a long string of random characters with a couple periods.

    HTTP/1.1 201 Created
    X-Powered-By: Express
    Content-Type: text/html; charset=utf-8
    Content-Length: 143
    ETag: W/"8f-Bhmy1ZbFty9clLbGlcC/bQnQSk8"
    Date: Thu, 20 Jan 2022 08:12:24 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY0MjY2NjMzNywiZXhwIjoxNjQyNzUyNzM3fQ.8a4KELHh0U0hGoommudjbANC-DFSDtCg_KMDKjAC2ms

This token string is not complete gobbledygook. It is in fact made of three sections that are separated by periods:

  • The first section is a Base-64 encoded string of the algorithm used to generate the token. With the default algorithm used in the example above, the first section encodes the following string: {"alg":"HS256","typ":"JWT"}
  • The middle section is a Base-64 encoded string of the payload - the first argument to the jwt.sign() method - along with an issue time and expiration time. The decoded string looks like this: {"userId":1,"iat":1642666337,"exp":1642752737}. In this string, times are given as current epoch Unix timestamps.
  • The last section is a cryptographic signature that proves that the first two sections haven’t been tampered with. Only the backend can generate an authentic digital signature since only the backend knows the secret.

Important

Given the code above, you need to verify that the signed token is delivered correctly to the client.

Document the algorithm that is used for signing the token. You may optionally choose a different algorithm than the default one.

Document the token received by your client and describe the content of each section.

Accepting JSON Web Tokens

Once a JWT has been issued upon client request, the client will provide the token on each successive request. Thus, the backend needs to support tokens as an alternative authentication method.

Rather than include an Authorization header with type Basic on each request, the client will use the Bearer type. Since we implemented authentication using middlewares, we may add a new middleware function used for validating tokens:

lib/token-auth.js
const jwt = require('jsonwebtoken');

const signature = '1m_s3cure';

let tokenAuth = (req, res, next) => {
  let header = req.headers.authorization || '';
  let [type, token] = header.split(' ');

  if (type === 'Bearer') {
    let payload = jwt.verify(token, signature);
    console.log(payload);
  }
  next();
};

module.exports = tokenAuth;

Modify the HTTP GET request by changing the Authorization type from Basic to Bearer and provide the token issued by the server in the POST ‘/tokens’ request. If you do so, you should observe the JWT payload in the debugger console:

Object {userId: 1, iat: 1672940045, exp: 1673026445}

At this point, the server knows who is sending the request. It also knows that the token is authentic, because if the token is tampered with, jwt.verify() will throw an exception. If the token is authentic, it will decode the Base-64 token, parse it as JSON and return it. As with basicAuth, we should still look up the user’s details and store them in req.user for the routes. As basicAuth must find users by credentials, tokenAuth must be able to find users by tokens (using the user id). For this purpose, you need to create a findUserByToken() function.

Important

You should complete the implementation of the backend issuing JSON Web Tokens as follows:

  • Complete the tokenAuth function for handling properly the exception that the jwt.verify() function can throw in case of invalid token. In the case of invalid token, the server must send a 401 status code.
  • Curry the tokenAuth function so that finding users is decoupled from the function itself, as we did for the basicAuth function.
  • Add a findUserByToken function in the “lib/find-user.js” file. This function allows to find a user based on userId. Also export this function.
  • Modify the use of the tokenAuth middleware in your “index.js” file.

When the implementation is complete, show that a client can obtain a JWT from the server and use it successfully in a GET request. Also, show that obtaining a JWT with wrong credentials fails with the correct status code and that using a tampered JWT also fails with the correct status code.