Node.js + TypeScript + MongoDB: JWT Authentication - Phần cuối

EminelEminel
10 min read

Chào các bạn đây là phần 2 cũng là phần cuối của loạt bài viết "Node.js + TypeScript + MongoDB: JWT Authentication", xem lại phần 1 tại đây nhé.

Tạo Authentication Controller

Authentication Controller chịu trách nhiệm về mọi thứ liên quan đến xác thực người dùng. Dưới đây là một số công việc mà controller có thể thực hiện:

  • Đăng ký người dùng mới

  • Đăng nhập người dùng vào tài khoản của anh ấy

  • Gửi email đặt lại mật khẩu cho người dùng quên email hoặc mật khẩu của mình

  • Đặt lại mật khẩu của người dùng

  • Cập nhật mật khẩu của người dùng hiện đang đăng nhập

  • Xác thực bằng Google OAuth

  • Xác thực bằng GitHub OAuth

  • ...

src/controllers/auth.controller.ts

import config from 'config';
import { CookieOptions, NextFunction, Request, Response } from 'express';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import { createUser, findUser, signToken } from '../services/user.service';
import AppError from '../utils/appError';

// Exclude this fields from the response
export const excludedFields = ['password'];

// Cookie options
const accessTokenCookieOptions: CookieOptions = {
  expires: new Date(
    Date.now() + config.get<number>('accessTokenExpiresIn') * 60 * 1000
  ),
  maxAge: config.get<number>('accessTokenExpiresIn') * 60 * 1000,
  httpOnly: true,
  sameSite: 'lax',
};

// Only set secure to true in production
if (process.env.NODE_ENV === 'production')
  accessTokenCookieOptions.secure = true;

export const registerHandler = async (
  req: Request<{}, {}, CreateUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = await createUser({
      email: req.body.email,
      name: req.body.name,
      password: req.body.password,
    });

    res.status(201).json({
      status: 'success',
      data: {
        user,
      },
    });
  } catch (err: any) {
    if (err.code === 11000) {
      return res.status(409).json({
        status: 'fail',
        message: 'Email already exist',
      });
    }
    next(err);
  }
};

export const loginHandler = async (
  req: Request<{}, {}, LoginUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: req.body.email });

    // Check if user exist and password is correct
    if (
      !user ||
      !(await user.comparePasswords(user.password, req.body.password))
    ) {
      return next(new AppError('Invalid email or password', 401));
    }

    // Create an Access Token
    const { accessToken } = await signToken(user);

    // Send Access Token in Cookie
    res.cookie('accessToken', accessToken, accessTokenCookieOptions);
    res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send Access Token
    res.status(200).json({
      status: 'success',
      accessToken,
    });
  } catch (err: any) {
    next(err);
  }
};

Đây là phân tích những gì tôi đã làm trong tệp auth.controller.ts:

  • Chúng ta có hai hàm, registerHandler và loginHandler .

  • Khi người dùng cung cấp email, tên và mật khẩu của mình để đăng ký tài khoản, registerHandler sẽ được gọi sau đó registerHandler cũng sẽ gọi service createUser với thông tin xác thực người dùng được yêu cầu.

  • Service createUser sau đó sẽ giao tiếp với mô hình người dùng để tạo người dùng mới.

  • Trong khối catch của registerHandler, tôi đã kiểm tra xem lỗi có mã 11000 hay không, đây là mã lỗi MongoDB của một trường duy nhất trùng lặp.

  • Khi mã lỗi là 11000 thì điều đó có nghĩa là người dùng đã tồn tại, vì vậy chúng tôi sẽ gửi thông báo lỗi và mã trạng thái thích hợp.

  • Tiếp theo, trong loginHandler, tôi đã kiểm tra xem người dùng có email đó có tồn tại trong cơ sở dữ liệu MongoDB của chúng tôi hay không. Nếu người dùng tồn tại thì chúng tôi kiểm tra xem mật khẩu có giống với mật khẩu được mã hóa trong cơ sở dữ liệu hay không.

  • Sau đó, chúng tôi tạo mã thông báo truy cập JWT mới và gửi mã đó cho người dùng dưới dạng phản hồi và cookie.

Tạo bộ User Controller để test Authorization

Trong User Contrller có hai function

  1. getMeHandler trả về thông tin hồ sơ người dùng hiện đang đăng nhập.

  2. Hàm getAllUsersHandler chỉ dành cho Quản trị viên để lấy tất cả người dùng.

src/controllers/user.controller.ts

import { NextFunction, Request, Response } from 'express';
import { findAllUsers } from '../services/user.service';

export const getMeHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = res.locals.user;
    res.status(200).json({
      status: 'success',
      data: {
        user,
      },
    });
  } catch (err: any) {
    next(err);
  }
};

export const getAllUsersHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const users = await findAllUsers();
    res.status(200).json({
      status: 'success',
      result: users.length,
      data: {
        users,
      },
    });
  } catch (err: any) {
    next(err);
  }
};

Viết hàm để giải mã người dùng

Việc "giải mã" trong ngữ cảnh này là quá trình chuyển đổi một đối tượng User đã được mã hóa thành dạng tệp hoặc chuỗi... trở lại thành đối tượng trong ngôn ngữ lập trình.

Middleware này có trách nhiệm lấy mã thông báo JWT (JWT Authorization bearer token) và cookie từ tiêu đề (headers) và đối tượng cookie tương ứng.

Sau đó, nó xác thực mã thông báo, kiểm tra xem người dùng có phiên hợp lệ hay không, kiểm tra xem người dùng vẫn còn tồn tại và thêm người dùng vào res.locals nếu không có bất kỳ lỗi nào xảy ra.

src/middleware/deserializeUser.ts

import { NextFunction, Request, Response } from 'express';
import { findUserById } from '../services/user.service';
import AppError from '../utils/appError';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';

export const deserializeUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    // Get the token
    let access_token;
    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith('Bearer')
    ) {
      access_token = req.headers.authorization.split(' ')[1];
    } else if (req.cookies.access_token) {
      access_token = req.cookies.access_token;
    }

    if (!access_token) {
      return next(new AppError('You are not logged in', 401));
    }

    // Validate Access Token
    const decoded = verifyJwt<{ sub: string }>(access_token);

    if (!decoded) {
      return next(new AppError(`Invalid token or user doesn't exist`, 401));
    }

    // Check if user has a valid session
    const session = await redisClient.get(decoded.sub);

    if (!session) {
      return next(new AppError(`User session has expired`, 401));
    }

    // Check if user still exist
    const user = await findUserById(JSON.parse(session)._id);

    if (!user) {
      return next(new AppError(`User with that token no longer exist`, 401));
    }

    // This is really important (Helps us know if the user is logged in from other controllers)
    // You can do: (req.user or res.locals.user)
    res.locals.user = user;

    next();
  } catch (err: any) {
    next(err);
  }
};

Định nghĩa một hàm để kiểm tra xem người dùng đã đăng nhập hay chưa

Middleware này sẽ được gọi sau middleware deserializeUser để kiểm tra xem người dùng có tồn tại trong res.locals hay không.

src/middleware/requireUser.ts

import { NextFunction, Request, Response } from 'express';
import AppError from '../utils/appError';

export const requireUser = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = res.locals.user;
    if (!user) {
      return next(new AppError(`Invalid token or session has expired`, 401));
    }

    next();
  } catch (err: any) {
    next(err);
  }
};

Định nghĩa một Middleware để Hạn chế Truy cập Trái phép.

Middleware này kiểm tra xem vai trò (role) của người dùng có tồn tại trong mảng allowedRoles hay không. Nếu vai trò của người dùng có trong mảng này, điều đó có nghĩa là người dùng được phép thực hiện hành động đó, ngược lại nó sẽ tạo ra một lỗi.

src/middleware/restrictTo.ts

import { NextFunction, Request, Response } from 'express';
import AppError from '../utils/appError';

export const restrictTo =
  (...allowedRoles: string[]) =>
  (req: Request, res: Response, next: NextFunction) => {
    const user = res.locals.user;
    if (!allowedRoles.includes(user.role)) {
      return next(
        new AppError('You are not allowed to perform this action', 403)
      );
    }

    next();
  };

Tạo Authentication Routes

Bây giờ, hãy tạo hai thư mục định tuyến (route) có tên user.route.ts và auth.route.ts trong thư mục src.

Một route trong Express được coi như một ứng dụng nhỏ. Khi một yêu cầu phù hợp với route trong ngăn xếp middleware, Express sẽ ủy quyền yêu cầu đó cho trình xử lý route tương ứng của route đó.

src/routes/user.route.ts

import express from 'express';
import {
  getAllUsersHandler,
  getMeHandler,
} from '../controllers/user.controller';
import { deserializeUser } from '../middleware/deserializeUser';
import { requireUser } from '../middleware/requireUser';
import { restrictTo } from '../middleware/restrictTo';

const router = express.Router();
router.use(deserializeUser, requireUser);

// Admin Get Users route
router.get('/', restrictTo('admin'), getAllUsersHandler);

// Get my info route
router.get('/me', getMeHandler);

export default router;

Tệp user.route.ts chứa các route để:

  1. Lấy tất cả người dùng (chỉ bởi Admin)

  2. Lấy thông tin đăng nhập hiện tại (người dùng đang đăng nhập)

src/routes/auth.route.ts

import express from 'express';
import { loginHandler, registerHandler } from '../controllers/auth.controller';
import { validate } from '../middleware/validate';
import { createUserSchema, loginUserSchema } from '../schema/user.schema';

const router = express.Router();

// Register user route
router.post('/register', validate(createUserSchema), registerHandler);

// Login user route
router.post('/login', validate(loginUserSchema), loginHandler);

export default router;

Tệp auth.route.ts chứa các route để:

  1. Đăng nhập người dùng

  2. Đăng ký người dùng

Cập nhật tệp app.ts để sử dụng các route đã được định nghĩa.

Tiếp theo, cập nhật tệp app.ts để sử dụng các middleware sau:

  • Body Parser middleware để phân tích cú pháp (parse) nội dung yêu cầu và gắn nó vào req.body

  • Morgan để ghi log yêu cầu HTTP vào terminal

  • Cors để hỗ trợ chia sẻ tài nguyên qua nguồn gốc khác nhau (Cross-Origin Resource Sharing)

  • Cookie Parser để phân tích cú pháp (parse) cookie và gắn nó vào req.cookies

  • Bộ định tuyến người dùng (user router)

src/app.ts

require('dotenv').config();
import express, { NextFunction, Request, Response } from 'express';
import morgan from 'morgan';
import config from 'config';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import connectDB from './utils/connectDB';
import userRouter from './routes/user.route';
import authRouter from './routes/auth.route';

const app = express();

// Middleware

// 1. Body Parser
app.use(express.json({ limit: '10kb' }));

// 2. Cookie Parser
app.use(cookieParser());

// 3. Logger
if (process.env.NODE_ENV === 'development') app.use(morgan('dev'));

// 4. Cors
app.use(
  cors({
    origin: config.get<string>('origin'),
    credentials: true,
  })
);

// 5. Routes
app.use('/api/users', userRouter);
app.use('/api/auth', authRouter);

// Testing
app.get('/healthChecker', (req: Request, res: Response, next: NextFunction) => {
  res.status(200).json({
    status: 'success',
    message: 'Welcome to CodevoWeb????',
  });
});

// UnKnown Routes
app.all('*', (req: Request, res: Response, next: NextFunction) => {
  const err = new Error(`Route ${req.originalUrl} not found`) as any;
  err.statusCode = 404;
  next(err);
});

// Global Error Handler
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  err.status = err.status || 'error';
  err.statusCode = err.statusCode || 500;

  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
  });
});

const port = config.get<number>('port');
app.listen(port, () => {
  console.log(`Server started on port: ${port}`);
  // ? call the connectDB function here
  connectDB();
});

Kiếm tra JWT Authentication Rest API

Chạy lệnh sau để khởi động mongo và redis container

docker-compose up -d

Tiếp theo chúng ta khởi động server

Register users

Đăng ký user với route sau http://localhost:8000/api/auth/register

Sau khi đăng ký thành công sử dụng ứng dụng mongoCompass để truy cập và kiểm tra dữ liệu

Login User

Sau khi tạo user ở bước trên tiếp theo chúng ta sẽ login và kiểm tra

Ngoài kết quả được trả về ở body, accessToken cũng được trả về ở Cookies

Bây giờ chúng ta sẽ kiểm tra thông tin của user đang được lưu ở redis

Nếu nhìn kỹ hình trên bạn sẽ thấy thời gian của bản ghi còn 44 phút, tức là sau 44 phút bản ghi sẽ bị xóa, đây cũng là thời gian còn hiệu lực của access-token

Lấy thông tin hiện tại của người dùng đang login

Sau khi đăng nhập, hãy sao chép access-token và thêm vào header để có thể sử dụng được api /api/users/me

Admin Get All Users

Quay lại mongoCompas và truy cập vào collection user chọn 1 user bất kỳ đổi role thành admin, như tôi đang đổi user inovationthinking@gmail.com thành admin.Sau đó sử dụng api /api/users để get danh sách user

Tổng Kết

Vậy là tôi và các bạn đã đi qua hết loạt bài xác thực với người dùng sử dụng jwt trong nodejs.Hi vọng mọi người có thêm kiến thức để làm việc trong tương lai.Nếu có bất kỳ câu hỏi nào có thể comment xuống dưới để tôi có thể xem và hỗ trợ nhé.

0
Subscribe to my newsletter

Read articles from Eminel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eminel
Eminel

Hello, my name is Eminel a software engineer specializing in scalable software architecture, microservices, and AI-powered platforms. Passionate about mentoring, performance optimization, and building solutions that drive business success.