Hôm nay, chúng ta sẽ học cách sử dụng TypeScript, Express.js và TypeORM để tạo một Rest API với xác thực và phân quyền cơ bản bằng JWT. TypeORM cho phép bạn chỉ cần viết một class với công cụ đồng bộ hóa, nó sẽ 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.
TypeORM tương thích với nhiều cơ sở dữ liệu khác nhau như MySQL, MariaDB, Postgres, SQLite, Microsoft SQL Server, Oracle, sql.js, và MongoDB. Bạn có thể dễ dàng chuyển đổi giữa các cơ sở dữ liệu mà không cần phải thay đổi mã nguồn. Trong project này, chúng ta sẽ sử dụng SQLite vì tính đơn giản và tiện dụng cho việc phát triển và thử nghiệm. Tuy nhiên, tôi khuyên bạn không nên sử dụng SQLite cho môi trường production. Vì không biết bạn sử dụng cơ sở dữ liệu nào, nên tôi tạo dự án này với SQLite để bạn có thể dễ dàng cài đặt thông qua npm mà không cần phải cài đặt database riêng.
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"
}