DEV Community

Cover image for An express authentication gateway with passport and JWT
 Mat Kwa
Mat Kwa

Posted on

An express authentication gateway with passport and JWT

This is an authentication gateway to a microservices backend.

Here is the link to the repository:
https://github.com/p-michael-b/ExpressAuthGateway

It's a hybrid solution between a monolithic architecture and a true microservices backend. For client-side authentication, this gateway uses passportJS and sessions, the server-side authentication to the microservices is being handled by JSON Web Token (JWT). The sessions, validation tokens and user information (operators) get stored in a schema called auth of a Postgres database.

Please refer to the following Entity Relationship Diagram (ERD) and set up the database accordingly.

Auth ERD

For query building, knexJS is being used, which also offers a convenient session storage solution. The knex connection is configured in a separate knexfile with options to have different parameters in the development and production environment (here only debug logs are different):

// Initialize a PostgreSQL database connection using Knex for the 'auth' schema.
module.exports = {
    development: {
        client: 'pg',
        searchPath: 'auth',
        connection: {
            database: process.env.DB_NAME,
            user: process.env.DB_USER,
            password: process.env.DB_PASSWORD,
            host: process.env.DB_HOST,
            port: process.env.DB_PORT,
        },
        pool: {
            min: 2,
            max: 10
        },
        debug: true
    },
    production: {
        client: 'pg',
        searchPath: 'auth',
        connection: {
            database: process.env.DB_NAME,
            user: process.env.DB_USER,
            password: process.env.DB_PASSWORD,
            host: process.env.DB_HOST,
            port: process.env.DB_PORT,
        },
        pool: {
            min: 2,
            max: 10
        },
        debug: false
    }
}

Enter fullscreen mode Exit fullscreen mode

The current functionality of the gateway can best be illustrated by looking at the endpoints, or routes it provides, which can be found in the auth_routes.js file. Some of the routes need to be protected, this is handled by a middleware which checks if the request is authenticated via passport.

// Import the Express.js framework
const express = require('express');

// Create an instance of the Express Router.
const router = express.Router();

// Import the local authentication controller.
const authController = require('../controllers/auth_controller');


//middleware for protecting the routes
function isAuthenticated(req, res, next) {
    if (req.isAuthenticated()) {
        return next();
    }
    return res.status(403).json({
        status: 403,
        success: false,
        message: 'Unauthenticated. Please log in first',
        data: [],
    });
}

//Authentication routes

//Login endpoint for acquiring authentication. Sets JWT token to authenticate with microservices
//not a protected route, the user is just logging in
router.post('/login', authController.login);

//Logout endpoint for exiting the apebase
//This must be a protected route
router.get('/logout', isAuthenticated, authController.logout);

//Forgot endpoint for sending password reset email.
//not a protected route, user cannot login, requests password reset email
router.post('/forgot', authController.forgot);

//set password endpoint for setting new password.
//not a protected route, user received password reset email and is verified by token
router.post('/password', authController.password);

//update operator endpoint for setting new operator name.
//This must be a protected route
router.post('/operator', isAuthenticated, authController.operator);

//probe operator endpoint for probing availability of new operator name.
//This must be a protected route
router.post('/probe', isAuthenticated, authController.probe);

//invite friend endpoint for sending invitation email.
//This must be a protected route
router.post('/invite', isAuthenticated, authController.invite);

//welcome endpoint for retrieving email address from token.
//not a protected route, welcoming user from token and sending back email
router.post('/welcome', authController.welcome);

//welcome endpoint for retrieving email address from token.
//not a protected route, registering operator name and password
router.post('/init', authController.init);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Together with the mail microservice, which you can find in one of my previous articles, the gateway manages the entire authentication workflow for a regular web application:

  • Operator login (with email and password)
  • Operator logout
  • Request Password reset email (with email)
  • Update Password (with token and password)
  • Update Operator Name (with name)
  • Probe for Existing Operator Name (with name)
  • Send Invitation Email to New Operator (with email)
  • Retrieve Operator Email from Invitation Token (with token)
  • Initialize New Operator (with name, password and token)

The app.js file is quite standard for an express app, in the first part, all the required dependencies get imported, we implement a simple check for required environment variables. These must be set correctly in the .env file, for the gateway to function properly:

// Load environment variables from a .env file
require('dotenv').config();

// Import the Express.js framework
const express = require('express');

// Import morgan middleware for request logging (for debugging and monitoring)
const morgan = require('morgan');

// Import cors middleware for enabling Cross-Origin Resource Sharing
const cors = require('cors');

// Import helmet middleware for enhancing security by setting various HTTP headers
const helmet = require('helmet');

// Import passport for authentication of clientside gateway requests.
const passport = require('passport');

// Import express-session for clientside session management.
const session = require('express-session');

// Import connect-session-knex for clientside session storage.
const knexSessionStore = require('connect-session-knex')(session);

// Define the port for your Express app, allowing it to be set through an environment variable
const PORT = process.env.SERVER_PORT || 5001;

// Creating an Express application instance.
const app = express();

// Creating an HTTP server instance to serve our Express app.
const server = require('http').Server(app);

// Parsing incoming JSON data in requests using Express middleware.
app.use(express.json());

// Configuring Cross-Origin Resource Sharing (CORS) middleware with credentials and origin options.
// credentials flag allows the client to send the session info in the header
// origin flag allows the server to reflect (enable) the requested origin in the CORS response
// this is a low security origin flag, should be changed in production
app.use(cors({credentials: true, origin: true}))

// Enhancing security by applying Helmet middleware for HTTP header protection.
app.use(helmet());

//Importing the environment variables and making sure they are all available
const requiredEnvVars = ['JWT_SECRET', 'COOKIE_SECRET', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_PORT', 'SERVER_PORT', 'PRODUCTION_ROOT'];
for (const envVar of requiredEnvVars) {
    if (!process.env[envVar]) {
        console.error(`Missing required environment variable: ${envVar}`);
        process.exit(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the second part of the main app file, we configure the database connection via knexfile and implement the required session store and middleware for client-side validation.

Here passport gets configured and the localStrategy imported from separate file. Additional middleware for knex availability in the routes and logging follow. Finally the authentication routes (auth_routes) are made available and a standard health route is being implemented.

// Load the Knex configuration from the 'knexfile.js' file based on the current environment.
const knexConfig = require('./knexfile.js')[process.env.NODE_ENV];

// Initialize a Knex instance using the loaded configuration for database operations.
const knex = require('knex')(knexConfig);

// Create a session store using the configured Knex connection for session management.
const store = new knexSessionStore({knex: knex,});

//For session lifecycle
const oneDay = 1000 * 60 * 60 * 24;

// Set up session middleware with the defined session store and configuration options.
app.use(session({
    store: store,
    name: 'session',
    secret: process.env.COOKIE_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        maxAge: oneDay
    }
}));

// Import the local authentication strategy for user login.
const localStrategy = require('./localStrategy.js');

// Configure Passport to use the local authentication strategy for user login.
passport.use(localStrategy);

// Initialize Passport to enable authentication in the application.
app.use(passport.initialize());

// Enable Passport session support to maintain user sessions.
app.use(passport.session());

// Define the serialization process for Passport user sessions.
passport.serializeUser(function (user, done) {
    process.nextTick(function () {
        return done(null, user)
    });
});
// Define the deserialization process for Passport user sessions.
passport.deserializeUser(function (user, done) {
    process.nextTick(function () {
        return done(null, user);
    });
});

// Middleware to make the Knex instance available in the request object.
app.use((req, res, next) => {
    req.knex = knex;
    next();
});

// Setting up Morgan middleware to log Authentication Service requests.
app.use(morgan('\n********** AUTH SERVICE REQUEST **********\n' +
    'Date       :date[iso]\n' +
    'Request    :method :url\n' +
    'Status     :status\n' +
    'Response   :response-time ms\n' +
    'Remote IP  :remote-addr\n' +
    'HTTP ver.  :http-version\n' +
    'Referrer   :referrer\n' +
    'User Agent :user-agent\n' +
    '********** END REQUEST **********\n\n'));

//after passport can use routes
const authRoutes = require('./routes/auth_routes');

// Use the 'authRoutes' middleware for routes under the '/auth' path.
app.use('/auth', authRoutes);

// Health Route to provide health check for the authentication gateway
app.get('/', (req, res) => {
    return res.status(200).json({
        success: true,
        message: 'The auth gateway',
    });
})

// Starting the server and listening on the specified port, logging the listening status.
server.listen(PORT, () => console.log(`server listening on port ${PORT}`));

Enter fullscreen mode Exit fullscreen mode

The local strategy for passport retrieves an existing operator for the provided email address from our database, if available, and uses bcrypt to compare the provided password with the hashed password stored in the database for the respective operator.

// Import the bcrypt library for hashing and verifying passwords.
const bcrypt = require('bcrypt');

// Import the LocalStrategy from 'passport-local' for local authentication.
const LocalStrategy = require('passport-local').Strategy;

// Load the Knex configuration from the 'knexfile.js' file based on the current environment.
const knexConfig = require('./knexfile.js')[process.env.NODE_ENV];

// Initialize a Knex instance using the loaded configuration for database operations.
const knex = require('knex')(knexConfig);

/**
 * Local authentication strategy using Passport for user login.
 * @param {string} email - The user's email address.
 * @param {string} password - The user's password.
 * @returns {function} - An asynchronous function that handles user login and authentication.
 */

module.exports = new LocalStrategy({usernameField: 'email', passwordField: 'password'}, async (email, password, done) => {
    knex.select('_id', 'email', 'password', 'operator')
        .from('auth.operators')
        .where({email: email})
        .first()
        .then((operator) => {
            if (!operator) {
                return done(null, null, 'Operator not found');
            }
            bcrypt.compare(password, operator.password, function (error, isMatch) {
                if (error) {
                    return done(error, null, 'Bcrypt error');
                }
                if (!isMatch) {
                    return done(null, null, 'Password does not match');
                }
                return done(null, operator, 'Successfully authenticated');
            });
        })
        .catch((error) => {
            return done(error, null, 'knex error');
        });
});
Enter fullscreen mode Exit fullscreen mode

When the operator logs in successfully this gets stored in the session, and the user is now authenticated, thereby passing the middleware check in the routes.

// Authenticate a user using Passport's 'local' strategy, create a session, and sign the session with a JWT upon successful login.
const login = async (req, res, next) => {
    passport.authenticate('local', async (error, authenticatedUser) => {
        if (!authenticatedUser) {
            return res.status(400).json({
                success: false,
                message: 'You shall not pass!',
            });
        }
        req.login(authenticatedUser, async (error) => {
            if (error) return next(error)

            return res.status(200).json({
                success: true,
                message: 'Login successful',
                data: {
                    user: authenticatedUser
                }
            });
        })
    })(req, res, next);
}

Enter fullscreen mode Exit fullscreen mode

Compared to the authentication process (login) the other endpoints are trivial. They involve accessing the database, mail and validation service. The error management in the controller is standardized, so that we can get the errors passed down from the services, to the controller, all the way back to the client.

// Log out the user by clearing their session.
const logout = async (req, res) => {
    req.logout(() => {
        req.session.destroy(() => {
            return res.status(200).json({
                success: true,
                message: 'The operator has been logged out'
            })
        })
    })
}

/**
 * Handle the 'forgot password' process:
 * 1. Retrieve the recipient email from the request.
 * 2. Check if the email is valid.
 * 3. Check if the email corresponds to a registered operator.
 * 4. Generate a reset token and a link for the reset process.
 * 5. Send a password reset email with the reset link.
 * 6. Handle success and error responses accordingly.
 */
const forgot = async (req, res) => {
    const {mailRecipient} = req.body;
    const knex = req.knex;
    try {
        const validEmail = await validationService.validateEmail(mailRecipient);
        if (!validEmail) {
            throw new Error("That's not a valid email!");
        }
        const operator = await databaseService.getOperatorByEmail(knex, mailRecipient);
        if (!operator) {
            return res.status(200).json({
                success: true,
                message: 'If that was your email, you will soon get mail.',
            });
        }
        const token = databaseService.generateToken();
        const link = databaseService.generateLink('forgot', token);
        const mailSubject = "Of course I forget things, it's not like I have an elephant for a brain."
        const mailText = `You have requested password reset for your apebase account. If it wasn't you, please let us know. Here is your link:

    ${link}`
        await databaseService.setResetToken(knex, operator._id, token);
        const response = await mailService.sendMail(mailRecipient, mailSubject, mailText);
        const responseData = await response.json();
        if (response.ok) {
            return res.status(200).json({
                success: true,
                message: 'If that was your email, you will soon get mail.',
            });
        } else {
            return res.status(response.status).json({
                success: false,
                message: responseData.error
            });
        }

    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message,
        });
    }
}

/**
 * Handle the password reset process:
 * 1. Retrieve the reset token and new password from the request.
 * 2. Validate the new password.
 * 3. Check if the reset token is valid and exists.
 * 4. Hash the new password.
 * 5. Update the user's password with the hashed password.
 * 6. Return a response indicating the success or failure of the password update.
 */
const password = async (req, res) => {
    const {token, password} = req.body;
    const knex = req.knex;
    try {
        const validPassword = await validationService.validatePassword(password);
        if (!validPassword) {
            throw new Error("That's not a valid password!");
        }
        const tokenRecord = await databaseService.getResetTokenRecord(knex, token);
        if (!tokenRecord) {
            throw new Error("This token isn't valid. Tokens time out after an hour.");
        }
        const hash = await bcrypt.hash(password, saltRounds);
        await databaseService.updatePassword(knex, hash, tokenRecord);
        return res.status(200).json({
            success: true,
            message: 'Password updated successfully',
        });
    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message,
        });
    }
}

/**
 * Handle the creation or update of an operator's name:
 * 1. Retrieve the operator name from the request.
 * 2. Validate the operator name for correctness.
 * 3. Check if the operator name is already taken in the database.
 * 4. Create or update the operator's name if it's available.
 * 5. Return a response indicating success or an error message.
 */
const operator = async (req, res) => {
    const {operator} = req.body;
    const knex = req.knex;
    try {
        const validOperator = await validationService.validateOperator(operator);
        if (!validOperator) {
            throw new Error("That's not a valid name.");
        }
        const operatorRecord = await databaseService.getOperatorByName(knex, operator);
        if (operatorRecord) {
            throw new Error("This name is taken.");
        } else {
            await databaseService.updateOperator(knex, operator, req.user._id);
            return res.status(200).json({
                success: true,
                message: 'Stand and be recognized ' + operator
            });
        }
    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message
        });
    }
}

/**
 * Handle the creation or update of an operator's name:
 * 1. Retrieve the operator name from the request.
 * 2. Check if the operator name is already taken in the database.
 * 3. Create or update the operator's name if it's available.
 * 4. Return a response indicating success or an error message.
 */
const probe = async (req, res) => {
    const {operator} = req.body;
    const knex = req.knex;
    try {
        const operatorRecord = await databaseService.getOperatorByName(knex, operator);
        if (operatorRecord) {
            throw new Error("This name is taken.");
        } else {
            return res.status(200).json({
                success: true,
                message: 'Name ' + operator + ' is available'
            });
        }
    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message
        });
    }
}

/**
 * Handle the invitation process for a new operator:
 * 1. Check if the operator's email already exists in the database.
 * 2. If not, generate an invitation token and link.
 * 3. Send an invitation email to the recipient.
 * 4. Return a response indicating success or an error message.
 */
const invite = async (req, res) => {
    const {mailRecipient} = req.body;
    const knex = req.knex;
    try {
        const validEmail = await validationService.validateEmail(mailRecipient);
        if (!validEmail) {
            throw new Error("That's not a valid email!");
        }
        const operatorRecord = await databaseService.getOperatorByEmail(knex, mailRecipient);
        if (operatorRecord) {
            throw new Error("This operator has already joined.");
        }
        const token = databaseService.generateToken();
        const link = databaseService.generateLink('welcome', token);
        const mailSubject = `The fate of our digital world rests in your hands.`
        const mailText = `A friend has invited you to join the apebase. Here is your link:

    ${link}`
        await databaseService.setInviteToken(knex, mailRecipient, token);
        await mailService.sendMail(mailRecipient, mailSubject, mailText);
        return res.status(200).json({
            success: true,
            message: 'Your invitation has been extended.',
        });
    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message
        });
    }
}

/**
 * Handle the welcome process for an invited operator:
 * 1. Check if an operator with the provided token exists in the database.
 * 2. If found, respond with a success message containing the operator's email.
 * 3. If not found, respond with an error message indicating no invitation exists for the token.
 */
const welcome = async (req, res) => {
    const {token} = req.body;
    const knex = req.knex;
    try {
        const operator = await databaseService.getOperatorByToken(knex, token);
        if (!operator) {
            throw new Error("There is no invitation for this token.");
        } else {
            return res.status(200).json({
                success: true,
                message: operator.email
            });
        }
    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message
        });
    }
}


/**
 * Initialize an operator using a provided token and credentials:
 * 1. Check if the operator name already exists in the database.
 * 2. Validate the operator name.
 * 3. Validate the password.
 * 4. Verify the validity of the invitation token.
 * 5. Hash the provided password.
 * 6. Initialize the operator using the valid name, hashed password, and the token record.
 * 7. Respond with a success message if the operator is successfully initialized.
 * 8. Handle and respond to any errors that may occur during the initialization process.
 */
const init = async (req, res) => {
    const {token, password, operator} = req.body;
    const knex = req.knex;

    try {
        const validOperator = await validationService.validateOperator(operator);
        if (!validOperator) {
            throw new Error("That's not a valid name.");
        }
        const existingOperator = await databaseService.getOperatorByName(knex, operator);
        if (existingOperator) {
            throw new Error("This name is taken.");
        }
        const validPassword = await validationService.validatePassword(password);
        if (!validPassword) {
            throw new Error("That's not a valid password.");
        }
        const tokenRecord = await databaseService.getInviteTokenRecord(knex, token);
        if (!tokenRecord) {
            throw new Error("This invitation is invalid.");
        }
        const hash = await bcrypt.hash(password, saltRounds);
        //We must pass both the token and tokenRecord because we also delete the token
        await databaseService.initOperator(knex, operator, hash, tokenRecord);
        return res.status(200).json({
            success: true,
            message: 'Operator successfully initialized.',
        });
    } catch (error) {
        return res.status(500).json({
            success: false,
            message: error.message
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

The database service handles all CRUD requests to the database. These are all simple queries, with the exception of setting the invite token, updating the password and initializing the operator, where we require transactions. Both operations are very straightforward with knex.

// Import the crypto module for cryptographic operations.
const crypto = require("crypto");

// Determine the 'root' variable based on the environment (development or production).
const root = (process.env.NODE_ENV === 'development') ? 'http://localhost:3000' : process.env.PRODUCTION_ROOT

/**
 * Get an operator by email from the 'operators' table.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} email - The email of the operator to retrieve.
 * @returns {Promise<Object>} - A promise that resolves to the operator with the specified email.
 * @throws {Error} - If there's an error during the database query.
 */

const getOperatorByEmail = async (knex, email) => {
    try {
        return await knex.select('*').from('operators').where('email', email).first();
    } catch (error) {
        throw new Error(error.message);
    }
}


/**
 * Get an operator by name from the 'operators' table.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} name - The email of the operator to retrieve.
 * @returns {Promise<Object>} - A promise that resolves to the operator with the specified email.
 * @throws {Error} - If there's an error during the database query.
 */

const getOperatorByName = async (knex, name) => {
    try {
        return await knex.select('*').from('operators').where('operator', name).first();
    } catch (error) {
        throw new Error(error.message)
    }
}

/**
 * Retrieve an operator by their token from the 'tokens' and 'operators' tables.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} token - The token associated with the operator.
 * @returns {Promise<Object>} - A promise that resolves to the operator linked to the provided token.
 * @throws {Error} - If there is an error during the database query.
 */

const getOperatorByToken = async (knex, token) => {
    try {
        return await knex
            .select('*')
            .from('tokens')
            .join('operators', 'tokens._fk_operator', 'operators._id')
            .where('tokens.token', token)
            .first();
    } catch (error) {
        throw new Error(error.message);
    }
}

/**
 * Retrieve a reset token record from the 'auth.tokens' table.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} token - The reset token to look up.
 * @returns {Promise<Object>} - A promise that resolves to the reset token record.
 * @throws {Error} - If there is an error during the database query.
 */

const getResetTokenRecord = async (knex, token) => {
    try {
        return await knex('auth.tokens')
            .where('token', token)
            .where('created_at', '>', knex.raw('NOW() - INTERVAL \'1 HOUR\''))
            .first();
    } catch (error) {
        throw new Error(error.message)
    }
}

/**
 * Retrieve an invite token record from the 'auth.tokens' table.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} token - The invite token to look up.
 * @returns {Promise<Object>} - A promise that resolves to the invite token record.
 * @throws {Error} - If there is an error during the database query.
 */

const getInviteTokenRecord = async (knex, token) => {
    try {
        return await knex('auth.tokens')
            .where('token', token)
            .first();
    } catch (error) {
        throw new Error(error.message)
    }
}

/**
 * Set a reset token for a given operator in the 'tokens' table.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {number} operator - The operator's ID.
 * @param {string} token - The reset token to be stored.
 * @throws {Error} - If there is an error during the database operation.
 * @throws {Error} - If a unique constraint error occurs, indicating a limit on password reset requests.
 */

const setResetToken = async (knex, operator, token) => {
    try {
        const currentTime = new Date().toISOString();
        await knex('tokens').insert({
            _fk_operator: operator,
            token: token,
            created_at: currentTime
        });
    } catch (error) {
        if (error.code === '23505') {
            throw new Error('Take it easy! For your safety, only one password reset request per hour.');
        } else {
            throw new Error(error.message)
        }
    }
}

/**
 * Set an invite token for a new operator in the 'auth.tokens' table and create the operator.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} operator - The email address of the new operator.
 * @param {string} token - The invite token to be stored.
 * @returns {Object} - An object indicating the success and an optional message.
 * @throws {Error} - If there is an error during the database operation.
 */

const setInviteToken = async (knex, operator, token) => {
    try {
        await knex.transaction(function (trx) {
            knex('auth.operators')
                .insert({
                    email: operator,
                    operator: "New Operator",
                })
                .returning('_id')
                .transacting(trx)
                .then(function (operator_id) {
                    const currentTime = new Date().toISOString();
                    return knex('auth.tokens')
                        .insert({
                            _fk_operator: operator_id[0]._id,
                            token: token,
                            created_at: currentTime
                        })
                        .transacting(trx)
                })
                .then(trx.commit)
                .catch(trx.rollback);
        })
        return {
            success: true,
            message: 'Till next time.',
        };
    } catch (error) {
        throw new Error(error.message)
    }
}

/**
 * Update the name of an operator in the 'auth.operators' table.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} name - The new name for the operator.
 * @param {number} operator - The ID of the operator to update.
 * @returns {number} - The ID of the updated operator.
 * @throws {Error} - If there is an error during the database operation.
 */

const updateOperator = async (knex, name, operator) => {
    try {
        await knex('auth.operators')
            .where({_id: operator})
            .update({operator: name})
        return operator;
    } catch (error) {
        throw new Error(error.message);
    }
}

/**
 * Update the password for an operator and delete the associated reset token from the database.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} hash - The hashed password to set for the operator.
 * @param {Object} token_record - The reset token record associated with the operator.
 * @throws {Error} - If there is an error during the database transaction.
 */

const updatePassword = async (knex, hash, token_record) => {
    try {
        await knex.transaction(function (trx) {
            knex('auth.operators')
                .where({_id: token_record._fk_operator})
                .update({password: hash})
                .transacting(trx)
                .then(function () {
                    return knex('auth.tokens')
                        .where('token', token_record.token)
                        .del()
                        .transacting(trx)
                })
                .then(trx.commit)
                .catch(trx.rollback);
        })
    } catch (error) {
        throw new Error(error.message);
    }
}


/**
 * Initialize an operator with a new password and update the operator's name, deleting the associated invite token.
 *
 * @param {Object} knex - The Knex instance for database queries.
 * @param {string} operator - The operator's name to set.
 * @param {string} hash - The hashed password to set for the operator.
 * @param {Object} token_record - The invite token record associated with the operator.
 * @throws {Error} - If there is an error during the database transaction.
 */

const initOperator = async (knex, operator, hash, token_record) => {
    try {
        await knex.transaction(function (trx) {
            knex('auth.operators')
                .where({_id: token_record._fk_operator})
                .update({password: hash, operator: operator})
                .transacting(trx)
                .then(function () {
                    return knex('auth.tokens')
                        .where('token', token_record.token)
                        .del()
                        .transacting(trx)
                })
                .then(trx.commit)
                .catch(trx.rollback);
        })
    } catch (error) {
        throw new Error(error.message)
    }
}

/**
 * Generate a random token for various purposes, such as reset tokens and invite tokens.
 *
 * @returns {string} - A randomly generated token in hexadecimal format.
 */

const generateToken = () => {
    return crypto.randomBytes(20).toString('hex');
}

/**
 * Generate a link by combining the root URL, a route, and a token.
 *
 * @param {string} route - The route to which the token is associated.
 * @param {string} token - The token to be included in the link.
 * @returns {string} - The complete link formed by combining the root, route, and token.
 */

const generateLink = (route, token) => {
    return root + '/' + route + '/' + token;
}

module.exports = {
    getOperatorByEmail,
    getOperatorByName,
    getOperatorByToken,
    getResetTokenRecord,
    getInviteTokenRecord,
    setResetToken,
    setInviteToken,
    updateOperator,
    updatePassword,
    initOperator,
    generateToken,
    generateLink,
};
Enter fullscreen mode Exit fullscreen mode

The mail service uses the mail microservice published in one of my previous articles. It uses JWT to secure the connection to the microservice. Don't forget to replace the domain name for your production environment, when you implement this code.

// Import JWT library for authentication and authorization.
const jwt = require('jsonwebtoken');

// Define API URL based on the environment .
const apiUrl = (process.env.NODE_ENV === 'development') ? 'http://localhost:5002/sendmail' : 'https://YOURDOMAINGOESHERE/sendmail';

/**
 * Send an email using the provided data to a designated mail service.
 *
 * @param {string} mailRecipient - The recipient's email address.
 * @param {string} mailSubject - The subject of the email.
 * @param {string} mailText - The email content.
 * @returns {Promise<Response>} - A promise that resolves to the result of the email sending operation.
 */
const sendMail = async (mailRecipient, mailSubject, mailText) => {
    const jwtToken = jwt.sign({}, process.env.JWT_SECRET, {expiresIn: '1h'});
    try {
        return await fetch(apiUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${jwtToken}` // pass token to mail service
            },
            body: JSON.stringify({mailRecipient, mailSubject, mailText})
        })
    } catch (error) {
        throw new Error(error.message)
    }
}


module.exports = {
    sendMail,
};
Enter fullscreen mode Exit fullscreen mode

Finally the validation service provides a set of methods to validate passwords, operator names and email addresses according to certain criteria. Also it gives the proper error messages if the criteria aren't met.

/**
 * Check if the provided value contains at least one lowercase letter.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const containsLowercase = (value) => ({
    valid: /[a-z]/.test(value),
    message: 'Password must contain at least one lowercase letter'
});


/**
 * Check if the provided value contains at least one uppercase letter.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const containsUppercase = (value) => ({
    valid: /[A-Z]/.test(value),
    message: 'Password must contain at least one uppercase letter'
});

/**
 * Check if the provided value contains at least one number.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const containsNumber = (value) => ({
    valid: /\d/.test(value),
    message: 'Password must contain at least one number'
});

/**
 * * Check if the provided value contains only allowed characters.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const onlyAllowedChars = (value) => ({
    valid: !/[^a-zA-Z0-9 !@#$%^&*()_+\-=[\]{};:,.?]/.test(value),
    message: 'Password can only contain the following characters: \n' +
        'a-z, A-Z, 0-9, spaces, and these special characters: \n' +
        '!@#$%^&*()_+-=[]{};:,.?'
});

/**
 * Check if the provided value contains at least one special character.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const containsSpecialChar = (value) => ({
    valid: /[!@#$%^&*()_+\-=[\]{};:,.?]/.test(value),
    message: 'Password must contain at least one special character: \n' +
        '(!@#$%^&*()_+-=[]{};:,.?)'
});

/**
 * Check if the provided value contains at least eight characters.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const atLeastEightCharacters = (value) => ({
    valid: value.length >= 8,
    message: 'Password must have at least eight characters'
});

/**
 * Check if the provided value contains at most twenty characters.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const atMostTwentyCharacters = (value) => ({
    valid: value.length <= 20,
    message: 'Password must have at most 20 characters'
});

/**
 * Check if the provided value contains at least five characters.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const atLeastFiveCharactersOperator = (value) => ({
    valid: value.length >= 5,
    message: 'Make it grow a little.'
});

/**
 * Check if the provided value contains at most twenty characters.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const atMostTwentyCharactersOperator = (value) => ({
    valid: value.length <= 20,
    message: 'Easy... Take it easy.'
});

/**
 * Validate a password based on a set of validation functions.
 * @param {string} password - The password to be validated.
 * @returns {boolean} - `true` if the password passes all validations, `false` otherwise.
 */
const validatePassword = (password) => {
    const validators = [
        containsLowercase,
        containsUppercase,
        containsNumber,
        containsSpecialChar,
        onlyAllowedChars,
        atLeastEightCharacters,
        atMostTwentyCharacters,
    ]
    let errors = {};
    // run each validator function on the value
    for (let validator of validators) {
        let result = validator(password);
        // if the validation fails, add an error message to the errors object
        if (!result.valid) {
            errors[0] = errors[0] || [];
            errors[0].push(result.message);
        }
    }
    return Object.keys(errors).length === 0
}

/**
 * Validate an operator's name based on a set of validation functions.
 * @param {string} operator - The operator's name to be validated.
 * @returns {boolean} - `true` if the operator's name passes all validations, `false` otherwise.
 */
const validateOperator = (operator) => {
    const validators = [
        atLeastFiveCharactersOperator,
        atMostTwentyCharactersOperator,
    ]
    let errors = {};
    // run each validator function on the value
    for (let validator of validators) {
        let result = validator(operator);
        // if the validation fails, add an error message to the errors object
        if (!result.valid) {
            errors[0] = errors[0] || [];
            errors[0].push(result.message);
        }
    }
    return Object.keys(errors).length === 0
}

// Validates an email address to ensure it meets standard email format requirements.
const isValidEmail = (email) => {
    // Check if the email string is empty or undefined
    if (!email) {
        return false;
    }

    // Check that the email contains a "@" symbol
    if (!email.includes("@")) {
        return false;
    }

    // Split the email into local and domain parts
    const parts = email.split("@");
    const localPart = parts[0];
    const domainPart = parts[1];

    // Check that the local part is not empty
    if (localPart.length === 0) {
        return false;
    }

    // Check that the domain part contains a dot
    if (!domainPart.includes(".")) {
        return false;
    }

    // Check that the domain part has at least two characters after the dot
    const domainParts = domainPart.split(".");
    const tld = domainParts[domainParts.length - 1];
    if (tld.length < 2) {
        return false;
    }

    // Check that the domain part does not have consecutive dots
    if (domainPart.includes("..")) {
        return false;
    }

    // Check that the domain part does not start or end with a hyphen
    if (domainPart.startsWith("-") || domainPart.endsWith("-")) {
        return false;
    }

    // Check that the local and domain parts do not contain invalid characters
    const localPartPattern = /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
    if (!localPartPattern.test(localPart)) {
        return false;
    }
    const domainPartPattern = /^[a-zA-Z0-9.-]+$/;
    if (!domainPartPattern.test(domainPart)) {
        return false;
    }

    // If all checks pass, the email is valid
    return true;
};

/**
 * Check if the provided value is a valid email address.
 * @param {string} value - The value to be checked.
 * @returns {Object} - An object with 'valid' indicating if the condition is met and a corresponding 'message'.
 */
const validEmail = (value) => ({
    valid: isValidEmail(value),
    message: `That doesn't seem to be right.`,
});


/**
 * Validate an operator's email based on a set of validation functions.
 * @param {string} operator - The operator's email to be validated.
 * @returns {boolean} - `true` if the operator's name passes all validations, `false` otherwise.
 */
const validateEmail = (operator) => {
    const validators = [
        validEmail
    ]
    let errors = {};
    // run each validator function on the value
    for (let validator of validators) {
        let result = validator(operator);
        // if the validation fails, add an error message to the errors object
        if (!result.valid) {
            errors[0] = errors[0] || [];
            errors[0].push(result.message);
        }
    }
    return Object.keys(errors).length === 0
}

module.exports = {
    validatePassword,
    validateOperator,
    validateEmail
};
Enter fullscreen mode Exit fullscreen mode

And that's the gateway as is. With these few files, you have a complete authentication backend available. I hope this helps you build awesome things. Let me know if you liked it.

Cheers,

Mat Kwa

Top comments (1)

Collapse
 
dotenv profile image
Dotenv

💛🌴