» Node.js: Build a REST API with Express » 2. Development » 2.12 Authentication

Authentication

Authentication in an API server is typically needed when you want to control access to certain resources or functionalities.

In other words, if you want to restrict access to certain API endpoints or data to users with special roles (e.g., administrator, moderator, or premium user), authentication is required.

Traditional User Flow

Update code

Add User domain entity in domain/model/user.ts:

// User represents an app user
export interface User {
  id?: number;
  email: string;
  password: string;
  salt: string;
  is_admin: boolean;
  created_at?: Date;
  updated_at?: Date;
}

Declare its business capabilities in domain/gateway/user_manager.ts:

import { User } from "@/domain/model";

export interface UserManager {
  createUser(u: User): Promise<number>;
  getUserByEmail(email: string): Promise<User | null>;
}

Add implementations in infrastructure/database/mysql.ts:

@@ -1,9 +1,9 @@
 import mysql, { ResultSetHeader, RowDataPacket } from "mysql2";
 
-import { Book } from "@/domain/model/book";
-import { BookManager } from "@/domain/gateway/book_manager";
+import { Book, User } from "@/domain/model";
+import { BookManager, UserManager } from "@/domain/gateway";
 
-export class MySQLPersistence implements BookManager {
+export class MySQLPersistence implements BookManager, UserManager {
   private db: mysql.Connection;
   private page_size: number;
 
@@ -84,6 +84,25 @@ export class MySQLPersistence implements BookManager {
     return rows as Book[];
   }
 
+  async createUser(u: User): Promise<number> {
+    const { email, password, salt, is_admin } = u;
+    const [result] = await this.db
+      .promise()
+      .query(
+        "INSERT INTO users (email, password, salt, is_admin, created_at, updated_at) VALUES (?, ?, ?, ?, now(), now())",
+        [email, password, salt, is_admin]
+      );
+    return (result as ResultSetHeader).insertId;
+  }
+
+  async getUserByEmail(email: string): Promise<User | null> {
+    let [rows] = await this.db
+      .promise()
+      .query("SELECT * FROM users WHERE email = ?", [email]);
+    rows = rows as RowDataPacket[];
+    return rows.length ? (rows[0] as User) : null;
+  }
+
   close(): void {
     this.db.end();
   }

Do the sign-up and sign-in preparation work in application/executor/user_operator.ts:

import { createHash } from "crypto";

import { UserManager } from "@/domain/gateway";
import { UserCredential, User } from "@/application/dto";

const saltLen = 4;
const errEmptyEmail = "empty email";
const errEmptyPassword = "empty password";
const errDoesNotExist = "user does not exist";

export class UserOperator {
  private userManager: UserManager;

  constructor(u: UserManager) {
    this.userManager = u;
  }

  async createUser(uc: UserCredential): Promise<User | null> {
    if (!uc.email) {
      throw new Error(errEmptyEmail);
    }
    if (!uc.password) {
      throw new Error(errEmptyPassword);
    }
    const salt = randomString(saltLen);
    const user = {
      email: uc.email,
      password: sha1Hash(uc.password + salt),
      salt,
      is_admin: false,
    };
    const id = await this.userManager.createUser(user);
    return { id, email: uc.email };
  }

  async signIn(email: string, password: string): Promise<User | null> {
    if (!email) {
      throw new Error(errEmptyEmail);
    }
    if (!password) {
      throw new Error(errEmptyPassword);
    }
    const user = await this.userManager.getUserByEmail(email);
    if (!user) {
      throw new Error(errDoesNotExist);
    }
    const passwordHash = sha1Hash(password + user.salt);
    if (user.password !== passwordHash) {
      throw new Error("wrong password");
    }
    return { id: user.id!, email: user.email };
  }
}

function randomString(length: number): string {
  const charset: string =
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  let result: string = "";
  for (let i = 0; i < length; i++) {
    result += charset.charAt(Math.floor(Math.random() * charset.length));
  }
  return result;
}

function sha1Hash(input: string): string {
  return createHash("sha1").update(input).digest("hex");
}

function randomString and sha1Hash are helper functions for salt generation and password hashing.

Why do you need a salt?

Without a salt, an attacker could precompute hashes for common passwords and store them in a table (known as a rainbow table). If the database is compromised, the attacker can then compare the hashed passwords against the precomputed hashes to quickly identify passwords. Adding a salt to each password ensures that even if two users have the same password, their hashed passwords will be different due to the unique salt.

Even if a user chooses a weak password, such as a common dictionary word, the salt ensures that the resulting hash is unique. Without a salt, all instances of the same password would hash to the same value, making them vulnerable to attacks.

Add DTOs in application/dto/user.ts:

// UserCredential represents the user's sign-in email and password
export interface UserCredential {
  email: string;
  password: string;
}

// User is used as result of a successful sign-in
export interface User {
  id: number;
  email: string;
}

DTO stands for Data Transfer Object.

Often, the internal representation of data within your application might differ from what you want to expose through your API. DTOs allow you to transform your internal data structures into a format that's more suitable for the API consumers. This helps in decoupling the internal representation from the external one, allowing for better flexibility and abstraction.

Wire in new dependencies in application/wire_helper.ts:

@@ -1,5 +1,5 @@
 import { Config } from "@/infrastructure/config";
-import { BookManager, ReviewManager } from "@/domain/gateway";
+import { BookManager, ReviewManager, UserManager } from "@/domain/gateway";
 import { MySQLPersistence, MongoPersistence } from "@/infrastructure/database";
 import { RedisCache, CacheHelper } from "@/infrastructure/cache";
 
@@ -22,6 +22,10 @@ export class WireHelper {
     return this.sql_persistence;
   }
 
+  userManager(): UserManager {
+    return this.sql_persistence;
+  }
+
   reviewManager(): ReviewManager {
     return this.no_sql_persistence;
   }

Finnally, add routes in adapter/router.ts:

@@ -2,16 +2,26 @@ import express, { Request, Response } from "express";
 import morgan from "morgan";
 
 import { Book } from "@/domain/model";
-import { BookOperator, ReviewOperator } from "@/application/executor";
+import {
+  BookOperator,
+  ReviewOperator,
+  UserOperator,
+} from "@/application/executor";
 import { WireHelper } from "@/application";
 
 class RestHandler {
   private bookOperator: BookOperator;
   private reviewOperator: ReviewOperator;
+  private userOperator: UserOperator;
 
-  constructor(bookOperator: BookOperator, reviewOperator: ReviewOperator) {
+  constructor(
+    bookOperator: BookOperator,
+    reviewOperator: ReviewOperator,
+    userOperator: UserOperator
+  ) {
     this.bookOperator = bookOperator;
     this.reviewOperator = reviewOperator;
+    this.userOperator = userOperator;
   }
 
   // Get all books
@@ -163,13 +173,39 @@ class RestHandler {
       res.status(404).json({ error: "failed to delete the review" });
     }
   }
+
+  public async userSignUp(req: Request, res: Response): Promise<void> {
+    const userCredential = req.body;
+    try {
+      const result = await this.userOperator.createUser(userCredential);
+      res.status(201).json(result);
+    } catch (err) {
+      console.error(`Failed to create: ${err}`);
+      res.status(404).json({ error: "failed to create the user" });
+    }
+  }
+
+  public async userSignIn(req: Request, res: Response): Promise<void> {
+    const userCredential = req.body;
+    try {
+      const result = await this.userOperator.signIn(
+        userCredential.email,
+        userCredential.password
+      );
+      res.status(200).json(result);
+    } catch (err: any) {
+      console.error(`Failed to sign in: ${err}`);
+      res.status(404).json({ error: err.message });
+    }
+  }
 }
 
 // Create router
 function MakeRouter(wireHelper: WireHelper): express.Router {
   const restHandler = new RestHandler(
     new BookOperator(wireHelper.bookManager(), wireHelper.cacheHelper()),
-    new ReviewOperator(wireHelper.reviewManager())
+    new ReviewOperator(wireHelper.reviewManager()),
+    new UserOperator(wireHelper.userManager())
   );
 
   const router = express.Router();
@@ -188,6 +224,9 @@ function MakeRouter(wireHelper: WireHelper): express.Router {
   router.put("/reviews/:id", restHandler.updateReview.bind(restHandler));
   router.delete("/reviews/:id", restHandler.deleteReview.bind(restHandler));
 
+  router.post("/users", restHandler.userSignUp.bind(restHandler));
+  router.post("/users/sign-in", restHandler.userSignIn.bind(restHandler));
+
   return router;
 }

Try with curl

User sign up

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-node@example.com", "password": "test-pass"}' http://localhost:3000/users

Result:

{"id":5,"email":"test-node@example.com"}

If you check out the records in the database, you will find something like this:

+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
| id | email                 | password                                 | salt | is_admin | created_at              | updated_at              |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
|  6 | test-node@example.com | b436b35185aa826695c4d0cc5de180a9d4be3716 | amGF |        0 | 2024-03-02 09:38:22.000 | 2024-03-02 09:38:22.000 |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+

User sign in successfully

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-node@example.com", "password": "test-pass"}' http://localhost:3000/users/sign-in

Result:

{"id":2,"email":"test-user@example.com"}

User sign in with an incorrect password

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-node@example.com", "password": "wrong-pass"}' http://localhost:3000/users/sign-in

Result:

{"error":"wrong password"}

User sign in with a non-existent email

curl -X POST -H "Content-Type: application/json" -d '{"email": "non-existent@example.com", "password": "test-pass"}' http://localhost:3000/users/sign-in

Result:

{"error":"user does not exist"}

Note:
It's good practice to use a uniform JSON response format for both successful and failed responses.
e.g.

{"code":0, "message":"ok", "data": {"id":2,"email":"test-user@example.com"}}
{"code":1001, "message":"wrong password", "data": null}

Use JWT (JSON Web Token)

JWT, or JSON Web Token, is a compact, URL-safe means of representing claims to be transferred between two parties. It's often used for authentication and authorization in web applications and APIs.

Install JWT dependency

npm i jsonwebtoken

And its type definitions:

npm install @types/jsonwebtoken --save-dev

Update code

Add UserPermission enums in domain/model/user.ts:

@@ -1,3 +1,11 @@
+// UserPermission represents different levels of user permissions.
+export enum UserPermission {
+  PermNone = 0,
+  PermUser = 1,
+  PermAuthor = 2,
+  PermAdmin = 3,
+}
+
 // User represents an app user
 export interface User {
   id?: number;

Declare its business capabilities in domain/gateway/user_manager.ts:

@@ -1,6 +1,11 @@
-import { User } from "@/domain/model";
+import { User, UserPermission } from "@/domain/model";
 
 export interface UserManager {
   createUser(u: User): Promise<number>;
   getUserByEmail(email: string): Promise<User | null>;
 }
+
+export interface PermissionManager {
+  generateToken(userID: number, email: string, perm: UserPermission): string;
+  hasPermission(token: string, perm: UserPermission): boolean;
+}

Implement these 2 methods in infrastructure/token/jwt.ts:

import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken";
import { UserPermission } from "@/domain/model/user";
import { PermissionManager } from "@/domain/gateway";

const errInvalidToken = "invalid token";

// UserClaims includes user info.
interface UserClaims {
  userID: number;
  userName: string;
  permission: UserPermission;
  [key: string]: any;
}

// Keeper manages user tokens.
export class TokenKeeper implements PermissionManager {
  private secretKey: Buffer;
  private expireHours: number;

  constructor(secretKey: string, expireInHours: number) {
    this.secretKey = Buffer.from(secretKey);
    this.expireHours = expireInHours;
  }

  // GenerateToken generates a new JWT token.
  generateToken(userID: number, email: string, perm: UserPermission): string {
    const expirationTime =
      Math.floor(Date.now() / 1000) + this.expireHours * 3600;
    const payload: UserClaims = {
      userID,
      userName: email,
      permission: perm,
      exp: expirationTime,
    };

    const options: SignOptions = {
      algorithm: "HS256",
    };

    return jwt.sign(payload, this.secretKey, options);
  }

  // ExtractToken extracts the token from the signed string.
  extractToken(tokenResult: string): UserClaims {
    const options: VerifyOptions = {
      algorithms: ["HS256"],
    };

    try {
      const decoded = jwt.verify(
        tokenResult,
        this.secretKey,
        options
      ) as UserClaims;
      return decoded;
    } catch (error) {
      throw new Error(errInvalidToken);
    }
  }

  // HasPermission checks if user has the given permission.
  hasPermission(tokenResult: string, perm: UserPermission): boolean {
    const claims = this.extractToken(tokenResult);
    return claims.permission >= perm;
  }
}

Add config items for Tokens in infrastructure/config/config.ts:

@@ -10,6 +10,8 @@ interface DBConfig {
 interface ApplicationConfig {
   port: number;
   page_size: number;
+  token_secret: string;
+  token_hours: number;
 }
 
 export interface CacheConfig {

Put in setting values in config.json:

@@ -1,7 +1,9 @@
 {
   "app": {
     "port": 3000,
-    "page_size": 5
+    "page_size": 5,
+    "token_secret": "I_Love_LiteRank",
+    "token_hours": 48
   },
   "db": {
     "file_name": "test.db",

Generate tokens and return them in application/executor/user_operator.ts:

@@ -1,7 +1,8 @@
 import { createHash } from "crypto";
 
-import { UserManager } from "@/domain/gateway";
-import { UserCredential, User } from "@/application/dto";
+import { PermissionManager, UserManager } from "@/domain/gateway";
+import { UserCredential, User, UserToken } from "@/application/dto";
+import { UserPermission } from "@/domain/model";
 
 const saltLen = 4;
 const errEmptyEmail = "empty email";
@@ -10,9 +11,11 @@ const errDoesNotExist = "user does not exist";
 
 export class UserOperator {
   private userManager: UserManager;
+  private permManager: PermissionManager;
 
-  constructor(u: UserManager) {
+  constructor(u: UserManager, p: PermissionManager) {
     this.userManager = u;
+    this.permManager = p;
   }
 
   async createUser(uc: UserCredential): Promise<User | null> {
@@ -33,7 +36,7 @@ export class UserOperator {
     return { id, email: uc.email };
   }
 
-  async signIn(email: string, password: string): Promise<User | null> {
+  async signIn(email: string, password: string): Promise<UserToken | null> {
     if (!email) {
       throw new Error(errEmptyEmail);
     }
@@ -48,10 +51,19 @@ export class UserOperator {
     if (user.password !== passwordHash) {
       throw new Error("wrong password");
     }
-    return { id: user.id!, email: user.email };
+    const token = this.permManager.generateToken(
+      user.id!,
+      user.email,
+      calcPerm(user.is_admin)
+    );
+    return { user: { id: user.id!, email: user.email }, token };
   }
 }
 
+function calcPerm(isAdmin: boolean): UserPermission {
+  return isAdmin ? UserPermission.PermAdmin : UserPermission.PermUser;
+}
+
 function randomString(length: number): string {
   const charset: string =
     "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

Add DTOs in application/dto/user.ts:

@@ -9,3 +9,8 @@ export interface User {
   id: number;
   email: string;
 }
+
+export interface UserToken {
+  user: User;
+  token: string;
+}

Tune application/wire_helper.ts:

@@ -1,13 +1,20 @@
 import { Config } from "@/infrastructure/config";
-import { BookManager, ReviewManager, UserManager } from "@/domain/gateway";
+import {
+  BookManager,
+  PermissionManager,
+  ReviewManager,
+  UserManager,
+} from "@/domain/gateway";
 import { MySQLPersistence, MongoPersistence } from "@/infrastructure/database";
 import { RedisCache, CacheHelper } from "@/infrastructure/cache";
+import { TokenKeeper } from "@/infrastructure/token";
 
 // WireHelper is the helper for dependency injection
 export class WireHelper {
   private sql_persistence: MySQLPersistence;
   private no_sql_persistence: MongoPersistence;
   private kv_store: RedisCache;
+  private tokenKeeper: TokenKeeper;
 
   constructor(c: Config) {
     this.sql_persistence = new MySQLPersistence(c.db.dsn, c.app.page_size);
@@ -16,12 +23,17 @@ export class WireHelper {
       c.db.mongo_db_name
     );
     this.kv_store = new RedisCache(c.cache);
+    this.tokenKeeper = new TokenKeeper(c.app.token_secret, c.app.token_hours);
   }
 
   bookManager(): BookManager {
     return this.sql_persistence;
   }
 
+  permManager(): PermissionManager {
+    return this.tokenKeeper;
+  }
+
   userManager(): UserManager {
     return this.sql_persistence;
   }

Add permCheck middleware into routes for Create/Update/Delete operations in adapter/router.ts:

@@ -1,13 +1,14 @@
 import express, { Request, Response } from "express";
 import morgan from "morgan";
 
-import { Book } from "@/domain/model";
+import { Book, UserPermission } from "@/domain/model";
 import {
   BookOperator,
   ReviewOperator,
   UserOperator,
 } from "@/application/executor";
 import { WireHelper } from "@/application";
+import { permCheck } from "@/adapter/middleware";
 
 class RestHandler {
   private bookOperator: BookOperator;
@@ -202,19 +203,32 @@ class RestHandler {
 
 // Create router
 function MakeRouter(wireHelper: WireHelper): express.Router {
+  const pm = wireHelper.permManager();
   const restHandler = new RestHandler(
     new BookOperator(wireHelper.bookManager(), wireHelper.cacheHelper()),
     new ReviewOperator(wireHelper.reviewManager()),
-    new UserOperator(wireHelper.userManager())
+    new UserOperator(wireHelper.userManager(), pm)
   );
 
   const router = express.Router();
 
   router.get("/books", restHandler.getBooks.bind(restHandler));
   router.get("/books/:id", restHandler.getBook.bind(restHandler));
-  router.post("/books", restHandler.createBook.bind(restHandler));
-  router.put("/books/:id", restHandler.updateBook.bind(restHandler));
-  router.delete("/books/:id", restHandler.deleteBook.bind(restHandler));
+  router.post(
+    "/books",
+    permCheck(pm, UserPermission.PermAuthor),
+    restHandler.createBook.bind(restHandler)
+  );
+  router.put(
+    "/books/:id",
+    permCheck(pm, UserPermission.PermAuthor),
+    restHandler.updateBook.bind(restHandler)
+  );
+  router.delete(
+    "/books/:id",
+    permCheck(pm, UserPermission.PermAuthor),
+    restHandler.deleteBook.bind(restHandler)
+  );
   router.get(
     "/books/:id/reviews",
     restHandler.getReviewsOfBook.bind(restHandler)

permCheck is implemented in adapter/middleware.ts:

import { Request, Response, NextFunction } from "express";
import { UserPermission } from "@/domain/model";
import { PermissionManager } from "@/domain/gateway";

const tokenPrefix = "Bearer ";

// permCheck middleware checks user permission
export function permCheck(
  permManager: PermissionManager,
  allowPerm: UserPermission
): (req: Request, res: Response, next: NextFunction) => void {
  return async (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith(tokenPrefix)) {
      return res.status(401).json({ error: "token is required" });
    }

    const token = authHeader.replace(tokenPrefix, "");

    try {
      if (!permManager.hasPermission(token, allowPerm)) {
        return res.status(401).json({ error: "Unauthorized" });
      }
      next();
    } catch (error: any) {
      return res.status(401).json({ error: error.message });
    }
  };
}

Those are all the changes you need to utilize JWT in your API server. Let's try it out again!

Try with curl

User sign in and get a Token

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:3000/users/sign-in

Result:

{
  "user": { "id": 2, "email": "test-user@example.com" },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjoxLCJleHAiOjE3MDkyNzA1MzV9.eJp5Yr9YZMxHFs5eId78bEJ6draO178jysquZ2VV9v8"
}

Put the token into Debugger on https://jwt.io/, then you can check the payload and verify the signature of your token.

JWT Debugger

Create a new book with a valid and proper Token

curl -X POST \
  http://localhost:3000/books \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOjIsInVzZXJOYW1lIjoidGVzdC11c2VyQGV4YW1wbGUuY29tIiwicGVybWlzc2lvbiI6MywiZXhwIjoxNzA5NTQ5OTk4LCJpYXQiOjE3MDkzNzcxOTh9.olJ2ITlSEKYiYAymtmALK-qTfK9y7Kp1Q0mCmBKtBCw' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

Successful result:

{
  "title": "Test Book",
  "author": "John Doe",
  "published_at": "2003-01-01",
  "description": "A sample book description",
  "isbn": "1234567890",
  "total_pages": 100,
  "id": 16
}

Create a new book with a Token, but it doesn't have proper permissions

curl -X POST \
  http://localhost:3000/books \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOjYsInVzZXJOYW1lIjoidGVzdC1ub2RlQGV4YW1wbGUuY29tIiwicGVybWlzc2lvbiI6MSwiZXhwIjoxNzA5NTUwNDc0LCJpYXQiOjE3MDkzNzc2NzR9.PBUUkMw5xTia3-8_rHc1iTMffeW8u2mpC0qrLmq5YQE' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

Result:

{"error":"Unauthorized"}

Create a new book without a Token

curl -X POST \
  http://localhost:3000/books \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

Result:

{"error":"token is required"}

Create a new book with a fake Token

curl -X POST \
  http://localhost:3000/books \
  -H 'Authorization: Bearer FAKE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

Result:

{"error":"invalid token"}

Perfect! 🎉

Now, some of your API endpoints are protected by the authentication mechanism.