TypeScript Rest API with Express.js, JWT, Authorization Roles and TypeORM

Hôm nay, chúng ta sẽ sử dụng TypeScirpt Express.js và TypeORM tạo một Rest API với JWT xác thực và phân quyền cơ bản.
TypeORM cho phép chỉ viết một class với công cụ đồng bộ hóa, nó tự động tạo ra cấu trúc SQL cho entity của bạn. Với gói xác thực class, chũng ta có thể sử dụng cùng một lớp để xác thực.
Nó tương thích với MySQL / MariaDB / Postgres / SQLite / Microsoft SQL Server / Oracle / sql.js / MongoDB. Bạn có thể chuyển đổi các cơ sở dữ liệu với nha mà không phải đổi mã. Chúng ta sẽ sử dụng SQLite cho project này. Tôi khuyên bạn không nên dùng nó cho production. Nhưng vì không biết bạn dùng database gì, nên tôi tạo dự án chung bạn có thể dễ dàng cài đặt thông qua npm, không phải cài đặt database.

Bắt đầu

TypeORM có một CLI cho phép tạo một ứng dụng cơ bản với TypeScript. Để có công cụ này, chúng ta cần cài typeORM global

sudo npm install -g typeorm

Bây giờ chúng ta có thể cài đặt ứng dụng của chúng ta.

typeorm init --name jwt-express-typeorm --database sqlite --express

Nó sẽ tạo một ứng dụng express với TypeScript và body-parser. Hãy cài đặt những dependencies này vơi lệnh.

npm install

Chúng ta cài dặt thêm vài dependencies nữa.

npm install -s helmet cors jsonwebtoken bcryptjs class-validator ts-node-dev

sau đõ cũng ta sẽ có vài dependencies sau:
helmet
Giúp chúng ta bảo mật giáo trị trong header cors
cors
Cho phép cross-origin Requests
body-parser
Chuyển đổi request của client từ json thành javascript object
jsonwebtoken
Sẽ xử lý jwt cho chúng ta
bcryptjs
Giúp chúng ta hash password
typeorm
ORM cho phép chúng ta thao tác với database
reflect-metadata
cho phép tính năng annotations được sử dụng với TypeORM
class-validator
Validate trong TypeORM
sqlite3
Chúng ta sẽ sử dụng sqlite
ts-node-dev
Tự động khởi động lại khi thay đổi tập tin bất kỳ

Cài đặt type check dependencies

Chúng ta đang làm việc với typescript, nên cài đặt @type là ý tưởng hay

npm install -s @types/bcryptjs @types/body-parser @types/cors @types/helmet @types/jsonwebtoken

Sau đó, chúng ta có thể sử dụng nó tự động hoàn toàn và typecheck ngay cả với các gói javascript

Thư mục src

TypeORM CLI tạo một thư mục src chứ toàn bộ các file typescript. Chúng ta sẽ chỉnh sửa các file cho API của chúng ta.

Index

CLI sẽ tạo một file index.ts là đầu vào của ứng dụng.
Hãy viết lại để phù hợp với mục đính của chúng ta.

import "reflect-metadata";
import { createConnection } from "typeorm";
import * as express from "express";
import * as bodyParser from "body-parser";
import * as helmet from "helmet";
import * as cors from "cors";
import routes from "./routes";

//Connects to the Database -> then starts the express
createConnection()
  .then(async connection => {
    // Create a new express application instance
    const app = express();

    // Call midlewares
    app.use(cors());
    app.use(helmet());
    app.use(bodyParser.json());

    //Set all routes from routes folder
    app.use("/", routes);

    app.listen(3000, () => {
      console.log("Server started on port 3000!");
    });
  })
  .catch(error => console.log(error));

Routes

CLI cũng tạo một file routes.ts. Trong dự án lớn, không phải là một ý tưởng tốt khi đưa hết routes vào trong cùng một file. Chúng ta tạo một thư mục routes, và một file routes/index.ts trong có chứa các đường dẫn các tập tin khác.

routes/auth.ts

import { Router } from "express";
import AuthController from "../controllers/AuthController";
import { checkJwt } from "../middlewares/checkJwt";

const router = Router();
//Login route
router.post("/login", AuthController.login);

//Change my password
router.post("/change-password", [checkJwt], AuthController.changePassword);

export default router;

routes/user.ts

 import { Router } from "express";
  import UserController from "../controllers/UserController";
  import { checkJwt } from "../middlewares/checkJwt";
  import { checkRole } from "../middlewares/checkRole";

  const router = Router();

  //Get all users
  router.get("/", [checkJwt, checkRole(["ADMIN"])], UserController.listAll);

  // Get one user
  router.get(
    "/:id([0-9]+)",
    [checkJwt, checkRole(["ADMIN"])],
    UserController.getOneById
  );

  //Create a new user
  router.post("/", [checkJwt, checkRole(["ADMIN"])], UserController.newUser);

  //Edit one user
  router.patch(
    "/:id([0-9]+)",
    [checkJwt, checkRole(["ADMIN"])],
    UserController.editUser
  );

  //Delete one user
  router.delete(
    "/:id([0-9]+)",
    [checkJwt, checkRole(["ADMIN"])],
    UserController.deleteUser
  );

  export default router;

routes/index.ts

import { Router, Request, Response } from "express";
import auth from "./auth";
import user from "./user";

const routes = Router();

routes.use("/auth", auth);
routes.use("/user", user);

export default routes;import { Router, Request, Response } from "express";
import auth from "./auth";
import user from "./user";

const routes = Router();

routes.use("/auth", auth);
routes.use("/user", user);

export default routes;

Để truy cập vào login route, chúng ta sẽ gọi

http://localhost:3000/auth/login

Middleware

Bạn có thể thấy, routes gọi vài middlewares trước khi gọi vào controller. Một middleware chỉ là một function điều khiển request của bạn và gọi middleware tiếp theo. Cách tốt nhất để hiểu về middleware là tạo một middleware đầu tiên.

middlewares/checkJwt.ts

import { Request, Response, NextFunction } from "express";
import * as jwt from "jsonwebtoken";
import config from "../config/config";

export const checkJwt = (req: Request, res: Response, next: NextFunction) => {
  //Get the jwt token from the head
  const token = <string>req.headers["auth"];
  let jwtPayload;
  
  //Try to validate the token and get data
  try {
    jwtPayload = <any>jwt.verify(token, config.jwtSecret);
    res.locals.jwtPayload = jwtPayload;
  } catch (error) {
    //If token is not valid, respond with 401 (unauthorized)
    res.status(401).send();
    return;
  }

  //The token is valid for 1 hour
  //We want to send a new token on every request
  const { userId, username } = jwtPayload;
  const newToken = jwt.sign({ userId, username }, config.jwtSecret, {
    expiresIn: "1h"
  });
  res.setHeader("token", newToken);

  //Call the next middleware or controller
  next();
};

middlewares/checkRole.ts

import { Request, Response, NextFunction } from "express";
import { getRepository } from "typeorm";

import { User } from "../entity/User";

export const checkRole = (roles: Array<string>) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    //Get the user ID from previous midleware
    const id = res.locals.jwtPayload.userId;

    //Get user role from the database
    const userRepository = getRepository(User);
    let user: User;
    try {
      user = await userRepository.findOneOrFail(id);
    } catch (id) {
      res.status(401).send();
    }

    //Check if array of authorized roles includes the user's role
    if (roles.indexOf(user.role) > -1) next();
    else res.status(401).send();
  };
};

The config file

config/config.ts

export default {
  jwtSecret: "@QEGTUI"
};

The User entity

entity/User.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  Unique,
  CreateDateColumn,
  UpdateDateColumn
} from "typeorm";
import { Length, IsNotEmpty } from "class-validator";
import * as bcrypt from "bcryptjs";

@Entity()
@Unique(["username"])
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Length(4, 20)
  username: string;

  @Column()
  @Length(4, 100)
  password: string;

  @Column()
  @IsNotEmpty()
  role: string;

  @Column()
  @CreateDateColumn()
  createdAt: Date;

  @Column()
  @UpdateDateColumn()
  updatedAt: Date;

  hashPassword() {
    this.password = bcrypt.hashSync(this.password, 8);
  }

  checkIfUnencryptedPasswordIsValid(unencryptedPassword: string) {
    return bcrypt.compareSync(unencryptedPassword, this.password);
  }
}

The Controllers

controllers/AuthController.ts

import { Request, Response } from "express";
import * as jwt from "jsonwebtoken";
import { getRepository } from "typeorm";
import { validate } from "class-validator";

import { User } from "../entity/User";
import config from "../config/config";

class AuthController {
  static login = async (req: Request, res: Response) => {
    //Check if username and password are set
    let { username, password } = req.body;
    if (!(username && password)) {
      res.status(400).send();
    }

    //Get user from database
    const userRepository = getRepository(User);
    let user: User;
    try {
      user = await userRepository.findOneOrFail({ where: { username } });
    } catch (error) {
      res.status(401).send();
    }

    //Check if encrypted password match
    if (!user.checkIfUnencryptedPasswordIsValid(password)) {
      res.status(401).send();
      return;
    }

    //Sing JWT, valid for 1 hour
    const token = jwt.sign(
      { userId: user.id, username: user.username },
      config.jwtSecret,
      { expiresIn: "1h" }
    );

    //Send the jwt in the response
    res.send(token);
  };

  static changePassword = async (req: Request, res: Response) => {
    //Get ID from JWT
    const id = res.locals.jwtPayload.userId;

    //Get parameters from the body
    const { oldPassword, newPassword } = req.body;
    if (!(oldPassword && newPassword)) {
      res.status(400).send();
    }

    //Get user from the database
    const userRepository = getRepository(User);
    let user: User;
    try {
      user = await userRepository.findOneOrFail(id);
    } catch (id) {
      res.status(401).send();
    }

    //Check if old password matchs
    if (!user.checkIfUnencryptedPasswordIsValid(oldPassword)) {
      res.status(401).send();
      return;
    }

    //Validate de model (password lenght)
    user.password = newPassword;
    const errors = await validate(user);
    if (errors.length > 0) {
      res.status(400).send(errors);
      return;
    }
    //Hash the new password and save
    user.hashPassword();
    userRepository.save(user);

    res.status(204).send();
  };
}
export default AuthController;

controllers/UserController.ts

import { Request, Response } from "express";
import { getRepository } from "typeorm";
import { validate } from "class-validator";

import { User } from "../entity/User";

class UserController{

static listAll = async (req: Request, res: Response) => {
  //Get users from database
  const userRepository = getRepository(User);
  const users = await userRepository.find({
    select: ["id", "username", "role"] //We dont want to send the passwords on response
  });

  //Send the users object
  res.send(users);
};

static getOneById = async (req: Request, res: Response) => {
  //Get the ID from the url
  const id: number = req.params.id;

  //Get the user from database
  const userRepository = getRepository(User);
  try {
    const user = await userRepository.findOneOrFail(id, {
      select: ["id", "username", "role"] //We dont want to send the password on response
    });
  } catch (error) {
    res.status(404).send("User not found");
  }
};

static newUser = async (req: Request, res: Response) => {
  //Get parameters from the body
  let { username, password, role } = req.body;
  let user = new User();
  user.username = username;
  user.password = password;
  user.role = role;

  //Validade if the parameters are ok
  const errors = await validate(user);
  if (errors.length > 0) {
    res.status(400).send(errors);
    return;
  }

  //Hash the password, to securely store on DB
  user.hashPassword();

  //Try to save. If fails, the username is already in use
  const userRepository = getRepository(User);
  try {
    await userRepository.save(user);
  } catch (e) {
    res.status(409).send("username already in use");
    return;
  }

  //If all ok, send 201 response
  res.status(201).send("User created");
};

static editUser = async (req: Request, res: Response) => {
  //Get the ID from the url
  const id = req.params.id;

  //Get values from the body
  const { username, role } = req.body;

  //Try to find user on database
  const userRepository = getRepository(User);
  let user;
  try {
    user = await userRepository.findOneOrFail(id);
  } catch (error) {
    //If not found, send a 404 response
    res.status(404).send("User not found");
    return;
  }

  //Validate the new values on model
  user.username = username;
  user.role = role;
  const errors = await validate(user);
  if (errors.length > 0) {
    res.status(400).send(errors);
    return;
  }

  //Try to safe, if fails, that means username already in use
  try {
    await userRepository.save(user);
  } catch (e) {
    res.status(409).send("username already in use");
    return;
  }
  //After all send a 204 (no content, but accepted) response
  res.status(204).send();
};

static deleteUser = async (req: Request, res: Response) => {
  //Get the ID from the url
  const id = req.params.id;

  const userRepository = getRepository(User);
  let user: User;
  try {
    user = await userRepository.findOneOrFail(id);
  } catch (error) {
    res.status(404).send("User not found");
    return;
  }
  userRepository.delete(id);

  //After all send a 204 (no content, but accepted) response
  res.status(204).send();
};
};

export default UserController;

Luồng request của các file

Chúng ta đã viết nhiều mã và không thể theo dõi hết thứ tự các file được gọi. Do đó tôi đã tạo một biều đồ đơn giản minh họa các luồng của người dùng, yêu cầu kiểm tra role và sử dụng chức năng từ userController.

Development and Production Scripts

Node.js không thể chạy trực tiếp file .ts. Nên đó là lý do quan trọng phài biết các công cụ sau đây. "tsc" tạo thư mục /build chuyển đổi tất cả .ts sang .js "tsc-node" cho phép node chạy file .ts. Không khuyến khích sử dụng cho production "ts-node-dev" cũng tương tự cái trên, nhưng cho phép bạn chạy lại node server mỗi khi có file thay đổi.

 "scripts": {
   "tsc": "tsc",
   "start": "set debug=* && ts-node-dev --respawn --transpileOnly ./src/index.ts",
   "prod": "tsc && node ./build/app.js",
   "migration:run": "ts-node ./node_modules/typeorm/cli.js migration:run"
 }

tham khảo: https://medium.com/javascript-in-plain-english/creating-a-rest-api-with-jwt-authentication-and-role-based-authorization-using-typescript-fbfa3cab22a4


All Rights Reserved