Mastering Authentication and User Flow in Node.js with Knex and Redis

Create and understand a robust authentication solution for Node js server using Knex for database management, Redis for efficient caching, and Express for seamless routing.

Anton Kalik
ITNEXT

--

Photo by Christian Stahl on Unsplash

In my quest for a swift, intuitive, and streamlined authentication solution for my Node.js applications, I encountered scenarios demanding rapid implementation without compromising on functionality. From user signup and login to managing forgotten passwords, updating user data, and even account deletion, I sought a comprehensive solution that seamlessly navigates through these essential user interactions. This my article aims to present precisely that — a cohesive approach integrating clear methodologies to implement authentication and caching, ensuring a robust and efficient user flow. Here we’ll bypass the fundamental installation procedures and model creation, honing in directly on the intricacies of authentication and the user flow. We’ll include all necessary links to obtain configuration files throughout the article, ensuring seamless access to the resources needed for setup.

Tools

For this implementation, we’ll leverage Node.js version 20.11.1 alongside Knex, Express, and Redis. Additionally, we’ll utilize PostgreSQL as our database, which will be containerized and orchestrated using Docker for seamless management.

The name of our application will be user-flow-boilerplate. Let’s create that folder and inside run npm init -y to generate basic package.json

Initial package json

The next step is to add the necessary dependencies:

dependencies: npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator

devDependencies: npm i -D @babel/core @babel/eslint-parser @babel/plugin-transform-class-properties @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript @faker-js/faker @types/bcrypt @types/body-parser @types/cors @types/express @types/jest @types/jsonwebtoken @types/node @types/node-cron @types/validator @typescript-eslint/eslint-plugin @typescript-eslint/parser babel-jest cross-env eslint eslint-config-prettier eslint-plugin-prettier jest nodemon npm-run-all prettier ts-jest ts-loader ts-node tsconfig-paths tslint typescript webpack webpack-cli webpack-node-externals

and add scripts that will build and run our application:

"scripts": {
"start": "NODE_ENV=production node dist/bundle.js",
"build": "NODE_ENV=production webpack --config webpack.config.js",
"dev": "cross-env NODE_ENV=development && npm-run-all -p dev:*",
"dev:build": "webpack --config webpack.config.js --watch",
"dev:start": "nodemon --watch dist --exec node dist/bundle.js",
"test": "NODE_ENV=test jest --config ./jest.config.js",
"lint": "eslint ./src -c .eslintrc.json"
},

To ensure the smooth launch of our application, it’s essential to create a src folder and place our initial entry point file, index.ts, within it.

Entrypoint

For the development, we need to have settings for typscript, lint, jest, bable, prettier, nodemon. All of those files I described in the following article: How to Create Node.js Server With Postgres and Knex on Express.

After configuring all settings and creating the entry point, executing npm run dev should initiate the server, and you should expect to see output similar to the following:

./src/index.ts 1.7 KiB [built] [code generated]
external "dotenv" 42 bytes [built] [code generated]
external "process" 42 bytes [built] [code generated]
external "express" 42 bytes [built] [code generated]
external "body-parser" 42 bytes [built] [code generated]
external "cors" 42 bytes [built] [code generated]
webpack 5.90.3 compiled successfully in 751 ms
[nodemon] restarting due to changes...
[nodemon] starting `node dist/bundle.js`
Server is running on port 9999

Next, navigate to Postman where we’ll establish a collection dedicated to testing our endpoints. In the new collection add a new GET request, press cmd + E (on Mac, but the keys depend on your OS), and name it as health. Add enter for URL: {{BASE_URI}}/health. For BASE_URI add new variable which you going to use across the collection: http://localhost:9999/api/v1

Postman Set Base URL
Postman Set Base Url

Afterward, simply click the ‘Send’ button, and you should observe the response body:

{
"message": "OK"
}

Database

Before moving forward, it’s crucial to have our database up and running. We’ll accomplish this by launching it with docker-compose. To access and manage the database, you can utilize various development platforms like pgAdmin. Personally, I prefer using RubyMine, which comes equipped with a driver enabling seamless connectivity to PostgreSQL databases for efficient management.

We need .env file with necessary keys, passwords and test names:

.env for connection to database, redis and test values for seeds

Fear not, I randomly generated the JWT_SECRET to illustrate it in a more authentic manner. So, let’s create a docker-compose.yml file at the root of the project:

docker-compose file with services

We’re going to spin up two services in Docker for rapid connectivity. I’ve streamlined this process to facilitate quick access to the database or Redis, allowing us to retrieve data efficiently. So let’s run those services docker-compose up and we have to be able to see the output after docker ps following output:

CONTAINER ID   IMAGE             COMMAND                  CREATED              STATUS              PORTS                    NAMES
e4bef95de1dd postgres:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:5432->5432/tcp postgres
365e3a68351a redis:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:6379->6379/tcp redis

Now we need to create the src/@types/index.ts file where we store our types for application:

Types for service

At this moment you need to have knexfile.ts in the root of the project and database folder for connection, migrations, and seeds.

I left a pretty detailed explanation in How to Create Node.js Server With Postgres and Knex on Express article about how to migrate and seed users to the database where we are using those env variables.

I’d like to specifically check the migrations to ensure that we’re on the same page. We already launched our services and we have to be able to check the connection to the database.

docker exec -it postgres psql -U username_123 user_flow_boilerplate

If the connection is good, then you will be in the psql console. Ok, if the connection has no problems, then we should be able to migrate there our tables. Run knex migrate:latest. Then you should observe the newly added columns in your users table within the database.

Users table after migration

Let’s seed it with fake data knex seed:run and check the table again.

Result in database after seed

So, we are now equipped to manipulate the database, allowing us to add, delete, or update users as needed.

Router

Finally, we can forget about settings and preparation and focus on user flow specifically. For that, we need to create a router. We need to handle by that router the following operations: login , logout , signup , delete_user, update_user. For that on src/routes/index.ts add the following code:

Routes

As you can see that in the beginning we add /health route which we already checked. So, then let’s update the entry point to apply those routes there. First, remove previous get.

-> REMOVE -> app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' }));

and add to the top of the file:

import { router } from 'src/routes';
// ...

app.use(cors());
app.use('/api/v1', router);

and create first controller for health check src/controllers/healthController.ts with code:

Health Controller

Now, let’s back to the router, and let’s check what we have to add more to the routes. We need to add two more files: authRouter.ts and userRouter.ts

Auth Router
User Router

I’ve divided this logic for the sake of readability and responsibility, to maintain isolated functionality. All of those routes need controllers where we going to handle the logic.

Controllers

Auth and health routes don’t need authentication middleware, so those routes are not protected, but if no match, we going to get status 404.

router.get('/health', healthController);
router.use('/auth', authRouter);

Now, as we have settled up all routes, we have to set the user model.

User Model

I’ll be utilizing a base model for the user model, from which I’ll reuse CRUD methods. While I’ve previously covered model creation in another article, I’ll include the base model here for better visibility and understanding. Create in src/models/Model.ts

Base model

Based on the base model we have to be able to create UserModel.ts in the same folder:

User Model

In the model of user I set role just by default if not provided from the payload. And now that we have our models ready, we can proceed to utilize them within our controllers and middlewares.

Auth Middleware

The auth middleware in a Node.js application is responsible for authenticating incoming requests, ensuring that they are coming from valid and authorized users. It typically intercepts incoming requests, extracts authentication tokens or credentials, and verifies their validity against a predefined authentication mechanism, such as JWT (JSON Web Tokens) in this case. If the authentication process succeeds, the middleware allows the request to proceed to the next handler in the request-response cycle. However, if authentication fails, it responds with an appropriate HTTP status code (e.g., 401 Unauthorized) and optionally provides an error message.

Create folder src/middlewares and add there a file authMiddleware.ts with the following code:

Auth Middleware

The auth middleware extracts the JWT token from the request header, verifies its validity using the JWT library, and checks if the token matches the one stored in Redis. If the token is valid and matches the stored token, the middleware sets the authenticated user session on the request object (req.user) and calls the next() function to pass control to the next middleware or route handler. Otherwise, it responds with a 401 status code indicating authentication failure.

JSON Web Tokens

Let’s review the util for jwt. Create in src/utils/jwt.ts file with following code:

JWT Utility

This utility serves a critical role in handling JSON Web Tokens within the Node.js application. The jwt object exports functions for both signing and verifying JWTs, leveraging the jsonwebtoken library. These functions facilitate the creation and validation of JWTs, essential for implementing authentication mechanisms in the application. Utility encapsulates the functionality for handling JWTs, ensuring secure authentication mechanisms within the Node.js application while adhering to best practices for environment variable management.

Redis

Used as a database, cache, and message broker. Commonly used in a variety of use cases, including caching, session management, real-time analytics, messaging queues, leaderboards, and more. Checking the token from Redis serves as an additional layer of security and validation for the JWT token. Let’s dive in to the settings. For that create in file src/redis/index.ts with following code:

Redis Store

By Redis, we are going to store and manage user session tokens. In the auth middleware, after verifying the JWT token’s authenticity, the middleware checks whether the token exists and matches the one stored in Redis for the corresponding user session. This helps ensure that only valid and authorized users can access protected routes. Redis is used as a key-value store to maintain user session tokens. When a user logs in or authenticates, their session token is stored in Redis. This allows for efficient and fast retrieval of session tokens during subsequent authentication checks.

Redis is utilized in the auth middleware for efficient session management, while the Redis-related file handles the configuration and connection to the Redis server and provides functions for interacting with Redis in other parts of the application. This setup ensures secure and reliable authentication mechanisms, with user session tokens stored and managed in Redis.

The last part we have to connect to Redis in our entry point:

Connect to Redis

After completing the authentication preparation, we can now shift our focus to the controllers.

Controllers

Controllers in routes help to organize the application’s logic by separating concerns and promoting code maintainability. We’ve already created the controller for the health check. Next, we’ll proceed to create controllers for handling operations with user.

The first controller what we going to take is sessionController.ts which has to be in src/controllers with the following code:

Session Controller

This controller serves the purpose of handling a session-related endpoint, likely responsible for retrieving information about the currently authenticated user. We need this controller by following reasons:

User Session Information: This controller allows the application to retrieve information about the user’s session, such as their user profile or other relevant data. This information can be useful for customizing the user experience or providing personalized content based on the user’s profile.

Authentication and Authorization: By checking if req.user exists, the controller ensures that only authenticated users can access the endpoint. This helps enforce authentication and authorization rules, ensuring that sensitive user data is only accessible to authorized users.

User Profile Retrieval: The controller queries the database (using the UserModel) to retrieve the user's information based on their session ID. This allows the application to fetch user-specific data dynamically, providing a tailored experience for each user. This part definitely can be improved by Redis cache:

Session Controller with Redis set session

We define a constant CACHE_EXPIRATION to specify the cache expiration time in seconds. In this example, it's set to 3600 seconds (1 hour). Cached data is periodically refreshed, preventing stale data from being served to users and maintaining data integrity within the cache.

Before proceeding to create the signUpController, which manages the sign-up process for new users in our application, let’s review the schema:

Signup Process

In our case, when attempting to sign up with an existing in the database email, we prioritize user privacy by not explicitly revealing whether the user exists. Instead, we inform the client with a generic message stating Invalid email or password. This approach encourages the client to submit valid credentials without disclosing unnecessary information about existing users.

Now let’s create src/controllers/auth/signUpController.ts and add the following code:

Sign Up Controller

The controller receives a request containing the user’s email and password, typically from a sign-up form. It validates the incoming data against a predefined userSchema to ensure it meets the required format. If the validation passes successfully, indicating no existing user and valid fields, the controller proceeds to hash the password using bcrypt.hash, generates a username, and creates the user using UserModel.create. Finally, it generates a token using jwt, sets the session data in Redis, and sends the token back to the user.

Now let’s focus on the creation of a login controller. Create file src/controllers/auth/loginController.ts:

Login Controller

Essentially, we begin by validating the provided fields and then check for the existence of a user. If no user is found, we respond with a 400 status code along with the message Invalid email or password, similar to the behavior in the signupController. If a user exists, we proceed to compare the provided password with the hashed password stored in the database using bcrypt.compare. If the passwords do not match, we respond with the familiar message ‘Invalid email or password.’ Finally, upon successful authentication, we generate a token, set the session in Redis, and send the token back to the client.

Let’s review our protected controllers, which depend on the presence of a user_id obtained from middleware. We consistently rely on this user_id for operations within these controllers. In cases where the request lacks an authorization header, we must respond with a 401 status code.

const authHeader = req.headers['authorization'];

Create file src/controllers/user/logoutController.ts with the following code:

Logout Controller

This logoutController, is responsible for logging out a user from the system. Upon receiving a request, it interacts with the Redis client to delete the session associated with the user.id. If the operation is successful, it responds with a 200 status code to indicate successful logout. However, if an error occurs during the process, it responds with a 500 status code to signal an internal server error.

Next, let’s address the deletion of user data. Create src/controllers/user/deleteUserController.ts and add this code:

Delete user controller

When a request is received, it extracts the user ID from the request object, typically obtained from the authentication middleware.

Subsequently, it proceeds to delete the session associated with this user_id from Redis using the Redis client. Afterward, it invokes the delete method of the UserModel to remove the user's data from the database.

Upon successful deletion of both the session and the user data, it responds with a 200 status code to indicate successful deletion. In the event of an error during the deletion process, it responds with a 500 status code to signify an internal server error.

To update the user data in the system create src/controllers/user/updateUserController.ts and add the following code to the file:

Update User Controller

Upon receiving a request, it extracts the fields first_name, last_name, and username from the request body. Next, it filters these fields using the filterObject utility function to ensure that only valid fields are included in the payload. Subsequently, it checks if the provided username already exists in the database. If it does, the controller responds with a 400 status code and an error message indicating an invalid username. If the username is unique, the controller proceeds to update the user data in the database using the updateOneById method of the UserModel. Upon successful update, it responds with a 200 status code and the updated user data. In case of any errors during the update process, the controller responds with a 500 status code to signify an internal server error.

The last one will be to update the password, pretty much the same idea as updating the user data, but with hashing the new password. Create last controller from our list src/controllers/user/updatePasswordController.ts and add the code:

Update Password controller

Upon receiving a request, it extracts the new password from the request body. It then checks if a password is provided in the request body. If not, it responds with a 400 status code, indicating a bad request. Next, it hashes the new password using the bcrypt library with a salt factor of 10. The hashed password is then stored securely in the database using the updateOneById method of the UserModel, associating it with the user.id. Upon successful password update, the controller responds with a 200 status code and a JSON object containing the user’s ID. In case of any errors during the password update process, the controller responds with a 500 status code to indicate an internal server error as in other controllers.

Ensure to review and set up the validation helper and utilities from the GitHub repository. Once configured, you should be ready to test the endpoints.

Let’s check the signup endpoint:

Auth Signup enpoint

As evident, we have obtained a token, which will be utilized in the header to retrieve the session.

Result of the session in response

We sent the authorization token in the header to the server, and in response, the server provided us with the user data retrieved from the database.

Feel free to explore and experiment with security features and Redis caching. With the foundational model in place, you can delve into additional functionalities, such as account recovery for users who forget their passwords. However, this topic will be reserved for a future article.

Conclustion

Managing routing and user authentication flow in a scalable manner can be challenging. While we’ve implemented middleware to safeguard routes, there are additional strategies available to enhance the performance and reliability of the service. There is further enhance user experience by providing clearer error messages, as error handling remains a significant aspect that requires more comprehensive coverage. However, we’ve successfully implemented the primary authentication flow, enabling users to sign up, access their accounts, retrieve session data, update user information, and delete accounts. I hope you found this journey insightful and gained valuable knowledge into user authentication.

Feel free to reach me

Resources

Github Repo
Knex.js
Express
Create Node Application
Postman

--

--