Introduction
Authenticating users to our application prevents the wrong people from gaining access to our service. Without strong authentication and proper implementation strategies, our service could be compromised. In this article, we would look at how to implement a custom authentication strategy in Fastify.
Fastify is a very fast, developer-friendly, and modular Node.js framework. Also, Fastify has a huge plugin ecosystem and it is fully extensible with decorators, plugins, and hooks. In this article, we would build our authentication strategy using the fastify-auth plugin.
Fastify-auth
The fastify-auth module is a Fastify plugin that provided a utility to handle authentication in routes without adding overhead. It gives us a way to compose multiple authentication strategies in Fastify. fastify-auth does not provide any authentication strategy consequently, we are required to provide our own via a decorator or another plugin.
Kindly consider the code below:
import fastifyAuth from 'fastify-auth';
fastify
.decorate('verifyJWT', function (request, reply, done) {
// validation logic goes here!
done() // pass an error if the authentication fails
})
.decorate('verifyUsernameAndPassword', function (request, reply, done) {
// validation logic goes here!
done() // pass an error if the authentication fails
})
.register(fastifyAuth))
.after(() => {
fastify.route({
method: 'GET',
url: '/multiple/auth',
preHandler: fastify.auth([
fastify.verifyJWT,
fastify.verifyUsernameAndPassword
]),
handler: (req, reply) => {
req.log.info('Auth route')
reply.send({ hello: 'world' })
}
})
})
The code above demonstrates the usage of the fastify-auth plugin. Here Fastify instance is decorated with two authentication strategies namely verifyJWT and verifyUsernameAndPassword. These authentication methods are then used in the preHandler hook to protect the endpoint as seen below:
...
preHandler: fastify.auth([
fastify.verifyJWT,
fastify.verifyUsernameAndPassword
]),
...
The relationship between these customized strategies is or; we could change it to and as seen below:
...
preHandler: fastify.auth([
fastify.verifyJWT,
fastify.verifyUsernameAndPassword
]),
{
relation: 'and'
}),
...
Also, in Fastify, routes are declared either as a plugin or within the .after() callback. In our implementation above we declared our routes in the .after() callback and we would use this convention as we build our custom authentication strategy. Let’s get started with the prerequisite in the next section.
Prerequisite
To follow along in this article, here are a few prerequisites to note:
- Node.js and MongoDB installed.
- Basic knowledge of MongoDB and Mongoose
- Basic knowledge of Node.js
Getting Started
To get started, we need to set up our server. Follow the steps below to create the server:
- Create an npm project and install all the needed dependencies:
# create project directory
mkdir <!--- project name -->
cd <!-- project name -->
# create npm project
npm init -y
# install dependencies
npm i fastify nodemon fastify-plugin bcryptjs mongoose fastify-auth jsonwebtoken
dotenv
- Enable es6 modules:
Simply add the following code to your package.json file — if you have Node version 12 and above installed.
However, if you have Node version 8 or 10 you can enable this feature by using esm.
- Add our scripts:
Open the package.json file and edit the scripts section as follows:
...
"scripts": {
"start": "node --es-module-specifier-resolution=node ./src/index.js",
"dev": "nodemon --es-module-specifier-resolution=node ./src/index.js"
}
...
- "start": "node --es-module-specifier-resolution=node src/index.js" is the production command to start our server it uses node.
- "dev": "nodemon --es-module-specifier-resolution=node ./src/index.js" uses nodemon to restart our server any time we compile our code.
The --es-module-specifier-resolution=node is required to aid interoperability between the es module — ESM and Node’s commonJS modules.
- Setup environment variables:
In the root directory create a .env file and add the following code:
PORT=5000
MONGODB_URI=mongodb://localhost:27017/fastify-auth
JWT_SECRET=mysupersecret1234567
- Setup our server:
In the root directory, create a src directory with an index.js file containing the following code:
import fastify from 'fastify';
import env from 'dotenv';
env.config();
const Port = process.env.PORT;
const uri = process.env.MONGODB_URI;
const app = fastify({ logger: true });
// Activate plugins below:
// create server
const start = async () => {
try {
await app.listen(Port);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
Now we can start the server by running npm run dev the outputs:
{"level":30,"time":1620743280976,"pid":8037,"hostname":"pc-name","msg":"Server listening at http://127.0.0.1:5000"}
Great our server is working. Let’s start implementing our custom authentication strategy in the next section.
Building Our Custom Authentication Strategy
In this section, we would start by building our models and connecting our app to MongoDB.
Create models
In the src directory create a config directory with an index.js file and a models, directory. Add the following codes to the index.js file.:
import fp from 'fastify-plugin';
import mongoose from 'mongoose';
import User from '../models/user';
const models = { User };
const ConnectDB = async (fastify, options) => {
try {
mongoose.connection.on('connected', () => {
fastify.log.info({ actor: 'MongoDB' }, 'connected');
});
mongoose.connection.on('disconnected', () => {
fastify.log.error({ actor: 'MongoDB' }, 'disconnected');
});
const db = await mongoose.connect(options.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true
});
// decorates fastify with our model
fastify.decorate('db', { models });
} catch (error) {
console.error(error);
}
};
export default fp(ConnectDB);
The code above connects our app to MongoDB using mongoose and logs the connection status to the console. Also, we decorate Fastify with our models to enhance code reuse. Also, inside the models directory, create a user.js file with the following codes:
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const userSchema = mongoose.Schema({
username: {
type: String,
required: true
},
password: {
type: String,
required: true
},
tokens: [
{
token: {
type: String,
required: true
}
}
]
});
// encrypt password using bcrypt conditionally. Only if the user is newly created.
// Hash the plain text password before saving
userSchema.pre('save', async function(next) {
const user = this;
if (user.isModified('password')) {
user.password = await bcrypt.hash(user.password, 8);
}
next();
});
userSchema.methods.generateToken = async function() {
let user = this;
const token = jwt.sign({ _id: user._id.toString() }, process.env.JWT_SECRET, { expiresIn: '72h' });
user.tokens = user.tokens.concat({ token });
await user.save();
return token;
};
// create a custom model method to find user by token for authenticationn
userSchema.statics.findByToken = async function(token) {
let User = this;
let decoded;
try {
if (!token) {
return new Error('Missing token header');
}
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return error;
}
return await User.findOne({
_id: decoded._id,
'tokens.token': token
});
};
// create a new mongoose method for user login authenticationn
userSchema.statics.findByCredentials = async (username, password) => {
const user = await User.findOne({ username });
if (!user) {
throw new Error('Unable to login. Wrong username!');
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
throw new Error('Unable to login. Wrong Password!');
}
return user;
};
const User = mongoose.model('user', userSchema);
export default User;
In our code above, the userSchema.pre hook is a middleware function used to hash our password using the bcrypt algorithm before the users are saved. Since storing passwords in our database in plain text is a terrible idea; we can securely store our passwords as hashed values. Also, in our schema above, we encapsulate our database logic within the data model by creating mongoose methods and statics. Statics are methods that can be invoked directly by a model — User; while Methods are invoked on an instance of a mongoose document — user.
The generateToken Method generates a new token using the user ID and our JWT_SECRET while the findByToken Statics method decodes a user token and finds the user using the ID and token as parameters. Lastly, the findByCredentials Statics method validates the user credentials — username and password and returns the user. These methods would be used in our authentication strategies in the next section.
Building our authentication strategies
In this section we will create two authentication strategies namely:
- asyncVerifyJWT — authenticates a route based on the provided token
- asyncVerifyUsernameAndPassword — authenticates a route based on the provided credentials — username and password.
We would create four routes to demonstrates the usage of these strategies. In the src directory create a routes folder with a users.js file. Add the following codes to the users.js file:
import FastifyAuth from 'fastify-auth';
import User from '../models/user';
const usersRoutes = async (fastify, opts) => {
fastify
.decorate('asyncVerifyJWT', async (request, reply) => {
try {
if (!request.headers.authorization) {
throw new Error('No token was sent');
}
const token = request.headers.authorization.replace('Bearer ', '');
const user = await User.findByToken(token);
if (!user) {
// handles logged out user with valid token
throw new Error('Authentication failed!');
}
request.user = user;
request.token = token; // used in logout route
} catch (error) {
reply.code(401).send(error);
}
})
.decorate('asyncVerifyUsernameAndPassword', async (request, reply) => {
try {
if (!request.body) {
throw new Error('username and Password is required!');
}
const user = await User.findByCredentials(request.body.username, request.body.password);
request.user = user;
} catch (error) {
reply.code(400).send(error);
}
})
.register(FastifyAuth)
.after(() => {
// our routes goes here
});
};
export default usersRoutes;
In the code above we decorate Fasitify with our two authentication strategies. The asyncVerifyJWT strategy gets the user token from the header and calls the findByToken statics method on the User model. The user is then passed to the request object making it accessible in the handler function — where our requests are handled. The asyncVerifyUsernameAndPassword strategy gets the user’s username and password from the request.body and passes them as arguments to the findByCredentials statics method. The returned user is then passed to the request object. Also, the .after() hook would contain all our routes let’s create them in the next section.
Building our routes
In the .after() in the code above add the following codes below the
// our routes goes here comment:
...
fastify.route({
method: [ 'POST', 'HEAD' ],
url: '/register',
logLevel: 'warn',
handler: async (req, reply) => {
const user = new User(req.body);
try {
await user.save();
const token = await user.generateToken();
reply.status(201).send({ user });
} catch (error) {
reply.status(400).send(error);
}
}
});
// login route
fastify.route({
method: [ 'POST', 'HEAD' ],
url: '/login',
logLevel: 'warn',
preHandler: fastify.auth([ fastify.asyncVerifyUsernameAndPassword ]),
handler: async (req, reply) => {
const token = await req.user.generateToken();
reply.send({ status: 'You are logged in', user: req.user });
}
});
// proifle route
fastify.route({
method: [ 'GET', 'HEAD' ],
url: '/profile',
logLevel: 'warn',
preHandler: fastify.auth([ fastify.asyncVerifyJWT ]),
handler: async (req, reply) => {
reply.send({ status: 'Authenticated!', user: req.user });
}
});
// logout route
fastify.route({
method: [ 'POST', 'HEAD' ],
url: '/logout',
logLevel: 'warn',
preHandler: fastify.auth([ fastify.asyncVerifyJWT ]),
handler: async (req, reply) => {
try {
req.user.tokens = req.user.tokens.filter((token) => {
return token.token !== req.token;
});
const loggedOutUser = await req.user.save();
reply.send({ status: 'You are logged out!', user: loggedOutUser });
} catch (e) {
res.status(500).send();
}
}
});
...
In our code above we have four routes namely:
- Register route /register: it is unauthenticated. It registers a new user and generates a token by calling the generateToken method.
- Profile route /profile: it is authenticated using the asyncVerifyJWT.
- Login route /login: typically login routes should not be authenticated but the pattern used here validates the user credentials using the asyncVerifyUsernameAndPassword strategy as seen here.
- Logout route /logout: it is authenticated using the asyncVerifyJWT.
Register plugins and test our endpoints
To register our plugins we first import them into the index.js file in the src directory as seen below:
...
import db from './config/index';
import users from './routes/users';
...
Then below add the following code below the // Activate plugins below: comment:
...
app.register(db, { uri });
app.register(users);
...
Next, we start our server by running mongod and npm run dev. This starts MongoDB and our application server.
Note in linux we may be required to run sudo mongod.
Now we can start testing our endpoint using curl and jq.
curl 'http://127.0.0.1:5000/register' -H 'content-type: application/json' \
-d '{"username": "eagles","password":"mypass"}' | jq
// returns
{
"user": {
"_id": "609a55357e794e1e9e29b6a3",
"username": "eagles",
"password": "$2a$08$PWAPCKkqYON9QYMXaGjT0uf31Mxhx6.3HX8tzxgwoPhlu8q5fCrbO",
"tokens": [
{
"_id": "609a55357e794e1e9e29b6a4",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDlhNTUzNTdlNzk0ZTFlOWUyOWI2YTMiLCJpYXQiOjE2MjA3MjcwOTMsImV4cCI6MTYyMDk4NjI5M30.U_W7fGgktPOI_-KgtTN49NYSU3dN301T6GGBy_7ulpE"
}
],
"__v": 1
}
}
// Without a token
curl 'http://127.0.0.1:5000/profile' | jq
// returns
{
"statusCode": 401,
"error": "Unauthorized",
"message": "No token was sent"
}
// With an invalid token
curl 'http://127.0.0.1:5000/profile' -H 'content type: application/json' \
-H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDk5MDA4MjQ4MmUzZTJjYjQ1M2Y5ODUiLCJpYXQiOjE2MjA2NDM3MjQsImV4cCI6MTYyMDkwMjkyNH0.3Z-gKoYO8ASpKxTG6yzYP5qq_HoZMR36ZbIU8Z-0y-E' | jq
// returns
{
"statusCode": 401,
"error": "Unauthorized",
"message": "Authentication failed!"
}
// With a vaild token
curl 'http://127.0.0.1:5000/profile' -H 'content type: application/json' \
-H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDlhNTUzNTdlNzk0ZTFlOWUyOWI2YTMiLCJpYXQiOjE2MjA3MjcwOTMsImV4cCI6MTYyMDk4NjI5M30.U_W7fGgktPOI_-KgtTN49NYSU3dN301T6GGBy_7ulpE' | jq
// returns
{
"status": "Authenticated!",
"user": {
"_id": "609a55357e794e1e9e29b6a3",
"username": "eagles",
"password": "$2a$08$PWAPCKkqYON9QYMXaGjT0uf31Mxhx6.3HX8tzxgwoPhlu8q5fCrbO",
"tokens": [
{
"_id": "609a55357e794e1e9e29b6a4",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDlhNTUzNTdlNzk0ZTFlOWUyOWI2YTMiLCJpYXQiOjE2MjA3MjcwOTMsImV4cCI6MTYyMDk4NjI5M30.U_W7fGgktPOI_-KgtTN49NYSU3dN301T6GGBy_7ulpE"
}
],
"__v": 1
}
}
curl 'http://127.0.0.1:5000/logout' -X POST -H 'content type: application/json' \
-H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDlhNTUzNTdlNzk0ZTFlOWUyOWI2YTMiLCJpYXQiOjE2MjA3MjcwOTMsImV4cCI6MTYyMDk4NjI5M30.U_W7fGgktPOI_-KgtTN49NYSU3dN301T6GGBy_7ulpE' | jq
// returns
{
"status": "You are logged out!",
"user": {
"_id": "609a55357e794e1e9e29b6a3",
"username": "eagles",
"password": "$2a$08$PWAPCKkqYON9QYMXaGjT0uf31Mxhx6.3HX8tzxgwoPhlu8q5fCrbO",
"tokens": [],
"__v": 2
}
}
curl 'http://127.0.0.1:5000/login' -H 'content-type: application/json' -d '{"username": "eagles","password":"mypass"}' | jq
// returns
{
"status": "You are logged in",
"user": {
"_id": "609a54a37e794e1e9e29b69d",
"username": "eagles",
"password": "$2a$08$JQQk/UAJLcia1wQL6swQvuPvzWuhUmKroRwiV7.bK3T0e1kJiX6vK",
"tokens": [
{
"_id": "609a5a897e794e1e9e29b6a6",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDlhNTRhMzdlNzk0ZTFlOWUyOWI2OWQiLCJpYXQiOjE2MjA3Mjg0NTcsImV4cCI6MTYyMDk4NzY1N30.43tuLF3wL3i0UvE1ouIYIynwFb2Dyw9z6duoua1kVvI"
}
],
"__v": 5
}
}
Conclusion
Fastify is a great framework and the developer experience is awesome. In this article, we have successfully built our custom authentication strategy however, It is good to mention that we can compose our strategies for a single route as seen below:
fastify.route({
method: [ 'GET', 'HEAD' ],
url: '/multi/auth',
logLevel: 'warn',
preHandler: fastify.auth([
fastify.asyncVerifyJWT,
fastify.asyncVerifyUsernameAndPassword ]),
handler: async (req, reply) => {
reply.send({ status: 'Authenticated!', user: req.user });
}
});
In the code above, fastify-auth will run all our authentication methods and continue the request if anyone succeeds. For a basic authentication strategy you can try out the fastify-basic-auth plugin and you can also get the full code for this app in the Github repository.