原文: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。
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 函数的用户请求的流程。
开发和生产脚本
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
棒棒哒
@i5ting 感谢设置为精华
@gocpplua 你好
6666
typeorm 好东西啊,,就是捐献较少,,作者不勤快。。。。。
@luo1234560 你好啊
@ganshiqingyuan 对的啊,不过现在应该好些了
谢谢分享