Node.js + TypeScript + MongoDB: JWT Authentication - Xác thực người dùng sử dụng JWT (Phần 2)

 


Chào các bạn đây là phần 2 của loạt bài viết "Xác thực người dùng sử dụng JWT", xem lại phần 1 tại đây

Cách tạo private key và pulic key để xác thực JWT
Có nhiều cách để tạo cặp private key và public key tuy nhiên trong bài viết này tôi hướng dẫn sử dụng các website để tạo cặp key cho ngắn gọn.
  • Truy cập vào website để sinh ra cặp key RSA, chú ý chọn độ dài key là 4096, vì với thư viện jsonwebtoken có version > 9 thì bắt buộc độ dài của khóa phải lớn hơn 2048.
  • Mã hóa cặp key được sinh ra ở phía trên bằng định dạng base64, truy cập vào website để mã hóa
Sau khi có cặp key thì copy cặp key vào file env như sau
.env
NODE_ENV=development
MONGODB_USERNAME=inovationthinking
MONGODB_PASSWORD=password123
MONGODB_DATABASE_NAME=jwtAuth

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBTlFLQStSV2ZQZFdHR25iYS9WRVo1TUs5cG1nMUlQay9paEE5dXF2Ny8rNVlzRjNUVURoCnFHZXN1bGJhdFFGdkNPaHVmSlNJQmFWT3RjbVZrTWZoWmRrQ0F3RUFBUUpBYkVlTkF6NnpaQzhBR3BhbGc4TmgKelBJdFNmaWFiWnd6dWVTcTh0L1RoRmQrUGhqN2IxTmphdjBMTjNGamhycjlzV3B2UjBBNW13OFpoSUFUNzZMUgpzUUloQU95Zmdhdy9BSTVoeGs3NmtWaVRRV0JNdjdBeERwdi9oSG1aUFdxclpyL1ZBaUVBNVdjalpmK0NaYlhTCnlpV3dUbEVENGVZQ3BSNk16Qk8wbFVhbExKdVRFL1VDSUhWTWZSUE9CNUNObDZqL1BaNFRJWTJEZm1MeGJyU1cKYmkxNWNhQzNaekFoQWlBNmUrVG1hQkdTWkp4c3ROY1I0RTJoRmNhdTJlOERTRExOcThrSWFsRkEwUUloQUlwUApUODFlWlNzYmVrNTlidGJPZ3J3bTJBdzJqUVk4TitJa3FMSTNySWFFCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBTlFLQStSV2ZQZFdHR25iYS9WRVo1TUs5cG1nMUlQawovaWhBOXVxdjcvKzVZc0YzVFVEaHFHZXN1bGJhdFFGdkNPaHVmSlNJQmFWT3RjbVZrTWZoWmRrQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
Định nghĩa Middleware để ký và xác minh JWT
Tiếp theo, tạo một thư mục middleware trong thư mục src và tạo một tệp có tên tệp jwt.ts trong thư mục middleware. Sao chép và dán mã bên dưới vào tệp jwt.ts.
src/utils/jwt.ts
import jwt, { SignOptions } from 'jsonwebtoken';
import config from 'config';

export const signJwt = (payload: Object, options: SignOptions = {}) => {
  const privateKey = Buffer.from(
    config.get<string>('accessTokenPrivateKey'),
    'base64'
  ).toString('ascii');
  return jwt.sign(payload, privateKey, {
    ...(options && options),
    algorithm: 'RS256',
  });
};

export const verifyJwt = <T>(token: string): T | null => {
  try {
    const publicKey = Buffer.from(
      config.get<string>('accessTokenPublicKey'),
      'base64'
    ).toString('ascii');
    return jwt.verify(token, publicKey) as T;
  } catch (error) {
    return null;
  }
};
Để tôi giải thích những gì tôi đã làm trong tệp jwt.ts:
  • Tôi đã export hai hàm signJwt và verifyJwt.
  • Hàm signJwt có trách nhiệm ký một JsonWebToken mới. Hàm signJwt nhận hai tham số là object payload và object SignOptions.
  • Trong hàm signJwt, tôi đã chuyển đổi private key được mã hóa mà chúng ta lưu trữ trong tệp .env thành một chuỗi ASCII.
  • Tiếp theo, tôi đã biến hàm verifyJwt trở thành một generic function để nó có thể trả về generic type hoặc giá trị null.
  • Khi hàm verifyJwt trả về giá trị null, điều đó có nghĩa là mã thông báo không hợp lệ hoặc đã hết hạn.
Định nghĩa Trình xử lý lỗi tùy chỉnh trong Express
Tiếp theo, hãy tạo một custom Express Error Handler bằng cách mở rộng lớp Error.
src/utils/appError.ts
export default class AppError extends Error {
  status: string;
  isOperational: boolean;

  constructor(public message: string, public statusCode: number = 500) {
    super(message);
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }
}
Dưới đây là middleware để hanlde các lỗi
app.use((err: any, req: Request, res: Response, next: NextFunction) =&gt; {
  err.status = err.status || 'error';
  err.statusCode = err.statusCode || 500;

  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
  });
});
Đoạn mã trên là một middleware xử lý lỗi trong ứng dụng Express. Khi có lỗi xảy ra trong các middleware hoặc router, Express sẽ chuyển quyền kiểm soát đến middleware này.

Nếu có lỗi, đoạn mã sẽ gán giá trị mặc định cho các trường của lỗi (nếu chúng không được đặt trước đó).
Sau đó, nó trả về một phản hồi JSON với thông tin lỗi, bao gồm mã trạng thái và thông điệp của lỗi, để gửi về cho người dùng.
Định nghĩa Zod Validation Schema
Với mọi backend application, tốt nhất là không nên tin vào đầu vào của người dùng. Bạn phải luôn xác thực đầu vào của người dùng trong backend application của mình. 
Bản thân Mongoose đi kèm với schema validation nhưng vì chúng ta đang sử dụng TypeScript nên chúng ta có thể thêm Zod Validation Schema.
Dưới đây là các quy tắc schema validation cho cả chức năng đăng nhập và đăng ký.
src/schemas/user.schema.ts
import { object, string, TypeOf } from 'zod';

export const createUserSchema = object({
  body: object({
    name: string({ required_error: 'Name is required' }),
    email: string({ required_error: 'Email is required' }).email(
      'Invalid email'
    ),
    password: string({ required_error: 'Password is required' })
      .min(8, 'Password must be more than 8 characters')
      .max(32, 'Password must be less than 32 characters'),
    passwordConfirm: string({ required_error: 'Please confirm your password' }),
  }).refine((data) => data.password === data.passwordConfirm, {
    path: ['passwordConfirm'],
    message: 'Passwords do not match',
  }),
});

export const loginUserSchema = object({
  body: object({
    email: string({ required_error: 'Email is required' }).email(
      'Invalid email or password'
    ),
    password: string({ required_error: 'Password is required' }).min(
      8,
      'Invalid email or password'
    ),
  }),
});

export type CreateUserInput = TypeOf<typeof createUserSchema>['body'];
export type LoginUserInput = TypeOf<typeof loginUserSchema
>['body'];

Tạo một Middleware để xác thực đầu vào của người dùng
Hàm này chịu trách nhiệm xác thực đầu vào của người dùng dựa trên schema được truyền cho nó dưới dạng đối số.
src/middleware/validate.ts
import { NextFunction, Request, Response } from 'express';
import { AnyZodObject, ZodError } from 'zod';

export const validate =
  (schema: AnyZodObject) =>
  (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        params: req.params,
        query: req.query,
        body: req.body,
      });

      next();
    } catch (err: any) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          status: 'fail',
          error: err.errors,
        });
      }
      next(err);
    }
  };
Tạo một Service để giao tiếp với database
Bạn nên luôn có các dịch vụ giao tiếp với cơ sở dữ liệu vì một vài lý do. 
Đừng để controller của bạn truy cập và thay đổi cơ sở dữ liệu trực tiếp nếu bạn muốn mở rộng quy mô và dễ dàng kiểm tra server Express của mình. 
Dưới đây là các chức năng giao tiếp với cơ sở dữ liệu. tôi cũng đã thêm logic để ký mã thông báo JWT mới.
src/services/user.service.ts
import { excludedFields } from '../controllers/auth.controller';
import { signJwt } from '../utils/jwt';
import redisClient from '../utils/connectRedis';
import { DocumentType } from '@typegoose/typegoose';

// CreateUser service
export const createUser = async (input: Partial<User>) => {
  const user = await userModel.create(input);
  return omit(user.toJSON(), excludedFields);
};

// Find User by Id
export const findUserById = async (id: string) => {
  const user = await userModel.findById(id).lean();
  return omit(user, excludedFields);
};

// Find All users
export const findAllUsers = async () => {
  return await userModel.find();
};

// Find one user by any fields
export const findUser = async (
  query: FilterQuery<User>,
  options: QueryOptions = {}
) => {
  return await userModel.findOne(query, {}, options).select('+password');
};

// Sign Token
export const signToken = async (user: DocumentType<User>) => {
  // Sign the access token
  const access_token = signJwt(
    { sub: user._id },
    {
      expiresIn: `${config.get<number>('accessTokenExpiresIn')}m`,
    }
  );

  // Create a Session
  redisClient.set(user._id.toString(), JSON.stringify(user), {
    EX: 60 * 60,
  });

  // Return access token
  return { access_token };
};
Những điều quan trọng cần lưu ý trong đoạn mã trên: 
Trong hàm signToken, lần đầu tiên tôi tạo mã thông báo truy cập và chỉ định thời gian hết hạn tính bằng phút. Sau đó, tôi đã gọi phương thức set trên đối tượng redisClient và sử dụng id người dùng làm khóa và thông tin của người dùng làm giá trị được lưu trữ trong Redis. Tôi cũng đã cho dữ liệu thời gian hết hạn.

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();
});
Kết thúc phần 2, mời các bạn đón xem phần 3

Nhận xét

Bài đăng phổ biến từ blog này

Cài đặt SSL cho website sử dụng certbot

Xây dựng một hệ thống comment real-time hoặc chat đơn giản sử dụng Pusher

CÁC BÀI TẬP SQL CƠ BẢN - PART 1

Xây dựng một hệ thống tracking hành vi người dùng (phần 1)

Xây dựng một hệ thống tracking hành vi người dùng (phần 2)

Enterprise architecture trên 1 tờ A4

Web caching (P2)

Bàn về async/await trong vòng lặp javascript

Web caching (P1)

Cài đặt môi trường để code website Rails