精华 【翻译】TypeScript Rest API with Express.js, JWT, Authorization Roles and TypeORM
发布于 3 个月前 作者 gocpplua 3599 次浏览 来自 分享

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

带有 Express.js、JWT、授权角色和 TypeORM 的 TypeScript Rest API

今天,我们将使用 TypeScript Express.js 和 TypeORM 创建具有 JWT 身份验证和基于角色的授权的企业级 Rest API。 目标是创建一个存储库,您可以将其用作现实生活项目的基础。

在我们开始之前,建议您熟悉以下主题。 你不需要成为专家,但如果你从未听说过其中一个,我选择了一个介绍性阅读:

什么是 Rest API 和基础 http 响应代码

什么是 JWT 以及为什么我们使用它来进行无状态身份验证

什么是 ORM(对象-关系-映射器)

为什么是 TypeORM?

TypeORM 允许您只编写一个 TypeScript 类,并且使用同步工具,它会自动为您的实体生成所有 SQL 结构。 使用 class-validator 包,我们可以使用相同的模型类进行验证。

它与 MySQL / MariaDB / Postgres / SQLite / Microsoft SQL Server / Oracle / sql.js / MongoDB 兼容。 您可以在这些数据库之间切换,而无需重写代码。

我们将使用 SQLite 开始这个项目。 我不建议保留它用于生产。 但是,因为我不知道您将使用什么 DB,它允许我们制作一个通用项目,您只需“npm install”即可运行该项目,而无需设置数据库服务器。

开始吧

TypeORM 有一个 CLI 工具,允许我们生成一个已经在 TypeScript 中的基本应用程序。 要使用这个工具,我们首先需要安装 typeORM 作为全局依赖:

npm install -g typeorm

现在我们可以设置我们的应用程序:

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

它将使用 TypeORM 和 body-parser 在 TypeScript 中创建一个示例 express 应用程序。 让我们安装这些依赖项:

npm install

现在,我们将安装一些额外的依赖项:

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

之后,我们将拥有以下依赖项:

  • helmet:通过设置各种 HTTP 标头帮助我们保护我们的应用程序
  • cors:启用跨域请求
  • body-parser:将客户端的请求从 json 解析为 javascript 对象
  • jsonwebtoken:将为我们处理 jwt 操作
  • bcryptjs: 帮助我们散列用户密码
  • typeorm:我们将用于操作数据库的 ORM
  • reflect-metadata:允许一些与 TypeORM 一起使用的注释功能
  • class-validator:一个非常适合 TypeORM 的验证包
  • sqlite3:我们将使用 sqlite 作为开发数据库
  • ts-node-dev:当我们更改任何文件时自动重新启动服务器

安装类型检查依赖项

由于我们正在使用 TypeScript,因此为我们的依赖项安装 @types 是个好主意。

npm install -s [@types](/user/types)/bcryptjs [@types](/user/types)/body-parser [@types](/user/types)/cors [@types](/user/types)/helmet [@types](/user/types)/jsonwebtoken

之后,即使使用 JavaScript 包,您也可以使用自动完成和类型检查。

源文件夹

TypeORM CLI 创建了一个包含所有typescript文件的 src 文件夹。 现在我们将修改这些文件以创建我们的 API。 image

Index

CLI 已经创建了一个 index.ts 文件作为应用程序的入口点。 让我们重写以更好地满足我们的目的。

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));

The routes

CLI 还创建了一个 routes.ts 文件。 在大型项目中,将所有路由放在同一个文件中可能不是一个好主意。 我们将创建一个路由/文件夹,其中包含一个 routes/index.ts 聚合来自其他文件的路由。

  • 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;

例如,要访问登录路由,您将调用:

http://localhost:3000/auth/login

Middleware

如您所见,路由在调用控制器之前调用了一些中间件。 中间件实际上只是一个操作您的请求并调用下一个中间件的函数。 最好的理解方法是创建您的第一个中间件。

  • middlewares/checkJwt.ts 这个中间件将在每条需要登录用户的路由上调用。 它将检查我们在请求标头上是否有有效的 JWT。 如果令牌有效,它将调用由控制器处理的下一个函数。 否则,它将发送带有 401(未授权)状态代码的响应。
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
  • 即使用户有效登录,他也可能尝试访问他可能没有角色授权访问的路由。 该中间件将检查登录用户是否真的具有访问此路由所需的角色。 如果没有,请回复 401(未授权)状态代码。 请注意,我们将角色设置为字符串数组。 这是因为您将来可能需要多个角色来访问同一路由。
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

要生成和验证 jwt 令牌,我们需要一个密钥。 我们将把它存储在一个配置文件中。 您可以将 jwtSecret 更改为您想要的任何字符串。

  • config/config.ts
export default {
  jwtSecret: "@QEGTUI"
};

The User entity

CLI 已经创建了一个“entity/User.ts”文件。 但是我们想要更改字段,添加验证并创建散列密码的方法。 所以我们需要重写这个类。

  • 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

CLI 还创建了一个名为 controller 的文件夹。 您可以删除它,并创建另一个命名控制器(复数)。 然后我们将创建身份验证和用户控制器。

  • 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;

通过文件的请求流

我们写了很多代码,忘记调用每个文件的顺序是可以的。 出于这个原因,我创建了一个简单的图表,它举例说明了需要检查角色并使用 userController 函数的用户请求的流程。 image

开发和生产脚本

image Node.js 本身无法运行 .ts 文件。 因此,了解以下工具很重要。

  • “tsc” — 创建一个 /build 文件夹并将所有 .ts 转换为 .js 文件。.
  • “ts-node” — 允许节点运行 .ts 项目。 不推荐用于生产用途.
  • “ts-node-dev” — 与上面相同,但允许您每次更改文件时重新启动节点服务器.

为了更好地设置开发和生产环境,我们将修改 package.json 的脚本会话。

 "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"
 }

最后,我们添加名为 smigration:run 的最后一行。 一些 Windows 用户在尝试从 npm 运行 TypeORM 迁移时遇到错误。 直接从节点模块文件夹运行它可以解决问题。

第一个用户呢?

如您所见,即使要创建新用户,我们也需要已经拥有 ADMIN。 第一个用户将由迁移过程创建。 迁移对于维护生产数据库也非常重要。 如果您打算在生产中使用 TypeORM,我真的建议您阅读迁移文档:migrations.

现在,让我们创建我们的第一个迁移:

typeorm migration:create -n CreateAdminUser

然后,我们将修改生成的文件:

import { MigrationInterface, QueryRunner, getRepository } from "typeorm";
import { User } from "../entity/User";

export class CreateAdminUser1547919837483 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<any> {
    let user = new User();
    user.username = "admin";
    user.password = "admin";
    user.hashPassword();
    user.role = "ADMIN";
    const userRepository = getRepository(User);
    await userRepository.save(user);
  }

  public async down(queryRunner: QueryRunner): Promise<any> {}
}

现在我们启动服务器,所以同步工具可以生成我们的数据库表。

npm start

现在我们可以运行迁移,插入第一个管理员用户。

npm run migration:run

最后,您的服务器已准备就绪。 只需获取 Postman 或任何其他工具,然后提出一些请求即可。

最终的存储库可以在 GitHub 上找到:jwt-express-typeorm

8 回复

@i5ting 感谢设置为精华

typeorm 好东西啊,,就是捐献较少,,作者不勤快。。。。。

@ganshiqingyuan 对的啊,不过现在应该好些了

回到顶部