How to Master Authentication and User Flow in Node.js With Knex and Redis

Written by antonkalik | Published 2024/03/17
Tech Story Tags: nodejs | javascript | knex | authentication | postgresql | hackernoon-top-story | tools-for-aunthentication | authentication-guide

TLDRUsing Knex for database management, Redis for efficient caching, and Express for seamless routing. Create and understand a robust authentication solution for Node js server using Knex and Redis. Use Knex to create a database for Node.js, then Redis to cache and Express to route data.via the TL;DR App

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

In my quest for a swift, intuitive, and streamlined authentication solution for my Node.js applications, I encountered scenarios demanding rapid implementation without compromising 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.

Thus, 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

{
  "name": "user-flow-boilerplate",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Initial package.json

The next step is to add the necessary dependencies:

dependenciesnpm 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 srcfolder, and place our initial entry point file, index.ts, within it.

require('dotenv').config();
import process from 'process';
import express from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';

const app = express();
const PORT = process.env.PORT || 9999;

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

(async () => {
  try {
    app.listen(PORT, async () => {
      console.log(`Server is running on port ${PORT}`);
    });
  } catch (error) {
    console.error('Failed to start server:', error);
    process.exit(1);
  }
})();

Entrypoint file

For the development, we need to have settings for typscriptlintjestbableprettiernodemon. All of those files I described in the following article: Creating a 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 a new variable which you are going to use across the collection: http://localhost:9999/api/v1

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:

PORT=9999

WEB_HOST="localhost"

# DB
DB_HOST="localhost"
DB_PORT=5432
DB_NAME="user_flow_boilerplate"
DB_USER="username_123"
DB_PASSWORD="SomeParole999"

# User
DEFAULT_PASSWORD="SomeParole999"
JWT_SECRET="6f1d7e9b9ba56476ae2f4bdebf667d88eeee6e6c98c68f392ed39f7cf6e51c5a"

# Test User
TEST_EMAIL="[email protected]"
TEST_USERNAME="test_username"
TEST_PASSWORD="SomeParole999"

# Redis
REDIS_HOST="localhost"
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD="SomeParole999"

.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:

version: '3.6'
volumes:
  data:
services:
  database:
    build:
      context: .
      dockerfile: postgres.dockerfile
    image: postgres:latest
    container_name: postgres
    environment:
      TZ: Europe/Madrid
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    networks:
      - default
    volumes:
      - data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    restart: unless-stopped
  redis:
    image: redis:latest
    container_name: redis
    command: redis-server --requirepass ${REDIS_PASSWORD}
    networks:
      - default
    ports:
      - "6379:6379"
    restart: unless-stopped

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:

export enum Role {
  Admin = 'admin',
  Blogger = 'blogger',
}

export type UserSession = {
  id: number;
};

export type DatabaseDate = {
  created_at: Date;
  updated_at: Date;
};

export type DefaultUserData = {
  role: Role;
};

export interface User extends DatabaseDate {
  id: number;
  email: string;
  username: string;
  password: string;
  role: Role;
}

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 Creating a 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.

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

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_userupdate_user.

For that, on src/routes/index.ts, add the following code:

import { Router } from 'express';
import { authRouter } from 'src/routes/authRouter';
import { healthController } from 'src/controllers/healthController';
import { sessionController } from 'src/controllers/sessionController';
import { authMiddleware } from 'src/middlewares/authMiddleware';
import { userRouter } from 'src/routes/userRouter';

export const router = Router({ mergeParams: true });

router.get('/health', healthController);
router.use('/auth', authRouter);
router.get('/session', authMiddleware, sessionController);
router.use('/user', authMiddleware, userRouter);

router.use((_, res) => {
  return res.status(404).json({ message: 'Not Found' });
});

Routes file

As you can see, in the beginning, we added /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 the first controller for health check src/controllers/healthController.ts with code:

import { Request, Response } from 'express';

export const healthController = (_: Request, res: Response) => res.status(200).send('ok');

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

import { Router } from 'express';
import { signUpController } from 'src/controllers/auth/signUpController';
import { loginController } from 'src/controllers/auth/loginController';

export const authRouter = Router();

authRouter.post('/signup', signUpController);
authRouter.post('/login', loginController);

Auth Router

import { Router } from 'express';
import { updateUserController } from 'src/controllers/user/updateUserController';
import { deleteUserController } from 'src/controllers/user/deleteUserController';
import { logoutController } from 'src/controllers/user/logoutController';
import { updatePasswordController } from 'src/controllers/user/updatePasswordController';

export const userRouter = Router();

userRouter.patch('/', updateUserController);
userRouter.delete('/', deleteUserController);
userRouter.post('/logout', logoutController);
userRouter.post('/update-password', updatePasswordController);

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.

Auth and health routes don’t need authentication middleware, so those routes are not protected, but if there is no match, we’re going to get a 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

import { database } from 'root/database';

export abstract class Model {
  protected static tableName?: string;

  protected static get table() {
    if (!this.tableName) {
      throw new Error('The table name must be defined for the model.');
    }
    return database(this.tableName);
  }

  public static async insert<Payload>(data: Payload): Promise<{
    id: number;
  }> {
    const [result] = await this.table.insert(data).returning('id');
    return result;
  }

  public static async updateOneById<Payload>(
    id: number,
    data: Payload
  ): Promise<{
    id: number;
  }> {
    const [result] = await this.table.where({ id }).update(data).returning('id');
    return result;
  }

  public static async delete(id: number): Promise<number> {
    return this.table.where({ id }).del();
  }

  public static async findOneById<Result>(id: number): Promise<Result> {
    return this.table.where('id', id).first();
  }

  public static async findOneBy<Payload, Result>(data: Payload): Promise<Result> {
    return this.table.where(data as string).first();
  }
}

Base model

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

import { Model } from 'src/models/Model';
import { Role, User, DefaultUserData } from 'src/@types';

export class UserModel extends Model {
  static tableName = 'users';

  public static async create<Payload>(data: Payload) {
    return super.insert<Payload & DefaultUserData>({
      ...data,
      role: data.role || Role.Blogger,
    });
  }

  public static findByEmail(email: string): Promise<User | null> {
    return this.findOneBy<
      {
        email: string;
      },
      User
    >({ email });
  }

  public static findByUsername(username: string): Promise<User | null> {
    return this.findOneBy<
      {
        username: string;
      },
      User
    >({ username });
  }
}

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:

import { jwt } from 'src/utils/jwt';
import { Redis } from 'src/redis';
import type { Request, Response, NextFunction } from 'express';
import type { UserSession } from 'src/@types';

export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  const JWT_SECRET = process.env.JWT_SECRET;

  if (!token) return res.sendStatus(401);

  if (!JWT_SECRET) {
    console.error('JWT_SECRET Not Found');
    return res.sendStatus(500);
  }

  if (!token) return res.status(401).json({ error: 'Token not provided' });

  try {
    const userSession = await jwt.verify<UserSession>(token);

    if (!userSession) {
      return res.sendStatus(401);
    }

    const storedToken = await Redis.getSession(userSession.id);

    if (!storedToken || storedToken !== token) {
      return res.sendStatus(401);
    }
    req.user = userSession;
    next();
  } catch (error) {
    console.error('JWT_ERROR', error);
    return res.sendStatus(401);
  }
}

Auth middleware file

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 the following code:

require('dotenv').config();
import jsonwebtoken from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET as string;

export const jwt = {
  verify: <Result>(token: string): Promise<Result> => {
    if (!JWT_SECRET) {
      throw new Error('JWT_SECRET not found in environment variables!');
    }

    return new Promise((resolve, reject) => {
      jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => {
        if (error) {
          reject(error);
        } else {
          resolve(decoded as Result);
        }
      });
    });
  },
  sign: (payload: string | object | Buffer): Promise<string> => {
    if (!JWT_SECRET) {
      throw new Error('JWT_SECRET not found in environment variables!');
    }

    return new Promise((resolve, reject) => {
      try {
        resolve(jsonwebtoken.sign(payload, JWT_SECRET));
      } catch (error) {
        reject(error);
      }
    });
  },
};

JWT utility file

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 into the settings. For that, create in file src/redis/index.ts with the following code:

require('dotenv').config({
  path: '../../.env',
});
import process from 'process';
import * as redis from 'redis';

const client = redis.createClient({
  url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});

client.on('error', error => console.error('Redis Client Error', error));

const connect = async () => {
  try {
    await client.connect();
    console.log('Connected to Redis');
  } catch (err) {
    console.error(`Could not connect to Redis: ${err}`);
    process.exit(1);
  }
};

class Redis {
  public static setSession(userId: number, token: string) {
    if (!userId) throw new Error('userId is required');
    if (!token) throw new Error('token is required');
    try {
      return client.set(`session:${userId}`, token);
    } catch (error) {
      console.error(error);
    }
  }

  public static getSession(userId: number) {
    if (!userId) throw new Error('userId is required');
    return client.get(`session:${userId}`);
  }

  public static deleteSession(userId: string) {
    if (!userId) throw new Error('userId is required');
    try {
      return client.del(`session:${userId}`);
    } catch (error) {
      console.error(error);
    }
  }
}

export { client, connect, Redis };

Redis Session 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 is we have to connect to Redis in our entry point:

// all imports
import * as Redis from 'src/redis';

const app = express();
const PORT = process.env.PORT || 9999;

// middlewares

(async () => {
  try {
    await Redis.connect();
    app.listen(PORT, async () => {
      console.log(`Server is running on port ${PORT}`);
    });
  } catch (error) {
    console.error('Failed to start server:', error);
    process.exit(1);
  }
})();

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 the user.

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

import { Request, Response } from 'express';
import { UserModel } from 'src/models/UserModel';
import type { User } from 'src/@types';

export const sessionController = async (req: Request, res: Response) => {
  if (!req.user) return res.sendStatus(401);

  try {
    const user = await UserModel.findOneById<User>(req.user.id);

    if (user) {
      return res.status(200).json(user);
    } else {
      return res.sendStatus(401);
    }
  } catch (error) {
    return res.sendStatus(500);
  }
};

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 for the 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:

import { Request, Response } from 'express';
import { UserModel } from 'src/models/UserModel';
import { Redis } from 'src/redis';
import type { User } from 'src/@types';

export const sessionController = async (req: Request, res: Response) => {
  if (!req.user) return res.sendStatus(401);

  try {
    const cachedProfile = await Redis.getSession(req.user.id);

    if (cachedProfile) {
      return res.status(200).json(JSON.parse(cachedProfile));
    } else {
      const user = await UserModel.findOneById<User>(req.user.id);

      if (user) {
        await Redis.setSession(req.user.id, JSON.stringify(user), CACHE_EXPIRATION);
        return res.status(200).json(user);
      } else {
        return res.sendStatus(401);
      }
    }
  } catch (error) {
    console.error('Error retrieving user profile:', error);
    return res.sendStatus(500);
  }
};

Session Controller file 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:

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:

import bcrypt from 'bcrypt';
import { jwt } from 'src/utils/jwt';
import { Request, Response } from 'express';
import { validate } from 'src/helpers/validation/validate';
import { userSchema } from 'src/helpers/validation/schemas/userSchema';
import { UserModel } from 'src/models/UserModel';
import { Redis } from 'src/redis';
import type { User } from 'src/@types';
import { getRandomString } from 'src/utils/getRandomString';

type Payload = Omit<User, 'id' | 'created_at' | 'updated_at' | 'role'>;

export async function signUpController(req: Request, res: Response) {
  const { email, password }: Payload = req.body;
  const validation = validate<Payload>(req.body, userSchema);

  if (!validation.isValid) {
    return res.status(400).send(`Invalid ${validation.invalidKey}`);
  }

  try {
    const user = await UserModel.findOneBy({ email });

    if (user) {
      return res.status(400).json({ message: 'Invalid email or password' });
    }

    const hashedPassword = (await bcrypt.hash(password, 10)) as string;
    const username = `${email.split('@')[0]}${getRandomString(5)}`;
    const createdUser = await UserModel.create<Payload>({
      email,
      password: hashedPassword,
      username,
    });
    const token = await jwt.sign({
      id: createdUser.id,
    });
    await Redis.setSession(createdUser.id, token);

    res.status(200).json({
      token,
    });
  } catch (error) {
    return res.sendStatus(500);
  }
}

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:

require('dotenv').config({
  path: '../../.env',
});
import bcrypt from 'bcrypt';
import { Request, Response } from 'express';
import { jwt } from 'src/utils/jwt';
import { UserModel } from 'src/models/UserModel';
import { Redis } from 'src/redis';

export async function loginController(req: Request, res: Response) {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ message: 'Invalid email or password' });
  }

  try {
    const user = await UserModel.findByEmail(email);

    if (user) {
      const isValidPassword = await bcrypt.compare(password, user.password);

      if (!isValidPassword) {
        return res.status(400).json({ message: 'Invalid email or password' });
      }

      const token: string = await jwt.sign({
        id: user.id,
      });

      await Redis.setSession(user.id, token);

      res.status(200).json({ token });
    } else {
      return res.status(400).json({ message: 'Invalid email or password' });
    }
  } catch (error) {
    console.error(error);
    return res.sendStatus(500);
  }
}

Login Controller

Essentially, we begin by validating the provided fields and then checking 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:

import type { Request, Response } from 'express';
import { Redis } from 'src/redis';

export async function logoutController(req: Request, res: Response) {
  try {
    await Redis.deleteSession(req.user.id);
    return res.sendStatus(200);
  } catch (error) {
    return res.sendStatus(500);
  }
}

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:

import { Request, Response } from 'express';
import { UserModel } from 'src/models/UserModel';
import { Redis } from 'src/redis';

export const deleteUserController = async (req: Request, res: Response) => {
  const user_id = req.user.id;

  try {
    await Redis.deleteSession(user_id);
    await UserModel.delete(user_id);
    return res.sendStatus(200);
  } catch (error) {
    return res.sendStatus(500);
  }
};

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_idfrom 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:

import { Request, Response } from 'express';
import { UserModel } from 'src/models/UserModel';
import { filterObject } from 'src/utils/filterObject';

type Payload = {
  first_name?: string;
  last_name?: string;
  username?: string;
};

export const updateUserController = async (req: Request, res: Response) => {
  const { first_name, last_name, username } = req.body;
  const payload: Payload = filterObject({
    first_name,
    last_name,
    username,
  });

  try {
    const existingUserName = await UserModel.findByUsername(username);

    if (existingUserName) {
      return res.status(400).json({
        error: 'Invalid username',
      });
    }

    const updatedUser = await UserModel.updateOneById<typeof payload>(req.user.id, payload);
    res.status(200).json(updatedUser);
  } catch (error) {
    res.sendStatus(500);
  }
};

Update User Controller

Upon receiving a request, it extracts the fields first_namelast_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 the last controller from our list src/controllers/user/updatePasswordController.ts, and add the code:

import { Request, Response } from 'express';
import { UserModel } from 'src/models/UserModel';
import bcrypt from 'bcrypt';

export const updatePasswordController = async (req: Request, res: Response) => {
  try {
    const { password } = req.body;
    if (!password) return res.sendStatus(400);
    const hashedPassword = (await bcrypt.hash(password, 10)) as string;
    const user = await UserModel.updateOneById(req.user.id, { password: hashedPassword });

    return res.status(200).json({ id: user.id });
  } catch (error) {
    return res.sendStatus(500);
  }
};

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 updateOneByIdmethod 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:

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

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.

Conclusion

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 enhanced 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.

Resources

GitHub Repo
Knex.js
Express
Create Node Application
Postman


Also published here


Written by antonkalik | Senior Software Engineer @ Amenitiz / Node JS / React
Published by HackerNoon on 2024/03/17