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:
As you can observe in this diagram, the flow of messages is as follows:
- 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 aWWW-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 arealm
describing the protected area. - 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. - 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:
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:
you should get a response with the 401
response code:
Adding the Authorization Header in the Client
For implementing the client behavior, you may now add an Authorization
header to the
GET request:
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:
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:
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:
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:
[
{
"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 a401
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:
...
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:
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:
- It uses stateless authentication instead of introducing cookies and sessions.
- Authentication support is made using middleware for flexibility.
- Authentication check and enforcement are implemented in separated middleware
functions, resp. in the
basicAuth
and therequireAuth
functions. - We decoupled user lookup logic by turning
basicAuth
into a curried function and by choosing the implementation of thefindUserByCredentials
function as a parameter of thebasicAuth
middleware function. This allows to separate the two concerns very clearly. - 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:
- A route where a client exchanges credentials for a JWT.
- 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:
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.
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:
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 thesign()
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:
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 thejwt.verify()
function can throw in case of invalid token. In the case of invalid token, the server must send a401
status code. - Curry the
tokenAuth
function so that finding users is decoupled from the function itself, as we did for thebasicAuth
function. - Add a
findUserByToken
function in the “lib/find-user.js” file. This function allows to find a user based onuserId
. 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.