Authentication and authorization in back-end without boilerplate
In the modern world of multi-tenant web applications keeping user data safe and sound is the second most important thing after making sure your business logic code does work as intended. This article will cover main ideas of authentication and authorization, what should RESTful back-end do to handle it and implementing it in Node.js with Express.
Front-end theory
This article will not cover the handling of authentication and authorization on front-end in details but here is the main idea.
When a user logs into an application they should either create a session or receive a JWT token which should be sent with each request. This article will use a token approach.
A token is sent to a back-end which tries to get a user from a database and check permission for the requested action. If the token is invalid or the user doesn't have correct permissions then an error should be thrown.
First server
Let's create a minimalistic code without authentication and authorization with static data in a variable.
npm init -y && npm install express
Using favorite text editor write the following in index.js.
// index.js
const express = require('express');
const resources = [{id: 1, data: "First resource"}, {id: 2, data: "Second resource"}];
const app = express();
app.get('/resources/:resourceId', (req, res) => {
// + to convert string to number
const resource = resources.find(resource => resource.id === +req.params.resourceId);
if (!resource) {
res.status(404).send('Resource not found');
} else {
res.send(`Resource data: ${resource.data}`);
}
});
const port = 5000;
app.listen(port);
To run a node project simply write in a terminal.
node index.js
Though, this approach is not very comfortable for development purposes as on every change the server should be restarted. Therefore nodemon
will be used which run node server and reloads it on every change of files.
npm install nodemon -g
nodemon index.js
In the second terminal, you can check that the server responds correctly using curl
.
curl localhost:5000/resources/1
Resource data: First resource
Note: depending on the system
curl
may be producing more output than just the response. To reduce amount of clutter--silent
flag can be used.
Adding authentication
Let's imagine front-end puts JWT token that contains only userId field. It will be put into header Authentication in the format of Bearer token. This article will not cover how to issue tokens, though there is good article about that. Now it's time to make a function that validates token using jsonwebtoken
library which should be installed beforehand.
npm install jsonwebtoken
// authentication.js
const jwt = require('jsonwebtoken');
module.exports = (req, res) => {
try {
const token = req.header('Authentication').split(' ')[1];
const decoded = jwt.verify(token, 'secret');
req.userId = decoded.userId; // putting userId into request object
return true;
} catch (err) {
console.log(`Unauthenticated access to ${req.originalUrl}`);
res.status(404).end();
return false;
}
}
Note:
secret
is the plain string used to sign and verify tokens symmetrically. It should be set through an environmental variable and never be visible to anyone.
This function may be used to check if a request contains valid token, otherwise, it sends 404 Not Found instead of 403 Forbidden to not disclose more information than needed and returns boolean showing result of authentication. Also, it sets userId inside valid request that the following functions can get it directly.
// index.js
const express = require('express');
const isAuthenticated = require('./authentication.js');
const resources = [{id: 1, data: "First resource"}, {id: 2, data: "Second resource"}];
const app = express();
app.get('/resources/:resourceId', (req, res) => {
if (!isAuthenticated(req, res)) {
return;
}
const resource = resources.find(resource => resource.id === +req.params.resourceId);
if (!resource) {
res.status(404).end();
} else {
res.send(`Resource data: ${resource.data}`);
}
});
const port = 5000;
app.listen(port);
Trying to make the same request will result in an empty response.
curl localhost:5000/resources/1 -s
# in node logs
Unauthenticated access to /resources/1
There is no token and therefore verification fails, to pass through it a valid token should be given. Using official JWT website generate token with content {"userId":1}
and signing string secret
and use it inside request.
curl localhost:5000/resources/1 \
-H "Authentication: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.s4vE0w6cUg68FMf7GjCRpweMCQ92MdFjYM5apky7MHE"
Resource data: First resource
This is the working authentication method which is currently applied only to this endpoint. But authentication should be applied to all endpoints except a couple of them (like /login and /registration). To call some function before certain endpoints it is possible to use middleware.
Introducing middleware
Middleware - some code that is executed between receiving a request and responding to the client. It can be chained and be used on all or only specific set of endpoints. In other languages it may be named differently, e.g. in Java, it is known as aspects.
As an example, there is a simple middleware that adds some data with examples on different scope attachments.
const express = require('express');
const app = express();
const middlewareFunc = (req, res, next) => {
req.answer = 42; // request object will be passed further with answer field set
next(); // never forget to call next() to proceed with call stack execution
}
// call middleware before each request
app.use(middlewareFunc);
// call middleware before all requests starting with /resources
app.use('/resources', middlewareFunc);
// call middleware before all GET requests
app.get(middlewareFunc);
// call middleware before all requests with URL exactly /resources/:resourceId
app.all('/resources/:resourceId', middlewareFunc);
Middleware function takes three arguments: request, response, and the next callback which conventionally called req
, res
, and next
. Next callback must be called to continue to the next middleware or endpoint. Not calling next callback and calling res.end()
is a simple way to break the normal flow of execution in case something went wrong.
Middleware allows doing more things than just authentication or authorization, one of the most prominent examples is body parsing. Front-end responds with a single string that should be converted according to Content-Type
header as JSON or plain form.
Middleware has the order of execution, therefore if some middleware depends on the result of another they should be in the correct order. Also, it is possible to add middleware after function if something should be done to the data after business logic.
Using newly acquired middleware knowledge let's rewrite the authentication flow to use it.
// authentication.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
try {
const token = req.header('Authentication').split(' ')[1];
const decoded = jwt.verify(token, 'secret');
req.userId = decoded.userId;
next();
} catch (err) {
console.log(`Unauthenticated access to ${req.originalUrl}`);
res.status(404).end();
}
}
// index.js
const express = require('express');
const authentication = require('./authentication.js');
const resources = [{id: "1", data: "First resource"}, {id: "2", data: "Second resource"}];
const app = express();
app.use(authentication); // authentication is checked on every call to the server
app.get('/resources/:resourceId', (req, res) => {
const resource = resources.find(resource => resource.id === req.params.resourceId);
res.send(`Resource data: ${resource.data}`);
});
const port = 5000;
app.listen(port);
Note: Every request is required to have valid authentication, even the ones going to /login or /register endpoints. To alleviate the problem it is possible to add handlers to these requests before attaching middleware to let
Express
handle them before hitting authentication.
Adding authorization
To check authorization there should be a piece of information connecting a resource to the user, namely userId
for each resource. It will be implemented as a middleware which checks for authorization and sets req.resource
to the actual resource and then attaching getResource
handler that just returns resource.data
in this example.
// resources.js
const resources = [{id: "1", data: "First resource", userId: 1}, {id: "2", data: "Second resource", userId: 2}];
const resourceMiddleware = (req, res, next) => {
const resource = resources.find(resource => resource.id === req.params.resourceId);
if (!resource) {
res.status(404).end();
} else if (resource.userId !== req.userId) {
console.log(`Unauthorized access to resource ${req.params.resourceId} from user ${req.userId}`);
res.status(404).end();
} else {
req.resource = resource;
next();
}
};
const getResource = (req, res) => {
res.send(`Resource data: ${req.resource.data}`);
};
// export function that takes express app as argument and setup everything
module.exports = (app) => {
app.use('/resources/:resourceId', resourceMiddleware);
app.get('/resources/:resourceId', getResource)
}
// index.js
const express = require('express');
const authentication = require('./authentication.js');
const setupResources = require('./resources.js');
const app = express();
app.use(authentication);
setupResources(app);
const port = 5000;
app.listen(port);
Returning to the terminal and testing the behavior.
# authorized request
curl localhost:5000/resources/1 \
-H "Authentication: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.s4vE0w6cUg68FMf7GjCRpweMCQ92MdFjYM5apky7MHE"
Resource data: First resource
# unauthorized request
curl localhost:5000/resources/2 \
-H "Authentication: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.s4vE0w6cUg68FMf7GjCRpweMCQ92MdFjYM5apky7MHE"
# in node logs
Unauthorized access to resource 2 from user 1
Proving scalability
Let's add one more endpoint to showcase the ease of writing them with middleware attached. The new endpoint will respond to the creation of new resource via POST with data send as JSON. Express is not handling parsing by itself but have well-defined middleware called body-parser
that is installed separately.
npm install body-parser
Changing the content of the files accordingly.
// resources.js
const resources = [{id: 1, data: "First resource", userId: 1}, {id: 2, data: "Second resource", userId: 2}];
const resourceMiddleware = (req, res, next) => {
const resource = resources.find(resource => resource.id === +req.params.resourceId);
if (!resource) {
res.status(404).end();
} else if (resource.userId !== req.userId) {
console.log(`Unauthorized access to resource ${req.params.resourceId} from user ${req.userId}`);
res.status(404).end();
} else {
req.resource = resource;
next();
}
};
const getResource = (req, res) => {
res.send(`Resource data: ${req.resource.data}`);
};
const createResource = (req, res) => {
if (!req.body.data) {
res.status(400).send("No data provided");
return;
}
resources.push({
id: resources.length + 1,
data: req.body.data,
userId: req.userId
});
res.status(201).send("Resource created");
};
module.exports = (app) => {
app.use('/resources/:resourceId', resourceMiddleware);
app.get('/resources/:resourceId', getResource)
app.post('/resources', createResource);
}
// index.js
const express = require('express');
const bodyParser = require('body-parser');
const authentication = require('./authentication.js');
const setupResources = require('./resources.js');
const app = express();
app.use(bodyParser.json());
app.use(authentication);
setupResources(app);
const port = 5000;
app.listen(port);
Testing new endpoint.
# valid create and get requests
curl -X POST localhost:5000/resources \
-d '{"data":"Third resource"}' \
-H "Content-Type: application/json" \
-H "Authentication: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.s4vE0w6cUg68FMf7GjCRpweMCQ92MdFjYM5apky7MHE"
Resource created
curl localhost:5000/resources/3 \
-H "Authentication: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.s4vE0w6cUg68FMf7GjCRpweMCQ92MdFjYM5apky7MHE"
Resource data: Third resource
# unauthenticated attempt to create resource
curl -X POST localhost:5000/resources \
-d '{"data":"Third resource"}' \
-H "Content-Type: application/json"
# in node terminal
Unauthenticated access to /resources
Ending words
Using middleware it is possible to reduce boilerplate code regarding data parsing, authentication or additional data appending easier and work on as big or small scope as required. Whenever you come across repetitive code in your endpoints, then ask yourself a question: "Can it be moved into middleware level?" That way actual business logic inside endpoints will remain less diluted and easier to navigate.
Top comments (0)