Hết Part 1 hệ thống trừ tiền đúng kể cả khi hai request chạy đồng thời. Nhưng nó vẫn có một lỗ hổng lớn
đến mức buồn cười: userId là tham số do client tự gửi. Ai cũng có thể tiêu credit của người
khác bằng cách đổi một con số trong body.
Part này bịt lỗ đó, rồi đi tiếp ra lớp ngoài cùng của hệ thống — chỗ request chạm vào trước khi tới NestJS. Xác thực, nginx, giới hạn tần suất, và luồng dữ liệu lớn: upload video 2 GB và phát lại có tua được, cả hai đều không được phép nạp cả file vào RAM.
Range mà Node gần như không tốn CPU nào. Kèm hai mốc giao diện React để nhìn thấy thứ mình
vừa dựng.
1. Mật khẩu lưu thế nào cho đúng
Bảng users ở Part 1 có cột password_hash. Chữ hash ở đây không phải
loại hash bạn dùng để kiểm tra tính toàn vẹn file. SHA-256 được thiết kế để chạy
nhanh — đó chính xác là điều bạn không muốn: card đồ hoạ phổ thông thử được hàng tỉ
SHA-256 mỗi giây, nên một bảng mật khẩu bị lộ sẽ bị bẻ gần hết trong vài giờ.
Hàm băm mật khẩu là loại được cố ý làm chậm và cố ý tốn bộ nhớ. Bản hiện đại nên dùng là argon2id: nó tốn RAM theo tham số bạn đặt, mà RAM là thứ GPU không nhân bản rẻ được như nhân tính toán.
npm i argon2 @nestjs/jwt
npm i -D @types/express
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
@Injectable()
export class PasswordService {
private readonly options = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB — moi lan bam ton chung nay RAM
timeCost: 2,
parallelism: 1,
} as const;
hash(plain: string): Promise<string> {
return argon2.hash(plain, this.options);
}
verify(hash: string, plain: string): Promise<boolean> {
return argon2.verify(hash, plain);
}
}
Không cần tự sinh salt: argon2 tự sinh và nhét thẳng vào chuỗi kết quả cùng với tham số. Một
password_hash trông như thế này, và đọc được từ trái sang: thuật toán, phiên bản, tham số,
salt, rồi hash.
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
memoryCost lên gấp đôi vì máy chủ mạnh hơn. Những mật khẩu đã băm bằng
tham số cũ vẫn phải đăng nhập được — và chúng vẫn kiểm tra được, vì mỗi dòng tự mang theo tham số của
chính nó. argon2.needsRehash(hash, options) cho biết dòng nào đang dùng tham số cũ, để bạn
băm lại bằng mật khẩu vừa nhập đúng ngay sau lần đăng nhập kế tiếp. Nâng cấp dần, không cần bắt cả triệu
người đổi mật khẩu.
Dữ liệu vào từ request phải qua DTO trước khi chạm tới AuthService — đúng quy ước đã đặt từ
Part 1:
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
Các decorator trên LoginDto không tự chạy — chúng chỉ là metadata cho tới khi có một
ValidationPipe toàn cục đọc metadata đó trước khi request chạm tới controller. Đăng ký ngay
trong src/main.ts, trước dòng đầu tiên gọi app.listen(...):
import { ValidationPipe } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // xoa nhung field khong khai bao trong DTO thay vi chuyen tiep nguyen si
forbidNonWhitelisted: true, // co field la khong khai bao -> 400, khong am tham xoa
transform: true, // "42" tu query string thanh so 42 truoc khi vao ham
}),
);
ValidationPipe toàn cục, NestJS không bao giờ đọc metadata mà
@IsEmail(), @IsInt(), @IsIn(...) gắn lên DTO — dữ liệu vào thẳng
controller y nguyên, đúng hay sai đều như nhau. Đo thật bằng cách bỏ dòng này rồi gọi
POST /billing/charge thiếu hẳn trường amount: không có 400 nào
cả, request đi thẳng xuống Postgres với giá trị undefined, và driver trả về nguyên văn
error: invalid input syntax for integer: "NaN" kèm mã lỗi 22P02 — một lỗi
500 lộ chi tiết nội bộ, đúng thứ ValidationPipe
được sinh ra để chặn trước khi nó xảy ra.
1.1. Đăng nhập sai thì trả lỗi gì
Chỗ này dễ viết sai theo hướng "tử tế với người dùng": email không tồn tại thì báo email không tồn tại, mật khẩu sai thì báo mật khẩu sai. Làm vậy là biến form đăng nhập thành công cụ dò danh sách khách hàng — kẻ tấn công thử một triệu email và biết chính xác email nào có tài khoản ở đây. Với dịch vụ có tính nhạy cảm thì bản thân việc "người này có tài khoản" đã là dữ liệu.
Cả hai trường hợp trả cùng một thông báo. Và còn một rò rỉ tinh vi hơn: nếu email không tồn tại thì hàm trả về ngay, còn email tồn tại thì phải chờ argon2 chạy xong mới trả về. Chênh lệch vài chục mili giây đó đo được từ xa. Cách vá là luôn chạy một lần verify, kể cả khi không có người dùng nào.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { PasswordService } from './password.service';
// Hash cua mot mat khau khong ai biet. Dung lam moi nhu khi email khong ton tai,
// de thoi gian phan hoi hai truong hop bang nhau.
const DUMMY_HASH =
'$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
private readonly passwords: PasswordService,
) {}
private async validateCredentials(email: string, plain: string): Promise<User> {
const user = await this.users.findOne({ where: { email: email.toLowerCase() } });
const ok = await this.passwords.verify(user?.passwordHash ?? DUMMY_HASH, plain);
// Mot thong bao duy nhat cho ca hai truong hop.
if (!ok || !user) {
throw new UnauthorizedException('Email hoac mat khau khong dung');
}
return user;
}
}
Thứ tự !ok || !user chứ không phải ngược lại là cố ý: nếu kiểm tra !user trước
thì TypeScript vẫn hài lòng nhưng ta lại thoát sớm đúng cái nhánh vừa mất công vá. Còn
user?.passwordHash ?? DUMMY_HASH đọc được ngay nhờ cờ
noUncheckedIndexedAccess và kiểu trả về User | null của findOne —
trình biên dịch không cho bạn quên rằng nó có thể rỗng.
npm run start:dev chạy nền, ngay khi lưu file này nó sẽ đỏ: src/auth/auth.service.ts:19:17 - error TS6133: 'validateCredentials' is declared but its value is
never read.
Không phải bạn gõ sai.
validateCredentials là private và chưa có ai gọi nó —
người gọi là login(), viết ở mục 3.2. Đây chính là cờ noUnusedLocals mà Part 1
mục 5 bảo bạn bật, đang làm đúng việc của nó: một phương thức private không ai gọi thường là code chết,
nên nó bắt bạn để ý. Lỗi này tự hết khi bạn viết
login() ở mục 3.2. Từ giờ tới đó cứ tạo file
và đọc tiếp; nếu bạn muốn màn hình sạch trong lúc đọc thì dừng watcher lại, đừng sửa cờ trong
tsconfig.json — nới cờ để tắt một cảnh báo đúng là cách nhanh nhất đánh mất giá trị của nó.
2. Access token: JWT và vì sao nó ngắn hạn
Sau khi xác thực xong, server phải phát cho client một thứ để đính vào các request sau. Có hai trường phái. Session trong database: phát một chuỗi ngẫu nhiên, mỗi request tra bảng để biết nó thuộc về ai. Thu hồi tức thì — xoá dòng là xong — nhưng mỗi request tốn một lượt đọc. JWT: nhét thông tin vào chính token và ký nó; server chỉ cần kiểm chữ ký, không tra gì cả. Nhanh, không trạng thái, chạy được ở nhiều instance mà không cần chia sẻ gì.
Cái giá của JWT là không thu hồi được. Đã ký ra rồi thì nó hợp lệ tới lúc hết hạn, kể cả khi bạn vừa khoá tài khoản đó. Đây không phải khiếm khuyết cần vá, mà là bản chất — và cách sống chung với nó là làm cho khoảng thời gian ấy đủ ngắn để thiệt hại chấp nhận được.
Refresh token — chuỗi ngẫu nhiên, sống 30 ngày, chỉ gửi tới một endpoint duy nhất, có lưu ở database nên thu hồi được ngay.
Nói cách khác: thứ đi khắp nơi thì không thu hồi được nhưng chết nhanh; thứ sống lâu thì đi rất ít chỗ và thu hồi được. Toàn bộ thiết kế xác thực dưới đây chỉ là hệ quả của hai câu này.
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url().startsWith('postgres://'),
// Doi 32 ky tu tro len. Khoa ngan thi chu ky do duoc bang vet can.
JWT_SECRET: z.string().min(32),
ACCESS_TOKEN_TTL: z.string().default('15m'),
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
// Dung cho RedisService (gioi han tan suat o muc 5, hang doi o Part 3).
REDIS_URL: z.string().url().startsWith('redis://').default('redis://localhost:6379'),
});
Ba trong bốn biến này có .default(...) nên thiếu cũng chạy được. JWT_SECRET thì
không — nó bắt buộc, và bỏ qua bước dưới đây thì npm run start:dev dừng ngay với
JWT_SECRET: Invalid input: expected string, received undefined. Khoá phải dài ít nhất 32 ký
tự, nên đừng gõ tay một chuỗi ngắn:
# Sinh khoa ngau nhien 64 ky tu hex va noi thang vao .env
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
# Them not vao .env.example de nguoi khac biet la can bien nay
# (KHONG ghi gia tri that vao file duoc commit)
cat >> .env.example <<'EOF'
JWT_SECRET=thay-bang-chuoi-ngau-nhien-tu-openssl-rand-hex-32
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=30
REDIS_URL=redis://localhost:6379
EOF
Thêm bốn dòng vào schema là kiểu AppConfig tự có thêm bốn trường — đúng như mục 4 của Part 1
đã nói. Giờ dựng module JWT, đọc khoá qua ConfigService chứ không đụng
process.env:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import type { AppConfig } from '../config/configuration';
import { User } from './user.entity';
import { RefreshToken } from './refresh-token.entity';
import { AuthService } from './auth.service';
import { PasswordService } from './password.service';
@Module({
imports: [
TypeOrmModule.forFeature([User, RefreshToken]),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig, true>) => ({
secret: config.get('JWT_SECRET', { infer: true }),
signOptions: {
expiresIn: config.get('ACCESS_TOKEN_TTL', { infer: true }),
issuer: 'media-forge',
},
}),
}),
],
// Chua co controller nao: file auth.controller.ts duoc viet o muc 3.5,
// vi chinh no la cho quyet dinh tra refresh token qua cookie hay qua body.
providers: [AuthService, PasswordService],
// JwtModule duoc export lai — ProgressGateway o Part 3 tiem JwtService
// truc tiep de xac thuc bat tay WebSocket, va no nam o mot module khac
// import AuthModule chu khong phai chinh AuthModule.
exports: [AuthService, JwtModule],
})
export class AuthModule {}
Module này chưa biên dịch được ngay, và điều đó là bình thường: nó khai
AuthService ở providers mà file đó tới mục 3.2 mới viết, còn controller thì tới
mục 3.5. Cứ tạo file, đừng chạy npm run start:dev vội — chúng ta sẽ cắm nó vào
AppModule và chạy thử ở cuối mục 3.5, khi mọi mảnh đã đủ.
2.1. Nội dung token và guard đọc nó
Payload của JWT không được mã hoá, chỉ được ký. Bất kỳ ai cầm token đều đọc được nội dung bằng cách giải base64 — chữ ký chỉ đảm bảo nội dung không bị sửa. Nên trong đó chỉ đặt thứ không ngại lộ: id, vai trò. Không đặt email, không đặt số dư, không đặt gì có thể cũ đi.
import type { Request } from 'express';
export const USER_ROLES = ['user', 'admin'] as const;
export type UserRole = (typeof USER_ROLES)[number];
export const USER_PLANS = ['free', 'pro'] as const;
export type UserPlan = (typeof USER_PLANS)[number];
export interface JwtPayload {
readonly sub: string; // user id — ten chuan trong RFC 7519
readonly role: UserRole;
readonly plan: UserPlan; // dung cho gioi han tan suat o muc 5
readonly iat: number;
readonly exp: number;
}
// Kieu request sau khi guard da gan nguoi dung vao.
export interface AuthedRequest extends Request {
readonly user: JwtPayload;
}
AuthedRequest âm thầm sai kiểuimport type { Request } from 'express', TypeScript không hiểu
Request là kiểu nào và lặng lẽ suy ra Request toàn cục của Fetch API (có sẵn
từ @types/node) thay vì kiểu Request của Express. Hai kiểu trùng tên nhưng
khác hẳn nhau: bản Fetch không có cookies, không index được vào headers bằng
dấu ngoặc vuông, và không phải một Node.js Readable stream. Hậu quả chỉ lộ ra ở nơi dùng
AuthedRequest — ví dụ UploadController ở mục 6 — dưới dạng những lỗi kiểu khó
hiểu, không phải ngay tại đây.
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import type { JwtPayload } from './jwt-payload';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers.authorization;
if (header === undefined || !header.startsWith('Bearer ')) {
throw new UnauthorizedException('Thieu access token');
}
try {
const payload = await this.jwt.verifyAsync<JwtPayload>(header.slice(7));
// Gan vao request de controller doc duoc.
Object.defineProperty(request, 'user', { value: payload, enumerable: true });
return true;
} catch {
// Het han, chu ky sai, token bi cat — deu ve mot cho.
throw new UnauthorizedException('Access token khong hop le');
}
}
}
Controller không nên moi request.user bằng tay ở từng nơi. Một decorator nhỏ vừa gọn hơn vừa
giữ được kiểu:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthedRequest, JwtPayload } from './jwt-payload';
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): JwtPayload =>
context.switchToHttp().getRequest<AuthedRequest>().user,
);
Giờ quay lại chỗ hở ở đầu bài. Endpoint tiêu credit của Part 1 trước và sau:
// TRUOC — userId do client gui. Ai cung tieu tien cua ai cung duoc.
@Post('charge')
charge(@Body() dto: ChargeDto) {
return this.billing.charge(dto.userId, dto.amount, dto.reason);
}
// SAU — userId lay tu chu ky, client khong noi vao duoc.
@Post('charge')
@UseGuards(JwtAuthGuard)
async charge(@CurrentUser() user: JwtPayload, @Body() dto: ChargeDto) {
await this.billing.charge(user.sub, dto.amount, dto.reason);
return { balance: await this.billing.getBalance(user.sub) };
}
BillingService thậtBillingService ở Part 1 chỉ có charge() và getBalance() — không
có spend(). Route và tên method ở đây đổi thành charge để khớp đúng những gì
Part 1 đã viết; ChargeDto khai báo amount và reason (kiểu
CreditReason từ credit-entry.entity.ts), qua class-validator như
quy ước ở mục 4 của Part 1.
DTO đi kèm giờ trông thế này — đã bỏ trường userId của bản Part 1, vì giờ nó đến từ JWT chứ
không còn tin theo client:
import { IsIn, IsInt, IsPositive } from 'class-validator';
import type { CreditReason } from './credit-entry.entity';
const CREDIT_REASONS: readonly CreditReason[] = ['topup', 'transcode', 'refund'];
export class ChargeDto {
@IsInt()
@IsPositive()
amount!: number;
@IsIn(CREDIT_REASONS)
reason!: CreditReason;
}
Bọc lại thành một controller hoàn chỉnh — bản "sau" ở trên là method thật, phần còn lại chỉ là khung quen thuộc:
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { BillingService } from './billing.service';
import { ChargeDto } from './charge.dto';
@Controller('billing')
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Post('charge')
@UseGuards(JwtAuthGuard)
async charge(@CurrentUser() user: JwtPayload, @Body() dto: ChargeDto): Promise<{ balance: number }> {
await this.billing.charge(user.sub, dto.amount, dto.reason);
return { balance: await this.billing.getBalance(user.sub) };
}
}
Controller xong nhưng chưa chạy được. Thêm @UseGuards(JwtAuthGuard) vào đây
là bạn vừa tạo ra một phụ thuộc mới cho BillingModule, và NestJS sẽ từ chối khởi động:
UnknownDependenciesException [Error]: Nest can't resolve dependencies of the JwtAuthGuard (?). Please
make sure that the argument JwtService at index [0] is available in the BillingModule module.
Thông báo này rất dễ gây hiểu lầm: nó chỉ vào
BillingModule trong khi
JwtAuthGuard nằm ở thư mục auth/ và bạn vừa sửa file
billing.controller.ts. Lý do là decorator @UseGuards nằm trên
controller của BillingModule, nên NestJS đi tìm JwtService trong ngữ cảnh
của BillingModule — chứ không phải nơi guard được định nghĩa. Chỗ dễ nhầm:
JwtAuthGuard không hề được export ở đâu cả — nó cũng không
nằm trong providers của module nào. Vậy import AuthModule thì giải quyết được
gì? Tách ra ba bước sẽ rõ: Một.
@UseGuards(JwtAuthGuard) nhận chính cái class, lấy về bằng
một import TypeScript thường. Không có token DI nào ở bước này, nên guard không cần ai
export. Hai. Nhưng Nest phải tự khởi tạo class đó, và nó khởi tạo trong injector của module khai controller — ở đây là
BillingModule. Ba. Muốn khởi tạo thì phải giải được tham số constructor
constructor(private readonly jwt: JwtService). JwtService mới là token DI cần
tìm, và nó phải giải được trong ngữ cảnh BillingModule. Mắt xích nằm ở dòng cuối của
auth.module.ts:
exports: [AuthService, JwtModule] — AuthModule export lại nguyên cả
JwtModule, nên ai import AuthModule cũng nhận được mọi thứ
JwtModule export, trong đó có JwtService. Nói gọn: import
AuthModule không phải để lấy guard — mà để lấy nhiên liệu cho guard. Bạn cũng có thể cho
BillingModule import thẳng JwtModule, nhưng phải
registerAsync lại với cùng JWT_SECRET — tức nhân đôi cấu hình khoá ở hai chỗ,
và đến ngày đổi khoá thì quên một chỗ là hỏng âm thầm. Đây là quy tắc chung, không phải chuyện riêng của billing: module nào gắn guard thì module đó phải import module cung cấp phụ thuộc của guard. Bạn sẽ gặp lại y hệt ở mục 5 với
MediaModule.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { CreditEntry } from './credit-entry.entity';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
// AuthModule: JwtAuthGuard tiem JwtService — guard duoc phan giai trong
// module DUNG no, khong phai module khai no.
imports: [TypeOrmModule.forFeature([CreditEntry]), AuthModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}
sub chứ không phải userIdsub (subject) là tên trường chuẩn trong RFC 7519, cùng họ với iat,
exp, iss. Dùng tên chuẩn thì mọi thư viện JWT, mọi công cụ gỡ lỗi, mọi API
gateway đều hiểu token của bạn mà không cần cấu hình riêng. Đặt tên tự chế thì lúc gắn thêm một thành
phần bên thứ ba, bạn sẽ phải đi ánh xạ tay.
2.2. Phân quyền theo vai trò
Xác thực trả lời "anh là ai", phân quyền trả lời "anh được làm gì". Hai câu hỏi khác nhau nên là hai guard
khác nhau — trộn vào một là con đường dẫn tới một hàm 200 dòng đầy if.
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthedRequest, UserRole } from './jwt-payload';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: readonly UserRole[]) => SetMetadata(ROLES_KEY, roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<readonly UserRole[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// Khong khai bao @Roles() thi khong rang buoc vai tro.
if (required === undefined || required.length === 0) return true;
const { user } = context.switchToHttp().getRequest<AuthedRequest>();
if (!required.includes(user.role)) {
throw new ForbiddenException('Khong du quyen');
}
return true;
}
}
Thứ tự khai báo có ý nghĩa: @UseGuards(JwtAuthGuard, RolesGuard) chạy từ trái sang, nên
RolesGuard chắc chắn đã có request.user để đọc. Đảo lại thì nó đọc phải
undefined và ném ra một lỗi khó hiểu — trình biên dịch không cứu được bạn ở đây vì thứ tự chỉ
tồn tại lúc chạy.
Một chỗ dùng thật: đội hỗ trợ cần xem số dư của một user bất kỳ khi xử lý khiếu nại, nhưng chỉ admin được
làm việc đó. Quay lại BillingController ở mục 2.1 và thêm một endpoint:
@Get(':userId/balance')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
async getBalanceForUser(@Param('userId') userId: string): Promise<{ balance: number }> {
return { balance: await this.billing.getBalance(userId) };
}
UserRole ở mục 2.1 đã có sẵn 'admin' trong USER_ROLES, nên
@Roles('admin') ở đây không cần khai báo gì thêm.
3. Refresh token: xoay vòng và phát hiện bị đánh cắp
Access token chết sau 15 phút. Nếu không có gì khác thì người dùng phải đăng nhập lại mỗi 15 phút — không ai chịu nổi. Refresh token là thứ đổi lấy access token mới mà không cần nhập lại mật khẩu.
Nhưng nó sống 30 ngày. Một chuỗi sống 30 ngày mà bị lộ thì kẻ trộm dùng thoải mái suốt 30 ngày, và bạn không hề biết. Trừ khi ta thêm một quy tắc: mỗi refresh token chỉ dùng được đúng một lần. Dùng xong, nó chết và server phát ra một cái mới.
Quy tắc đó tạo ra một hệ quả rất đẹp. Nếu một token đã dùng rồi lại được đem dùng lần nữa, chỉ có thể là một trong hai chuyện: hoặc kẻ trộm dùng bản sao cũ, hoặc nạn nhân dùng bản gốc sau khi kẻ trộm đã xoay nó. Trong cả hai trường hợp, kết luận giống nhau — chuỗi này đã bị nhân bản. Và server phản ứng bằng cách thu hồi toàn bộ chuỗi.
3.1. Entity và vì sao lưu hash
Bảng refresh_tokens đã có trong migration từ Part 1. Giờ là lúc nhìn kỹ nó, vì mỗi cột đều có
lý do:
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@Column({ name: 'user_id' })
userId!: string;
// SHA-256 cua token. Khong bao gio luu ban goc.
@Index({ unique: true })
@Column({ name: 'token_hash', length: 64 })
tokenHash!: string;
// Ca mot chuoi xoay vong dung chung mot family_id.
@Index()
@Column({ name: 'family_id' })
familyId!: string;
// null = chua dung. Co gia tri = da dung, dung lai la bao dong.
@Column({ name: 'used_at', type: 'timestamptz', nullable: true })
usedAt!: Date | null;
// Token nao da thay the token nay. Dung cho khoang an han o muc 3.3.
@Column({ name: 'replaced_by_hash', type: 'varchar', length: 64, nullable: true })
replacedByHash!: string | null;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
Cột replaced_by_hash là cột duy nhất chưa có trong migration của Part 1 — nó phục vụ khoảng
ân hạn ở mục 3.3. Thêm nó vào entity xong phải sinh và chạy migration, đúng hai bước như
Part 1 §7.4; TypeORM tự so sánh entity với schema hiện tại và sinh ra đúng một lệnh
ALTER TABLE:
npm run migration:generate -- src/database/migrations/AddReplacedByHash
npm run migration:run
issuePair() ghi
replacedByHash: null xuống một cột không tồn tại và Postgres từ chối:
column "replaced_by_hash" of relation "refresh_tokens" does not exist. Lỗi hiện ra ở
/auth/login chứ không phải ở chỗ vừa sửa, nên rất dễ đi tìm nhầm chỗ.
type: 'varchar' ở đây không phải thừatype đi và để TypeORM tự suy từ kiểu TypeScript sẽ làm
migration:generate chết ngay với
DataTypeNotSupportedError: Data type "Object" ... is not supported. Nguyên nhân nằm ở
reflect-metadata: TypeScript phát ra kiểu thiết kế (design:type) cho một union
như string | null thành Object chứ không phải String — phần
| null làm mất thông tin. usedAt: Date | null ở trên không gặp lỗi này vì nó
đã khai báo type: 'timestamptz' tường minh; quy tắc chung là
mọi cột nullable ngoài kiểu nguyên thuỷ không union đều cần khai type tay,
không dựa vào suy luận.
Refresh token cũng là mật khẩu — nó đổi được lấy quyền truy cập. Nhưng ở đây dùng SHA-256 chứ không phải
argon2, và đó không phải mâu thuẫn với mục 1. Mật khẩu người dùng thì ngắn, dễ đoán, nên cần hàm chậm để
chống dò. Refresh token là 32 byte ngẫu nhiên từ crypto.randomBytes — không gian tìm kiếm lớn
đến mức không ai dò nổi, nên vấn đề duy nhất là "database bị đọc trộm thì có dùng lại được không", và
SHA-256 giải quyết trọn vẹn điều đó với chi phí gần bằng không cho mỗi request.
@Index({ unique: true }) trên token_hashunique là thứ khiến bước xoay vòng ở mục 3.3 an toàn
trước cả tình huống đua: nếu hai request cùng lúc thử tạo cùng một dòng, database từ chối một cái. Chỉ
mục ở đây vừa là tối ưu vừa là ràng buộc đúng đắn — mà database thì luôn giữ ràng buộc tốt hơn code ứng
dụng.
3.2. Phát cặp token
import { randomBytes, createHash, randomUUID } from 'node:crypto';
import type { EntityManager } from 'typeorm';
export interface TokenPair {
readonly accessToken: string;
readonly refreshToken: string;
}
private sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
// manager tuy chon: khi goi tu trong mot transaction dang mo (xoay vong o
// muc 3.3), insert token moi phai dung CHUNG transaction do voi thao tac
// danh dau token cu — dung repo tiem (khong truyen manager) o day se tach
// hai thao tac ra hai ket noi khac nhau, mat tinh nguyen tu cua ca chuoi.
private async issuePair(user: User, familyId: string, manager?: EntityManager): Promise<TokenPair> {
const accessToken = await this.jwt.signAsync({ sub: user.id, role: user.role, plan: user.plan });
// 32 byte ngau nhien tu nguon cua he dieu hanh. Khong dung Math.random().
const refreshToken = randomBytes(32).toString('base64url');
const days = this.config.get('REFRESH_TOKEN_TTL_DAYS', { infer: true });
const repo = manager ? manager.getRepository(RefreshToken) : this.refreshTokens;
await repo.insert({
userId: user.id,
tokenHash: this.sha256(refreshToken),
familyId,
expiresAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
usedAt: null,
replacedByHash: null,
});
return { accessToken, refreshToken };
}
async login(email: string, plain: string): Promise<TokenPair> {
const user = await this.validateCredentials(email, plain);
// Moi lan dang nhap mo mot family moi — tuc la mot thiet bi.
return this.issuePair(user, randomUUID());
}
Math.random() không dùng được ở đâyMath.random() là bộ sinh giả ngẫu nhiên được tối ưu cho tốc độ, không phải cho việc đoán
trước. Biết vài giá trị đầu ra là suy ngược được trạng thái nội bộ và dự đoán các giá trị sau — nghĩa là
đoán được refresh token của người khác. randomBytes lấy từ nguồn entropy của hệ điều hành
và được thiết kế đúng cho mục đích này. Quy tắc dễ nhớ: bất cứ giá trị ngẫu nhiên nào mà việc đoán được
nó gây hại, phải đến từ node:crypto.
Trước khi sang mục sau, để ý dòng cuối cùng của khối trên — nó nhỏ nhưng là chỗ
duy nhất trong cả dự án sinh ra một familyId mới:
this.issuePair(user, randomUUID()). Từ đó trở đi giá trị này chỉ được chuyền tiếp,
không bao giờ tạo mới nữa, và ba quy tắc dưới đây là toàn bộ vòng đời của nó:
-
Đăng nhập mở một family mới. Mỗi lần
login()chạy là mộtrandomUUID()mới — nên mỗi thiết bị, mỗi trình duyệt bạn đăng nhập là một chuỗi riêng. -
Xoay vòng giữ nguyên family. Ở mục 3.3, token mới nhận
row.familyIdcủa token vừa dùng, chứ không sinh cái mới — nhờ vậy cả chuỗi xoay vòng của một thiết bị vẫn nhận ra nhau. -
Phát hiện dùng lại thì xoá cả family. Đó là lý do
delete({ familyId })quét sạch một lượt: kẻ trộm dùng lại một token cũ thì toàn bộ chuỗi của thiết bị đó bị thu hồi, không cần biết nó đang ở token thứ mấy.
3.3. Xoay vòng, và cái bẫy khi hai tab cùng refresh
Đây là hàm quan trọng nhất của mục này. Nó phải làm bốn việc trong cùng một transaction: tìm token, phát hiện tái sử dụng, đánh dấu đã dùng, phát cặp mới. Viết thành hai hàm — một hàm công khai mở transaction, một hàm riêng làm việc thật — vì mục 3.3 bên dưới cần gọi đệ quy vào đúng phần việc thật đó khi gặp khoảng ân hạn.
async refresh(presented: string): Promise<TokenPair> {
return this.dataSource.transaction((manager) => this.reissueFrom(manager, this.sha256(presented)));
}
private async reissueFrom(manager: EntityManager, hash: string): Promise<TokenPair> {
// Khoa dong nay lai. Hai tab cung refresh thi mot cai phai xep hang.
const row = await manager
.createQueryBuilder(RefreshToken, 'rt')
.setLock('pessimistic_write')
.where('rt.token_hash = :hash', { hash })
.getOne();
if (row === null) {
throw new UnauthorizedException('Refresh token khong hop le');
}
// TAI SU DUNG. Ban sao dang ton tai o dau do — dot ca family (xem tiep muc 3.3).
if (row.usedAt !== null) {
await manager.delete(RefreshToken, { familyId: row.familyId });
throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
}
if (row.expiresAt.getTime() < Date.now()) {
throw new UnauthorizedException('Refresh token da het han');
}
const user = await manager.findOneOrFail(User, { where: { id: row.userId } });
const pair = await this.issuePair(user, row.familyId, manager);
row.usedAt = new Date();
row.replacedByHash = this.sha256(pair.refreshToken);
await manager.save(row);
return pair;
}
Dòng setLock('pessimistic_write') chính là SELECT ... FOR UPDATE của Part 1, và
nó ở đây vì một lý do rất đời thường: người dùng mở hai tab.
Access token hết hạn cùng lúc ở cả hai tab, cả hai cùng gửi refresh với cùng một token. Không có
khoá, cả hai cùng đọc thấy used_at = null, cả hai cùng cho là hợp lệ, cả hai cùng phát token
mới — và bạn có hai nhánh xoay vòng song song từ một gốc, tức là chính cái tình trạng "token bị nhân bản"
mà cơ chế này sinh ra để phát hiện. Vài phút sau, một trong hai nhánh chạm vào token đã dùng và người dùng
bị đăng xuất dù chẳng ai tấn công cả.
Có khoá thì tab thứ hai xếp hàng, và khi tới lượt nó đọc được used_at đã có giá trị. Nhưng
như vậy nó rơi thẳng vào nhánh báo động — cũng đăng xuất người dùng vô tội. Cách xử lý thực dụng là cho
token vừa dùng một khoảng ân hạn: nếu nó mới bị dùng trong vòng vài chục giây, đi theo
dấu vết tới token đã thay thế nó và phát tiếp từ đó, thay vì hô báo động.
Đây là chỗ cột replaced_by_hash ở mục 3.1 kiếm sống. Nhớ lại dòng cuối của
reissueFrom: mỗi lần xoay vòng, token cũ ghi lại
row.replacedByHash = this.sha256(pair.refreshToken) — tức là
hash của token vừa thay thế nó. Một con trỏ, từ mắt xích cũ sang mắt xích kế tiếp.
Vì sao cần con trỏ đó? Khi tab thứ hai đưa lên một token vừa bị dùng, bạn không thể trả lại chính token đó
(nó đã bị đánh dấu used), và cũng không thể coi như không có chuyện gì. Thứ bạn cần là
đi tới mắt xích còn sống của chuỗi — mà token cũ thì không biết gì về nó, trừ khi lúc xoay vòng
ta đã ghi lại. replaced_by_hash chính là ghi chép đó, và vì reissueFrom vốn tra
cứu bằng hash nên con trỏ này dùng thẳng làm khoá tra được. Lưu hash chứ không lưu token gốc, cùng lý do
với token_hash: database bị đọc trộm cũng không đổi ra được token dùng thật.
Nói cho chính xác điều xảy ra sau đó, vì nó không hẳn như trực giác:
reissueFrom(manager, row.replacedByHash)
không trả lại token mà tab thứ nhất đang giữ. Nó tìm tới dòng của token thay thế, thấy
dòng đó chưa dùng, nên xoay tiếp một nhịp nữa và trả về một token mới. Kết quả: cả hai tab đều
cầm token hợp lệ trong cùng một family, không ai bị đăng xuất. Cái giá là mỗi lần va chạm như vậy tiêu
thêm một mắt xích — chấp nhận được, vì nó chỉ xảy ra khi hai tab refresh cách nhau vài giây.
Và đây là ranh giới của cơ chế, đáng nhớ: ân hạn chỉ che trong GRACE_MS. Một tab ngủ quên rồi
refresh bằng token đã dùng từ mười phút trước thì vẫn rơi vào nhánh báo động và cả family bị thu hồi —
đúng như thiết kế, vì ở khoảng cách đó không còn phân biệt được tab chậm với kẻ trộm.
const GRACE_MS = 30_000;
if (row.usedAt !== null) {
const age = Date.now() - row.usedAt.getTime();
if (age < GRACE_MS && row.replacedByHash !== null) {
// Gan nhu chac chan la tab thu hai, khong phai ke trom. Di theo con tro
// replaced_by_hash toi token da thay the token nay, roi xoay tiep tu do.
return this.reissueFrom(manager, row.replacedByHash);
}
await manager.delete(RefreshToken, { familyId: row.familyId });
throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
}
await manager.delete(...) rồi throw, cả hai đều nằm
trong cùng một dataSource.transaction() đang mở ở refresh(). Khi hàm
callback bên trong transaction() ném lỗi, TypeORM tự động
rollback toàn bộ những gì đã làm bên trong nó — kể cả lệnh xoá family bạn vừa gọi. Kết
quả: request bị từ chối đúng như mong đợi, nhưng family không hề bị đốt. Token cũ vẫn nằm
nguyên trong bảng, và lần phát hiện tái sử dụng kế tiếp lặp lại y hệt — cơ chế được quảng cáo là "đốt cả
family" thực chất không làm gì cả. Cách vá: đừng xoá bên trong transaction đang khoá dòng đó. Để
reissueFrom trả về
một tín hiệu thay vì tự ném lỗi khi gặp tái sử dụng thật, rồi xoá family
sau khi transaction đó đã đóng — dùng repository tiêm (this.refreshTokens), không
phải manager của transaction vừa kết thúc, để lệnh xoá này chạy trên một giao dịch độc lập,
không bị cuốn theo bất kỳ rollback nào:
tryReissue chứ không còn là reissueFrom. Không phải hàm
mới: nó thay thế hoàn toàn bản trên, và tên đổi vì hợp đồng đổi. Bản cũ gặp tái sử dụng
thì throw ngay tại chỗ; bản mới không ném mà trả về một kết quả
{ kind: 'ok' | 'reused' } để refresh() tự quyết sau khi transaction đã đóng.
Tiền tố try là quy ước cho đúng chuyện đó: hàm trả kết quả thay vì ném lỗi. Nếu bạn đang bực vì vừa học một cái tên rồi nó bị thay — hợp lý, và đây là lý do đáng để đổi: bản đầu không sai cú pháp, không sai logic khi đọc từng dòng, nó chỉ âm thầm không đốt được family vì rollback. Đọc thẳng bản đúng thì bạn sẽ không bao giờ biết cái bẫy đó tồn tại. Từ đây trở đi chỉ còn
tryReissue; bản reissueFrom ở trên không dùng nữa.
// Khai bao o pham vi module, canh DUMMY_HASH — khoi code o muc 3.3 phia tren
// da bi ban nay thay tron, nen dong nay phai co mat o day.
const GRACE_MS = 30_000;
async refresh(presented: string): Promise<TokenPair> {
const hash = this.sha256(presented);
const result = await this.dataSource.transaction((manager) => this.tryReissue(manager, hash));
if (result.kind === 'reused') {
// Repo tiem mo mot giao dich RIENG, doc lap voi giao dich khoa dong da dong o tren.
await this.refreshTokens.delete({ familyId: result.familyId });
throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
}
return result.pair;
}
private async tryReissue(
manager: EntityManager,
hash: string,
): Promise<{ kind: 'ok'; pair: TokenPair } | { kind: 'reused'; familyId: string }> {
const row = await manager
.createQueryBuilder(RefreshToken, 'rt')
.setLock('pessimistic_write')
.where('rt.token_hash = :hash', { hash })
.getOne();
if (row === null) {
throw new UnauthorizedException('Refresh token khong hop le');
}
if (row.usedAt !== null) {
const age = Date.now() - row.usedAt.getTime();
if (age < GRACE_MS && row.replacedByHash !== null) {
return this.tryReissue(manager, row.replacedByHash);
}
// KHONG throw o day. Tin hieu ra ngoai de refresh() xu ly sau khi
// transaction nay da dong — xem ly do o callout ben tren.
return { kind: 'reused', familyId: row.familyId };
}
if (row.expiresAt.getTime() < Date.now()) {
throw new UnauthorizedException('Refresh token da het han');
}
const user = await manager.findOneOrFail(User, { where: { id: row.userId } });
const pair = await this.issuePair(user, row.familyId, manager);
row.usedAt = new Date();
row.replacedByHash = this.sha256(pair.refreshToken);
await manager.save(row);
return { kind: 'ok', pair };
}
Nhắc lại cho chắc: tryReissue thay thế hoàn toàn reissueFrom ở trên — bản cũ tồn
tại để bạn thấy được cái bẫy rollback, không phải để giữ lại trong dự án. Cấu trúc tín hiệu ({ kind: 'ok' | 'reused', ... }) thay vì throw ngay là điểm mấu chốt: nó cho refresh() cơ hội chờ transaction
đóng xong rồi mới quyết định bước tiếp theo, đúng như nguyên tắc "transaction chỉ nên bảo vệ những gì
thật sự cần nguyên tử cùng nhau".
Muốn bỏ hẳn đánh đổi này thì phải giải bài toán ở phía client: dồn mọi yêu cầu refresh của cả ứng dụng vào một lời gọi duy nhất. Trong một tab, một biến
Promise dùng chung là đủ. Giữa
nhiều tab thì cần BroadcastChannel hoặc Web Locks API — làm được, nhưng đó là
thêm một hệ thống nữa để hỏng. Ân hạn phía server rẻ hơn nhiều và che được cả trường hợp client cũ.
Cơ chế này bạn sẽ tự chạy thấy ở cuối mục 3.5 — phải chờ tới đó vì demo cần hai endpoint
/auth/login và /auth/refresh, mà controller phát ra chúng lại là chỗ quyết định
chuyện cookie hay body, nên nó thuộc mục 3.5.
3.4. Đăng xuất một thiết bị hay tất cả
Nhờ có family_id, hai loại đăng xuất chỉ khác nhau ở điều kiện WHERE:
// Dang xuat thiet bi hien tai: xoa dung family cua token dang cam.
async logout(presented: string): Promise<void> {
const row = await this.refreshTokens.findOne({
where: { tokenHash: this.sha256(presented) },
});
if (row !== null) {
await this.refreshTokens.delete({ familyId: row.familyId });
}
}
// Dang xuat moi noi: dung khi doi mat khau hoac nghi bi lo.
async logoutEverywhere(userId: string): Promise<void> {
await this.refreshTokens.delete({ userId });
}
Access token đã phát vẫn sống thêm tối đa 15 phút sau khi đăng xuất — đúng như đã nói ở mục 2, và đó là cái giá đã biết trước của JWT. Với thao tác thật sự nhạy cảm (đổi email, xoá tài khoản, rút tiền), đừng tin mỗi access token: bắt nhập lại mật khẩu ngay tại chỗ.
AuthService vừa được lắp từng mảnh qua năm khối phía trên — mỗi khối chỉ thêm những gì
constructor cần cho riêng phần đó, nên constructor cuối cùng chưa từng xuất hiện trọn vẹn. Ráp lại một lần
cho rõ:
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
@InjectRepository(RefreshToken) private readonly refreshTokens: Repository<RefreshToken>,
private readonly passwords: PasswordService,
private readonly jwt: JwtService,
private readonly config: ConfigService<AppConfig, true>,
private readonly dataSource: DataSource,
) {}
3.5. Trả refresh token qua cookie hay qua body
Hai lựa chọn, và chúng thua ở hai kiểu tấn công khác nhau.
response.cookie('refresh_token', refreshToken, {
httpOnly: true, // JavaScript khong doc duoc — chan XSS lay token
secure: true, // chi gui qua HTTPS
sameSite: 'strict', // khong gui kem request tu site khac — chan CSRF
path: '/auth/refresh', // KHONG dinh vao moi request, chi dinh vao dung endpoint nay
maxAge: days * 24 * 60 * 60 * 1000,
});
Cookie httpOnly miễn nhiễm với XSS: kể cả khi kẻ tấn công chèn được script vào trang, script
đó không đọc nổi giá trị cookie. Đổi lại, trình duyệt tự động đính cookie vào request nên nó mở ra CSRF —
và đó là lý do sameSite: 'strict' phải có, không phải tuỳ chọn.
Trả qua body thì ngược lại: client tự cất, không tự động gửi đi đâu nên không có CSRF, nhưng chỗ cất nào cũng nằm trong tầm với của JavaScript, tức là trong tầm với của XSS.
httpOnly cho refresh token, access token
giữ trong biến JavaScript (không phải localStorage). Mất tab thì mất access token — không
sao, refresh lấy cái mới. Ứng dụng di động hoặc client không phải trình duyệt: trả qua body, vì không có khái niệm cookie tự động và các nền tảng đó có kho lưu trữ an toàn riêng.
Dự án này để endpoint hỗ trợ cả hai: đọc refresh token từ cookie trước, không có thì đọc từ body. Chỉ vài dòng, mà sau này thêm ứng dụng di động thì không phải sửa gì.
Ghép mọi thứ ở mục 3 lại thành controller thật — auth.module.ts ở đầu mục đã khai nó rồi:
import { IsOptional, IsString } from 'class-validator';
export class RefreshDto {
// Chi dung khi client khong phai trinh duyet (khong co cookie tu dong).
@IsOptional()
@IsString()
refreshToken?: string;
}
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { LoginDto } from './login.dto';
import { RefreshDto } from './refresh.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
import type { AuthedRequest, JwtPayload } from './jwt-payload';
import type { AppConfig } from '../config/configuration';
const COOKIE_NAME = 'refresh_token';
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly config: ConfigService<AppConfig, true>,
) {}
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) response: Response): Promise<{ accessToken: string }> {
const pair = await this.auth.login(dto.email, dto.password);
this.setRefreshCookie(response, pair.refreshToken);
return { accessToken: pair.accessToken };
}
// Doc cookie truoc, khong co thi doc body — dung nhu quyet dinh o muc 3.5.
@Post('refresh')
async refresh(
@Req() request: Request,
@Body() dto: RefreshDto,
@Res({ passthrough: true }) response: Response,
): Promise<{ accessToken: string }> {
const presented = this.readPresentedToken(request, dto);
if (presented === undefined) {
throw new UnauthorizedException('Thieu refresh token');
}
const pair = await this.auth.refresh(presented);
this.setRefreshCookie(response, pair.refreshToken);
return { accessToken: pair.accessToken };
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
async logout(@Req() request: Request, @Body() dto: RefreshDto, @Res({ passthrough: true }) response: Response): Promise<void> {
const presented = this.readPresentedToken(request, dto);
if (presented !== undefined) {
await this.auth.logout(presented);
}
response.clearCookie(COOKIE_NAME, { path: '/auth/refresh' });
}
@Post('logout-everywhere')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async logoutEverywhere(@CurrentUser() user: JwtPayload): Promise<void> {
await this.auth.logoutEverywhere(user.sub);
}
private readPresentedToken(request: Request, dto: RefreshDto): string | undefined {
return (request as AuthedRequest & Request).cookies?.[COOKIE_NAME] ?? dto.refreshToken;
}
private setRefreshCookie(response: Response, refreshToken: string): void {
const days = this.config.get('REFRESH_TOKEN_TTL_DAYS', { infer: true });
response.cookie(COOKIE_NAME, refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: days * 24 * 60 * 60 * 1000,
});
}
}
Controller vừa viết xong vẫn chưa được nạp: AuthModule ở mục 2 mới chỉ khai
providers. Thêm nó vào, và đây là lúc route /auth/* thật sự tồn tại:
import { AuthController } from './auth.controller';
@Module({
imports: [
/* ... giu nguyen nhu muc 2 ... */
],
controllers: [AuthController],
providers: [AuthService, PasswordService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
Khai module xong vẫn chưa xong: AppModule chưa biết nó tồn tại nên NestJS không nạp gì trong
đó cả. Cắm nó vào ngay bây giờ, đúng chỗ đã cắm BillingModule ở Part 1 mục 8.2 — làm sớm thì
mỗi lần lưu file bạn biết ngay có gãy gì không, thay vì dồn tới cuối mục 3 mới phát hiện:
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
AppConfigModule,
TypeOrmModule.forRootAsync({
/* ... nhu Part 1 ... */
}),
BillingModule,
AuthModule,
],
})
export class AppModule {}
imports của AppModule. Để khỏi lặp lại cả khối, từ đây bài chỉ viết "thêm
XModule vào imports của AppModule" — nghĩa là đúng chỗ này.
Khởi động lại app rồi kiểm nhanh trước khi chạy bài demo dài phía dưới. Số dư credit chưa liên quan gì ở đây, nên một request đăng nhập sai mật khẩu là đủ để biết dây đã thông:
curl -i -X POST localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"khong-ton-tai@test.local","password":"sai-mat-khau"}'
# Mong doi: HTTP/1.1 401 Unauthorized
# 401 = route CO THAT, DTO qua duoc, service da chay va tu choi dung.
# 404 = chua cam AuthModule vao AppModule, hoac chua them controllers: [...]
# 400 = ValidationPipe chan body, soi lai login.dto.ts
Endpoint đã có. Giờ chạy được cái đã hẹn ở mục 3.3: tự thấy một token cũ bị dùng lại làm thu hồi cả family.
# 1. Dang nhap, lay refresh token tu cookie tra ve
OLD=$(curl -s -i -X POST localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@test.local","password":"matkhau123"}' \
| sed -n 's/.*refresh_token=\([^;]*\).*/\1/p' | tr -d '\r')
# 2. Xoay vong mot lan -> nhan token moi
NEW=$(curl -s -i -X POST localhost:3000/auth/refresh \
-H 'Content-Type: application/json' -d "{\"refreshToken\":\"$OLD\"}" \
| sed -n 's/.*refresh_token=\([^;]*\).*/\1/p' | tr -d '\r')
# 3. PHAI CHO qua 30 giay an han, neu khong buoc 4 tra ve 201 chu khong phai 401
sleep 33
# 4. Dung lai token CU -> bao dong, thu hoi ca family
curl -s -X POST localhost:3000/auth/refresh \
-H 'Content-Type: application/json' -d "{\"refreshToken\":\"$OLD\"}"
# -> 401 {"message":"Phien dang nhap da bi thu hoi"}
# 5. Token MOI cung chet theo, vi ca family da bi xoa
curl -s -X POST localhost:3000/auth/refresh \
-H 'Content-Type: application/json' -d "{\"refreshToken\":\"$NEW\"}"
# -> 401 {"message":"Refresh token khong hop le"}
Bước 3 là bước dễ bỏ nhất và cũng gây hiểu nhầm nhất. Thử ngay lập tức thì bước 4 trả 201 kèm một access token hợp lệ — trông y như cơ chế phát hiện tái sử dụng không hoạt động, trong khi thật ra đó chính là khoảng ân hạn đang làm đúng việc của nó.
request.cookies cần cookie-parserCookie thành request.cookies — thiếu
cookie-parser, readPresentedToken ở trên luôn nhận undefined từ
nhánh cookie, và mọi client trình duyệt sẽ luôn rơi vào lỗi "Thieu refresh token" dù cookie vẫn nằm
trong request. Cài đặt và gắn ở main.ts, trước dòng app.listen():npm i cookie-parser
npm i -D @types/cookie-parserimport cookieParser from 'cookie-parser';
app.use(cookieParser());
4. nginx đứng trước ứng dụng
Đến đây NestJS đang nghe trực tiếp ở cổng 3000. Chạy được, nhưng không nên để vậy trên máy chủ thật. Có một lớp nữa cần dựng, và nó gánh những việc mà Node làm được nhưng làm dở.
upstream app {
server app:3000;
keepalive 32; # giu ket noi lai, khoi bat tay TCP moi request
}
server {
listen 443 ssl;
http2 on;
server_name media-forge.local;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Mac dinh cua nginx la 1MB. Khong sua thi upload video nao cung 413.
client_max_body_size 2g;
# Doc body cham cung khong sao — upload dai la binh thuong.
client_body_timeout 300s;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
# Bon dong nay quyet dinh ung dung "nhin thay" client la ai.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 15s - dung dung ngan sach thoi gian o muc 5.2, khong phai mac dinh cua nginx
proxy_read_timeout 15s;
}
}
server {
listen 80;
server_name media-forge.local;
return 301 https://$host$request_uri;
}
File cấu hình nằm đó thì chưa có gì chạy cả. Cần ba thứ nữa để thật sự thấy tầng này hoạt động: một chứng
chỉ, một dịch vụ nginx trong Compose, và một cách để hostname app trong khối
upstream trỏ về đúng tiến trình NestJS đang chạy.
mkdir -p docker/certs
# Chung chi tu ky, chi dung o may minh. Tren may chu that thi day la cho
# Let's Encrypt ghi vao, va do la ly do duong dan nam ngoai ung dung.
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout docker/certs/privkey.pem -out docker/certs/fullchain.pem \
-subj "/CN=media-forge.local" -addext "subjectAltName=DNS:media-forge.local"
services:
# ... dich vu postgres da chot o Part 1 muc 4.5, giu nguyen ...
nginx:
image: nginx:1.27-alpine
container_name: forge-nginx
ports:
- '8080:80'
- '8443:443'
volumes:
- ../nginx/media-forge.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
# Cho hostname `app` trong khoi upstream tro ve may that, noi
# `npm run start:dev` dang chay. Part 3 dong goi ung dung thanh container
# ten `app` thi bo dong nay di, con file cau hinh khong phai sua mot chu.
extra_hosts:
- 'app:host-gateway'
media-forge.conf phải đổi.
npm run db -- up -d nginx
npm run start:dev # o mot cua so khac, van chay tren host nhu truoc
# 1. Cong 80 phai day sang HTTPS
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
--resolve media-forge.local:8080:127.0.0.1 \
http://media-forge.local:8080/auth/login
# -> 301 https://media-forge.local/auth/login
# 2. Di xuyen qua nginx toi NestJS. -k vi chung chi tu ky.
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST https://media-forge.local:8443/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@test.local","password":"matkhau123"}'
# -> {"accessToken":"eyJhbGciOi..."} — dung request da di qua ca hai tang
# 3. http2 on da co tac dung chua
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-o /dev/null -w 'http=%{http_version}\n' https://media-forge.local:8443/
# -> http=2
Từ đây trở đi mọi thứ trong Part 2 đều đi qua cổng 8443 chứ không gọi thẳng cổng 3000 nữa — đó là điều
kiện để những mục sau có nghĩa, vì limit_req ở mục 5, header X-Forwarded-For ở
mục 4.2 và X-Accel-Redirect ở mục 7.1 đều là việc của nginx. Gọi thẳng 3000 thì cả ba đơn
giản là không tồn tại.
4.1. Vì sao TLS dừng lại ở nginx
Node hoàn toàn có thể tự nghe HTTPS. Nhưng để nginx cắt TLS có mấy cái lợi khó bỏ: gia hạn chứng chỉ không cần khởi động lại ứng dụng, đổi bộ thuật toán mã hoá là sửa một file cấu hình, và khi Part 3 chạy nhiều instance Node thì cả cụm dùng chung một chứng chỉ ở một chỗ thay vì mỗi tiến trình một bản. Phần bắt tay TLS cũng là việc nặng CPU mà nginx làm bằng C, còn Node thì mỗi giây dành cho việc đó là một giây không xử lý được nghiệp vụ.
Đoạn từ nginx tới Node đi bằng HTTP thường. Điều đó chấp nhận được vì hai bên nằm cùng một máy hoặc cùng một mạng riêng — và chỉ chấp nhận được với điều kiện đó. Nếu Node mở cổng 3000 ra internet thì mọi thứ ở trên thành vô nghĩa: người ta gọi thẳng cổng 3000, bỏ qua toàn bộ lớp nginx.
4.2. Cái bẫy X-Forwarded-For
Sau khi đặt nginx vào, request.ip trong Node không còn là IP người dùng nữa — nó là IP của
nginx. Mọi thứ dựa vào IP đều hỏng theo: nhật ký ghi sai, giới hạn theo IP gom cả thế giới vào một nhóm.
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// So proxy TIN CAY dung truoc ung dung. Dat dung con so, khong dat true.
app.set('trust proxy', 1);
trust proxy: true là một lỗ hổngX-Forwarded-For là một header thường — client tự đặt được. Đặt true, Express
tin toàn bộ chuỗi, nên ai cũng gửi kèm X-Forwarded-For: 1.2.3.4 để giả IP và vượt qua mọi
giới hạn theo IP. Đặt con số
1 nghĩa là "chỉ bỏ qua một chặng cuối cùng, phần còn lại do nginx ghi". Con số
phải khớp với số proxy thật đứng trước ứng dụng: qua Cloudflare rồi mới tới nginx thì là 2.
Đếm sai theo hướng nào cũng sai — thừa thì tin lời client, thiếu thì lấy nhầm IP của proxy.
5. Giới hạn tần suất: hai tầng, hai mục đích
Câu hỏi thường gặp là "đặt rate limit ở nginx hay ở ứng dụng". Câu trả lời là cả hai, vì chúng chặn hai thứ khác nhau.
upstream app {
server app:3000;
keepalive 32;
}
# NGU CANH `http` — hai dong nay phai nam NGOAI moi khoi server.
# 10MB bo nho giu duoc khoang 160.000 dia chi IP.
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
listen 443 ssl;
http2 on;
server_name media-forge.local;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 2g;
client_body_timeout 300s;
# DOI CHO so voi muc 4: bon dong header gio dat o muc `server` chu khong
# trong `location`, nen CA HAI location duoi day deu thua ke chung.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 15s;
location /auth/login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://app;
}
location / {
limit_req zone=general burst=60 nodelay;
proxy_pass http://app;
}
}
server {
listen 80;
server_name media-forge.local;
return 301 https://$host$request_uri;
}
limit_req_zone khai báo vùng nhớ dùng chung nên nó thuộc ngữ cảnh http — đặt
nhầm vào trong server thì nginx từ chối nạp. Còn limit_req là chỗ
sử dụng vùng đó nên nằm trong location. Nhét cả hai vào cùng một chỗ là lỗi
"location" directive is not allowed here. Cái bẫy thứ hai tinh vi hơn: mục 4 để bốn dòng
proxy_set_header bên trong
location /. Giờ có thêm location /auth/login, mà nginx
không cho location con thừa kế từ location anh em — chép thiếu là endpoint đăng nhập
mất sạch X-Forwarded-For, và trust proxy vừa đặt ở mục 4.2 lại đọc ra IP của
nginx. Đẩy chúng lên mức server như trên là cách gọn nhất để không bao giờ quên.
docker exec forge-nginx nginx -t && docker exec forge-nginx nginx -s reload
# rate=5r/m + burst=3 -> vai request dau lot, phan con lai bi chan ngay tai nginx
for i in $(seq 1 8); do
curl -sk --resolve media-forge.local:8443:127.0.0.1 -o /dev/null -w '%{http_code} ' \
-X POST https://media-forge.local:8443/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@test.local","password":"sai"}'
done; echo
# -> 400 400 400 400 503 503 503 503
rate=5r/m ở endpoint đăng nhập là để chống dò mật khẩu. burst=3 cho phép dồn ba
request vượt mức trước khi từ chối, còn nodelay nghĩa là phục vụ chúng ngay chứ không xếp
hàng cho đều — người gõ sai mật khẩu rồi thử lại ngay không phải chờ vô cớ.
Chú ý mã trả về là 503 chứ không phải 429. Đó là mặc định của limit_req, và
nó khác hẳn mã 429 mà tầng ứng dụng sẽ trả ở mục 5.1 ngay dưới đây — hai con số khác nhau là dấu hiệu rất
tiện để biết request bị chặn ở tầng nào. Muốn nginx cũng trả 429 thì thêm
limit_req_status 429;, nhưng giữ nguyên 503 thì đọc log dễ hơn.
Số request lọt qua ở lần chạy đầu có thể không đúng bằng 4: gáo của nginx được tính theo thời gian thật,
nên nếu vừa gọi /auth/login ở mục 4 xong thì ngân sách còn ít hơn. Chờ một phút rồi chạy lại
sẽ thấy đúng bốn request đầu lọt.
Nhưng nginx chỉ biết địa chỉ IP. Nó không biết người dùng này gói miễn phí hay trả tiền, không biết hôm nay họ đã chuyển mã bao nhiêu video. Cả một văn phòng đi chung một IP thì với nginx họ là một người. Giới hạn theo người dùng phải nằm trong ứng dụng, và cần một chỗ đếm dùng chung cho mọi instance — tức là Redis.
5.1. Token bucket trên Redis
Thuật toán ở đây là gáo token: mỗi người có một gáo, token nhỏ vào đều đặn theo thời gian, mỗi request múc ra một token, hết token thì bị từ chối. Nó nhỉnh hơn kiểu đếm theo cửa sổ cố định vì cho phép dồn cục ngắn — người dùng im lặng mười phút rồi bắn một loạt vẫn qua được, đúng với cách con người thật sự dùng ứng dụng.
Điều bắt buộc là toàn bộ phép tính phải nguyên tử. Đọc gáo rồi tính rồi ghi lại bằng ba lệnh Redis riêng lẻ là tái lập đúng lỗi double-spend của Part 1, chỉ đổi chỗ xảy ra. Redis chạy script Lua đơn luồng, nên gói cả phép tính vào một script là xong.
-- KEYS[1] = khoa cua gao, vi du "rl:user:42"
-- ARGV = suc chua, so token moi giay, thoi diem hien tai (giay), so token can lay
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local want = tonumber(ARGV[4])
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'updated')
local tokens = tonumber(bucket[1]) or capacity
local updated = tonumber(bucket[2]) or now
-- Do day lai theo thoi gian da troi qua, khong vuot suc chua.
tokens = math.min(capacity, tokens + (now - updated) * refill)
local allowed = 0
if tokens >= want then
tokens = tokens - want
allowed = 1
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'updated', now)
-- Gao day lai hoan toan sau capacity/refill giay thi khong con thong tin gi.
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2)
return { allowed, math.floor(tokens) }
Không có vòng lặp nào chạy nền để đổ token. Gáo chỉ được tính lại đúng lúc có người chạm vào nó, dựa trên khoảng thời gian kể từ lần chạm trước. Một triệu người dùng im lặng thì tốn đúng không đồng nào.
File Lua ở trên cần một nơi nạp và chạy nó, mà trước đó thì cần hai thứ chưa hề có trong dự án: thư viện client và một Redis đang chạy. Cả hai đều thêm vào lúc này.
npm i ioredis # bai nay viet voi ioredis@5
services:
# ... postgres (Part 1 muc 4.5) va nginx (muc 4) giu nguyen ...
redis:
image: redis:7-alpine
container_name: forge-redis
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
npm run db -- up -d redis
docker exec forge-redis redis-cli ping
# -> PONG
Biến REDIS_URL đã khai ở mục 2 với .default('redis://localhost:6379'), nên không
phải sửa .env. Mặt trái của cái mặc định đó: thiếu Redis thì ứng dụng vẫn khởi động bình
thường rồi mới chết lúc guard chạy request đầu tiên — khác hẳn kiểu dừng ngay từ đầu của
JWT_SECRET.
Giờ mới tới chỗ nạp file Lua. RedisService kế thừa thẳng từ client ioredis — mọi
lệnh Redis thô (xadd, publish, duplicate()...) dùng ở Part 3 đều là
method có sẵn từ lớp cha, chỉ runTokenBucket() là method riêng thêm vào cho gáo token ở đây:
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { AppConfig } from '../config/configuration';
@Injectable()
export class RedisService extends Redis implements OnModuleDestroy {
constructor(config: ConfigService<AppConfig, true>) {
super(config.get('REDIS_URL', { infer: true }));
this.defineCommand('tokenBucket', {
numberOfKeys: 1,
lua: readFileSync(join(__dirname, '../common/rate-limit/token-bucket.lua'), 'utf8'),
});
}
// Chu ky khop dung file .lua: capacity, refillPerSecond, now (giay), want.
// defineCommand gan method nay luc runtime nen TypeScript khong tu suy ra
// duoc kieu — khai bao tay o day de phan con lai dung kieu that.
async runTokenBucket(
key: string,
capacity: number,
refillPerSecond: number,
now: number,
want: number,
): Promise<[allowed: number, remaining: number]> {
const call = this as unknown as {
tokenBucket(key: string, capacity: number, refill: number, now: number, want: number): Promise<[number, number]>;
};
return call.tokenBucket(key, capacity, refillPerSecond, now, want);
}
onModuleDestroy(): void {
this.disconnect();
}
}
.lua biến mất sau nest buildnest build chỉ biên dịch .ts — nó không tự copy
token-bucket.lua sang dist/, nên bản build production
readFileSync ở trên báo ENOENT dù npm run start:dev (chạy qua
ts-node, đọc thẳng từ src/) không hề gặp lỗi này. Khai báo tường minh trong
nest-cli.json để asset này được copy đúng chỗ:"compilerOptions": {
"assets": [{ "include": "common/rate-limit/*.lua", "outDir": "dist" }],
"watchAssets": true
}
outDir ở đây phải khớp với vị trí thật của main.js sau khi build — TypeScript
tự suy ra rootDir từ tập file được biên dịch, nên tuỳ cấu hình
tsconfig.json của từng dự án, dist/main.js có thể nằm ở
dist/ hoặc dist/src/. Chạy find dist -iname main.js để biết chắc,
rồi đặt outDir đúng thư mục cha của file đó — đặt sai thì lỗi ENOENT vẫn còn
nguyên, chỉ khác đường dẫn báo lỗi.
RedisService là một provider, nên nó cần một module công bố mình ra. Module này rất ngắn
nhưng bỏ nó đi thì mọi thứ dựa vào Redis từ đây tới hết Part 3 đều không tiêm được:
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
// @Global vi gan nhu module nao cung can Redis (rate limit o day, hang doi va
// WebSocket o Part 3) — khai bao mot lan, khoi import lai o khap noi.
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}
Rồi thêm RedisModule vào mảng imports của AppModule, đúng chỗ đã
thêm AuthModule ở mục 2.
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import type { AuthedRequest } from '../../auth/jwt-payload';
import { RedisService } from '../../redis/redis.service';
interface BucketRule {
readonly capacity: number;
readonly refillPerSecond: number;
}
const RULES: Readonly<Record<'free' | 'pro', BucketRule>> = {
free: { capacity: 20, refillPerSecond: 0.2 }, // ~12 request/phut
pro: { capacity: 200, refillPerSecond: 2 },
};
@Injectable()
export class RateLimitGuard implements CanActivate {
constructor(private readonly redis: RedisService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthedRequest>();
const rule = RULES[request.user.plan];
const [allowed, remaining] = await this.redis.runTokenBucket(
`rl:user:${request.user.sub}`,
rule.capacity,
rule.refillPerSecond,
Math.floor(Date.now() / 1000),
1,
);
const response = context.switchToHttp().getResponse<{ setHeader: (k: string, v: string) => void }>();
response.setHeader('X-RateLimit-Remaining', String(remaining));
if (allowed === 0) {
// 429 kem Retry-After — client biet cho bao lau thay vi thu lien tuc.
throw new HttpException(
{ message: 'Vuot han muc, thu lai sau it phut' },
HttpStatus.TOO_MANY_REQUESTS,
);
}
return true;
}
}
Ứng dụng chặn hạn mức: tài khoản miễn phí chỉ được chuyển mã 5 video mỗi ngày. Đây là quyết định nghiệp vụ, cần biết người dùng là ai — nginx không thể biết vì thông tin đó nằm trong JWT mà nó không giải mã.
Đặt nhầm chỗ thì hoặc phải giải mã JWT trong nginx (làm được, nhưng nghiệp vụ rơi vào file cấu hình), hoặc để một trận lụt request chạy hết vào Node rồi mới từ chối — tức là vẫn chịu toàn bộ chi phí.
5.2. Ngân sách thời gian
Một request đi qua nhiều tầng, mỗi tầng có timeout riêng. Nếu các con số đó không được đặt theo một thứ tự có chủ đích, bạn sẽ gặp tình huống rất khó gỡ: nginx bỏ cuộc và trả 504 cho người dùng, trong khi Node vẫn đang cần mẫn chạy tiếp một truy vấn mà kết quả không còn ai nhận.
Truy van database : 5s (statement_timeout)
Handler NestJS : 10s (interceptor timeout)
proxy_read_timeout : 15s (nginx)
Client : 20s (fetch + AbortController)
Nguyen tac: tang ngoai LUON dai hon tang trong.
Nguoc lai thi tang ngoai bo cuoc truoc, tang trong van chay — ton tai nguyen
cho mot ket qua khong con ai doi, va nhat ky ghi "thanh cong" cho mot request
ma nguoi dung da thay bao loi.
Trong bốn con số đó mới có đúng một cái là thật: proxy_read_timeout 15s đã nằm trong
media-forge.conf từ mục 4. Ba cái còn lại phải tự đặt, và mỗi cái đặt ở một chỗ khác nhau.
Tầng trong cùng là một dòng trong tuỳ chọn TypeORM — Postgres tự huỷ truy vấn quá hạn, không cần ai canh:
// Bat buoc false. Xem canh bao ben duoi.
synchronize: false,
// Tang trong cung cua ngan sach thoi gian. Postgres tu huy truy van qua 5s,
// thay vi de no chay tiep khi khong con ai doi ket qua nua.
extra: { statement_timeout: 5_000 },
Tầng giữa là một interceptor toàn cục. Nó dùng rxjs, vốn đã có sẵn cùng NestJS nên không phải
cài thêm gì:
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common';
import { Observable, TimeoutError, throwError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
// Dai hon statement_timeout (5s) de truy van kip bao loi truoc, va ngan hon
// proxy_read_timeout (15s) de nginx khong bo cuoc truoc ung dung.
const HANDLER_TIMEOUT_MS = 10_000;
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
timeout(HANDLER_TIMEOUT_MS),
catchError((error: unknown) =>
throwError(() => (error instanceof TimeoutError ? new RequestTimeoutException() : error)),
),
);
}
}
app.useGlobalInterceptors(new TimeoutInterceptor());
extra của TypeORM đi thẳng xuống driver pg, nên dễ tưởng đã đặt mà thực ra
không. Cách nhanh nhất để biết chắc là hỏi thẳng Postgres qua chính AppDataSource đã viết ở
Part 1 mục 7: SHOW statement_timeout phải trả về 5s, và
SELECT pg_sleep(7) phải chết với
canceling statement due to statement timeout thay vì chạy đủ bảy giây. Tầng ngoài cùng —
AbortController phía trình duyệt — thuộc về giao diện, nên nó nằm ở mục 8
cùng với api.ts.
6. Upload: nhận file 2 GB mà không phình bộ nhớ
Đây là chỗ mọi hướng dẫn "upload trong Node" đều dạy sai, vì với file 2 MB thì cách sai vẫn chạy tốt.
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(@UploadedFile() file: Express.Multer.File) {
// file.buffer — CA FILE dang nam trong RAM.
// 10 nguoi upload 2GB cung luc = 20GB. Tien trinh chet.
return this.media.save(file.buffer);
}
Vấn đề không phải multer, mà là chế độ mặc định của nó: gom toàn bộ body vào một
Buffer rồi mới gọi handler. Cách đúng là không bao giờ giữ cả file — đọc
từng mẩu, ghi từng mẩu, mẩu đã ghi xong thì thả cho bộ nhớ thu hồi. Đó chính là stream.
import { Controller, Post, Req, UseGuards, BadRequestException } from '@nestjs/common';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitGuard } from '../common/rate-limit/rate-limit.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthedRequest, JwtPayload } from '../auth/jwt-payload';
import { MediaService } from './media.service';
@Controller('media')
@UseGuards(JwtAuthGuard, RateLimitGuard)
export class UploadController {
constructor(private readonly media: MediaService) {}
@Post('upload')
async upload(
@Req() request: AuthedRequest,
@CurrentUser() user: JwtPayload,
): Promise<{ videoId: string }> {
const contentLength = Number(request.headers['content-length'] ?? 0);
if (contentLength > 2 * 1024 * 1024 * 1024) {
throw new BadRequestException('File vuot qua 2GB');
}
const videoId = randomUUID();
const target = join(this.media.uploadDir, `${videoId}.bin`);
// pipeline lo backpressure va don dep khi co loi. Tu goi .pipe()
// thi phai tu bat 'error' o ca hai dau, quen mot ben la ro file.
await pipeline(request, createWriteStream(target));
// Ten file goc di kem qua header rieng — body la bytes thuan tuy, khong
// phai multipart, nen khong the doc file.name theo cach thuong (xem
// src/lib/upload.ts o muc 8.2, noi header nay duoc dat tu phia client).
const originalName = request.headers['x-filename'];
const decodedName = typeof originalName === 'string' ? decodeURIComponent(originalName) : null;
const video = await this.media.registerUpload(videoId, user.sub, target, decodedName);
return { videoId: video.id };
}
}
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { basename } from 'node:path';
import { Video } from './video.entity';
import type { AppConfig } from '../config/configuration';
export interface PlayableAsset {
readonly path: string;
readonly sizeBytes: number;
readonly storageKey: string;
}
@Injectable()
export class MediaService {
readonly uploadDir: string;
readonly tempDir: string;
constructor(
@InjectRepository(Video) private readonly videos: Repository<Video>,
config: ConfigService<AppConfig, true>,
) {
this.uploadDir = config.get('UPLOAD_DIR', { infer: true });
this.tempDir = config.get('TEMP_UPLOAD_DIR', { infer: true });
}
// videoId da duoc UploadController sinh TRUOC khi ghi file (de dat ten file
// theo id) — luu voi id da co san thay vi de TypeORM tu sinh.
async registerUpload(
videoId: string,
userId: string,
path: string,
originalName: string | null,
): Promise<Video> {
return this.videos.save(
this.videos.create({ id: videoId, userId, originalKey: path, originalName }),
);
}
// Dung boi stream.controller.ts o muc 7. Kiem userId ngay tai day: video
// khong thuoc ve nguoi goi thi coi nhu khong ton tai, khong tiet lo no co
// ton tai o dau khac hay khong.
async findPlayable(id: string, userId: string): Promise<PlayableAsset> {
const video = await this.videos.findOne({ where: { id, userId } });
if (video === null) {
throw new NotFoundException('Khong tim thay video');
}
return {
path: video.originalKey,
sizeBytes: Number(video.size ?? 0),
// storageKey la duong dan TUONG DOI ma nginx alias /var/media/ ghep
// vao — dung khi uploadDir chinh la /var/media (xem docker-compose Part 3).
storageKey: basename(video.originalKey),
};
}
}
findPlayable chưa từng được viết rastream.controller.ts ở mục 7 gọi this.media.findPlayable(id, userId) và kỳ
vọng nó trả về { path, sizeBytes, storageKey } — nhưng method này chưa từng tồn tại, và tên
trường trong đó cũng không khớp thẳng với entity (Video dùng
originalKey/size, không phải path/sizeBytes). Viết
ra ở đây, ánh xạ đúng tên cột thật sang hình dạng mà controller cần.
Thêm một biến môi trường cho thư mục lưu file — docker-compose.yml ở mục 5 của Part 3 gắn một
volume vào đúng đường dẫn này để API và worker cùng nhìn thấy một nơi:
UPLOAD_DIR: z.string().default('./uploads'),
TEMP_UPLOAD_DIR: z.string().default('./uploads/tmp'),
Hai biến này có .default() nên không phải sửa .env, nhưng
createWriteStream thì không tự tạo thư mục. Thiếu nó, lỗi
ENOENT nổ ra ở giữa lúc đang đọc body — tức là người dùng đợi hết cả file rồi mới nhận 500,
chứ không phải một lỗi cấu hình gọn gàng lúc khởi động. Tạo trước một lần:
mkdir -p uploads/tmp
Còn thiếu mảnh cuối: cả controller lẫn service ở trên đều chưa thuộc module nào, nên NestJS không hề biết
chúng tồn tại. Module này có một chi tiết dễ vấp — nó phải import AuthModule:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Video } from './video.entity';
import { MediaService } from './media.service';
import { UploadController } from './upload.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
// forFeature: cong bo repository cua Video cho MediaService tiem vao.
// AuthModule: vi JwtAuthGuard tiem JwtService — guard duoc phan giai trong
// module DUNG no, khong phai module khai no.
imports: [TypeOrmModule.forFeature([Video]), AuthModule],
controllers: [UploadController],
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}
AuthModule khỏi imports thì ứng dụng không khởi động nổi,
với thông báo dễ gây hiểu lầm:
Nest can't resolve dependencies of the JwtAuthGuard (?)... argument JwtService at index [0] is
available in the MediaModule module. Lỗi chỉ vào MediaModule dù JwtAuthGuard nằm trong thư mục
auth/ — vì decorator @UseGuards nằm ở UploadController, nên
NestJS đi tìm JwtService trong ngữ cảnh của MediaModule.
AuthModule đã exports: [AuthService, JwtModule] từ mục 2 chính là để chỗ này
dùng được.
Thêm MediaModule vào imports của AppModule rồi thử thật:
head -c 838860800 /dev/urandom > /tmp/big.bin # mot file 800MB
TOK=$(curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST https://media-forge.local:8443/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@test.local","password":"matkhau123"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["accessToken"])')
PID=$(lsof -ti:3000 | head -1)
ps -o rss= -p $PID # bo nho TRUOC khi upload
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST https://media-forge.local:8443/media/upload \
-H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/octet-stream' \
--data-binary @/tmp/big.bin -D- -w '\nhttp=%{http_code}\n'
ps -o rss= -p $PID # va SAU khi upload xong
Kết quả đo trên máy viết bài: file 800 MB đi trọn vẹn, trả về
{"videoId":"5df79e85-..."} với http=201, và bộ nhớ tiến trình đi từ
212 MB lên đỉnh 262 MB — tăng khoảng 50 MB, không phải 800. Đó là toàn bộ
luận điểm của mục này gói trong một con số: cách sai sẽ cho ra một tiến trình phình đúng bằng kích thước
file.
Trong đống header trả về còn một dòng đáng chú ý: x-ratelimit-remaining: 19. Nó chứng minh
RateLimitGuard ở mục 5.1 đã chạy thật — gáo token 20 của gói free vừa bị múc mất
một, và con số đó đi qua Redis chứ không nằm trong bộ nhớ tiến trình.
6.1. Backpressure, nói cho gọn
Mạng đưa dữ liệu vào nhanh hơn ổ đĩa ghi ra. Nếu cứ nhận bao nhiêu nhét vào bấy nhiêu, phần chênh lệch chất đống trong RAM — và ta quay lại đúng vấn đề vừa tránh, chỉ khó thấy hơn.
Backpressure là cơ chế cho phép đầu ghi nói với đầu đọc: khoan đã. Cụ thể,
writable.write() trả về false khi bộ đệm nội bộ đã đầy, và bên đọc phải tạm dừng
cho tới khi có sự kiện 'drain'. pipeline làm toàn bộ việc đó, kể cả phần khó
nhất là dọn dẹp: một đầu lỗi thì nó huỷ đầu kia và đóng mọi thứ.
// TU LAM — bo qua rat nhieu truong hop
request.pipe(createWriteStream(target)); // client ngat giua chung?
// dia day? -> file rac nam lai,
// handle khong dong
// PIPELINE — mot dau hong thi huy ca chuoi, dong sach
await pipeline(request, createWriteStream(target));
setInterval(() => console.log(process.memoryUsage().rss / 1e6), 1000);
Bản dùng
FileInterceptor có rss leo dần lên hơn một nghìn. Bản dùng
pipeline đi ngang, chênh lệch chỉ vài chục megabyte bất kể file lớn cỡ nào — vì tại mỗi
thời điểm trong bộ nhớ chỉ có vài mẩu đang trên đường từ socket sang đĩa.
6.2. Upload tiếp tục được sau khi mất mạng
Upload 2 GB qua mạng di động thì đứt giữa chừng là chuyện thường. Bắt người dùng làm lại từ đầu là cách nhanh nhất để họ bỏ đi. Cách chữa không cần giao thức gì cao siêu, chỉ cần chia nhỏ và ghi nối:
import { mkdir, readdir } from 'node:fs/promises';
// Va bo sung `Get` cung `Param` vao dong import @nestjs/common da co san:
// import { Controller, Post, Get, Param, Req, UseGuards, BadRequestException } from '@nestjs/common';
// Client chia file thanh cac manh 5MB va gui lan luot.
@Post('upload/:uploadId/chunk/:index')
async uploadChunk(
@Param('uploadId') uploadId: string,
@Param('index') index: string,
@Req() request: AuthedRequest,
) {
const target = join(this.media.tempDir, uploadId, `${index}.part`);
// Thu muc con theo tung uploadId chua tung duoc tao truoc do.
await mkdir(join(this.media.tempDir, uploadId), { recursive: true });
await pipeline(request, createWriteStream(target));
return { received: Number(index) };
}
// Truoc khi gui tiep, client hoi: toi da gui toi dau roi?
@Get('upload/:uploadId/status')
async status(@Param('uploadId') uploadId: string) {
const parts = await readdir(join(this.media.tempDir, uploadId));
return { received: parts.map((p) => Number(p.replace('.part', ''))).sort((a, b) => a - b) };
}
Mất mạng, mở lại trang, client gọi status, biết mảnh 0–37 đã tới nơi, gửi tiếp từ mảnh 38.
Ghép các mảnh lại là việc của bước cuối, và nó cũng chạy bằng stream chứ không đọc hết vào bộ nhớ.
import { createReadStream } from 'node:fs';
import { rm } from 'node:fs/promises';
// Client goi endpoint nay khi da gui xong tat ca cac manh.
@Post('upload/:uploadId/complete')
async completeUpload(
@Param('uploadId') uploadId: string,
@CurrentUser() user: JwtPayload,
): Promise<{ videoId: string }> {
const tempPath = join(this.media.tempDir, uploadId);
const parts = await readdir(tempPath);
const ordered = parts.map((p) => Number(p.replace('.part', ''))).sort((a, b) => a - b);
const videoId = randomUUID();
const target = join(this.media.uploadDir, `${videoId}.bin`);
const out = createWriteStream(target);
// MOT writeStream duy nhat cho ca file dich. { end: false } de pipeline
// khong tu dong voi moi manh, chi dong that su o cuoi vong lap.
for (const index of ordered) {
await pipeline(createReadStream(join(tempPath, `${index}.part`)), out, { end: false });
}
out.end();
// Da ghep xong, thu muc chua tung manh khong con can nua.
await rm(tempPath, { recursive: true, force: true });
const video = await this.media.registerUpload(videoId, user.sub, target, null);
return { videoId: video.id };
}
head -c 26214400 /dev/urandom > /tmp/chunky.bin # 25MB
split -b 5242880 /tmp/chunky.bin /tmp/part- # thanh 5 manh 5MB
UP=$(uuidgen)
# Gui 3 manh dau roi dung lai — gia lam mat mang
i=0; for f in $(ls /tmp/part-* | head -3); do
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST "https://media-forge.local:8443/media/upload/$UP/chunk/$i" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/octet-stream' \
--data-binary @$f; i=$((i+1));
done
# -> {"received":0} {"received":1} {"received":2}
# Mo lai trang: toi da gui toi dau roi?
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
"https://media-forge.local:8443/media/upload/$UP/status" -H "Authorization: Bearer $TOK"
# -> {"received":[0,1,2]} -> gui tiep tu manh 3
i=3; for f in $(ls /tmp/part-* | tail -2); do
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST "https://media-forge.local:8443/media/upload/$UP/chunk/$i" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/octet-stream' \
--data-binary @$f; i=$((i+1));
done
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST "https://media-forge.local:8443/media/upload/$UP/complete" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json'
# -> {"videoId":"33003ecf-..."}
# Phep thu that su: file ghep lai co dung tung byte khong?
shasum -a 256 /tmp/chunky.bin
shasum -a 256 uploads/<videoId>.bin
# -> hai chuoi bam GIONG HET nhau
Hai chuỗi băm khớp nhau là bằng chứng gọn nhất rằng thứ tự ghép đúng và
{ end: false } đã làm đúng việc của nó — sai một trong hai thì file vẫn tạo ra được, vẫn đúng
dung lượng, chỉ có nội dung là hỏng. Thư mục uploads/tmp/<uploadId> cũng biến mất sau
lệnh cuối, đúng như rm ở cuối method.
uploadId đi thẳng từ URL vào join(this.media.tempDir, uploadId). Mà
join chuẩn hoá .., nên một uploadId kiểu
../../something ghi được ra ngoài tempDir. Trong dự án thật, phải ép
uploadId là UUID hợp lệ trước khi ghép đường dẫn — chính là việc mà
ParseUUIDPipe làm. Thứ hai: không endpoint nào trong ba endpoint kiểm
uploadId có thuộc về người gọi hay
không. Ai biết được uploadId của người khác thì gọi complete được, và video sẽ
về tài khoản của họ. Cách chữa đúng là lưu chủ sở hữu của uploadId ngay từ mảnh đầu tiên
rồi đối chiếu ở mọi mảnh sau — cùng một tinh thần với findPlayable(id, userId) ở mục 6.
7. Download: tua được, mà Node gần như không làm gì
Phát lại video có một yêu cầu mà tải file thường không có: tua. Kéo thanh tiến trình tới phút thứ 12, trình duyệt không tải lại từ đầu — nó hỏi xin đúng đoạn byte tương ứng.
GET /media/abc123/play HTTP/1.1
Range: bytes=52428800-
HTTP/1.1 206 Partial Content
Content-Range: bytes 52428800-104857599/104857600
Content-Length: 52428800
Accept-Ranges: bytes
Content-Type: video/mp4
Mã trạng thái là 206 chứ không phải 200, và nếu server bỏ qua header
Range mà cứ trả 200 kèm cả file, trình duyệt sẽ vô hiệu hoá tua. Thanh tiến
trình vẫn hiện nhưng kéo không được — một lỗi rất hay gặp và rất khó đoán nguyên nhân nếu không biết chỗ
này.
@Get(':id/play')
@UseGuards(JwtAuthGuard)
async play(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
@Req() request: Request,
@Res() response: Response,
): Promise<void> {
const asset = await this.media.findPlayable(id, user.sub);
const size = asset.sizeBytes;
const range = request.headers.range;
if (range === undefined) {
response.writeHead(200, { 'Content-Length': size, 'Accept-Ranges': 'bytes' });
await pipeline(createReadStream(asset.path), response);
return;
}
// "bytes=52428800-" hoac "bytes=0-1023"
const [rawStart, rawEnd] = range.replace('bytes=', '').split('-');
const start = Number(rawStart);
const end = rawEnd === undefined || rawEnd === '' ? size - 1 : Number(rawEnd);
if (Number.isNaN(start) || start >= size || end >= size) {
response.writeHead(416, { 'Content-Range': `bytes */${size}` });
response.end();
return;
}
response.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Content-Length': end - start + 1,
'Accept-Ranges': 'bytes',
'Content-Type': 'video/mp4',
});
await pipeline(createReadStream(asset.path, { start, end }), response);
}
Đoạn này đúng và chạy được. Nhưng nó có một điểm yếu chỉ lộ ra khi đông người xem: mỗi người đang xem chiếm một luồng đọc đĩa và một chuỗi stream trong tiến trình Node. Một nghìn người xem đồng thời là một nghìn thứ như vậy, và Node đang bận bê byte từ đĩa ra mạng — công việc mà nó chẳng đóng góp giá trị gì.
7.1. X-Accel-Redirect: giao việc bê vác cho nginx
Ý tưởng là tách đôi: NestJS quyết định có được xem không, nginx lo phần gửi byte. Ứng
dụng trả về một response rỗng kèm một header trỏ tới file, nginx thấy header đó thì tự phục vụ file — bằng
sendfile, tức là dữ liệu đi thẳng từ đĩa ra socket trong nhân hệ điều hành, không đi qua tiến
trình nào ở tầng ứng dụng.
location /protected-media/ {
internal; # go thang URL nay tu ngoai vao -> 404
alias /var/media/;
sendfile on;
tcp_nopush on;
}
/var/media/ chưa tồn tại với container nginxalias /var/media/ là đường dẫn bên trong container nginx, còn file thì
đang nằm ở ./uploads trên máy mình theo UPLOAD_DIR ở mục 6. Không nối hai chỗ
đó lại thì nginx trả 404 cho mọi lượt xem, và lỗi trông y hệt như signed URL bị sai. Thêm
một dòng mount vào dịch vụ nginx đã dựng ở mục 4:volumes:
- ../nginx/media-forge.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ../uploads:/var/media:ro
Chỉ đọc (
:ro) là đủ và nên như vậy: nginx chỉ gửi file đi, việc ghi file là của NestJS. Ở
Part 3, khi API và worker cùng chạy trong Compose, chỗ này thành một volume dùng chung và
UPLOAD_DIR đổi thẳng thành /var/media — lúc đó không còn phải ánh xạ gì nữa.
@Get(':id/play')
@UseGuards(JwtAuthGuard)
async play(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
@Res() response: Response,
): Promise<void> {
// Nest van lam phan kho: kiem tra quyen, han muc, trang thai video.
const asset = await this.media.findPlayable(id, user.sub);
response.setHeader('X-Accel-Redirect', `/protected-media/${asset.storageKey}`);
response.setHeader('Content-Type', 'video/mp4');
response.end(); // Body rong. nginx thay header tren va tu gui file.
}
Toàn bộ phần Range viết tay ở trên biến mất — nginx xử lý 206,
Content-Range, 416 đầy đủ và đúng chuẩn hơn bản tự viết. Node chỉ còn chạy một
truy vấn kiểm tra quyền rồi trả về response rỗng, tính bằng mili giây. Từ một nghìn stream mở trong tiến
trình xuống còn không cái nào.
internal là thứ ngăn điều đó: vùng
/protected-media/ chỉ đáp ứng khi lệnh đến từ chính ứng dụng, gõ thẳng từ trình duyệt thì
nhận 404. Kết quả là ta được cả hai: mỗi lượt xem vẫn đi qua một lần kiểm tra quyền thật trong NestJS, nhưng không byte dữ liệu nào phải chui qua Node.
7.2. Khi thẻ <video> không gửi được header
Có một trở ngại thực tế: <video src="..."> do trình duyệt tự tải, và bạn không chen
được header Authorization vào đó. Cách phổ biến là URL có chữ ký — nhét
quyền vào chính đường dẫn, kèm hạn dùng ngắn.
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { AppConfig } from '../config/configuration';
@Injectable()
export class SignedUrlService {
constructor(private readonly config: ConfigService<AppConfig, true>) {}
private get secret(): string {
return this.config.get('SIGNED_URL_SECRET', { infer: true });
}
sign(assetId: string, userId: string, ttlSeconds = 300): string {
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
const payload = `${assetId}:${userId}:${expires}`;
const signature = createHmac('sha256', this.secret).update(payload).digest('base64url');
return `/media/${assetId}/play?u=${userId}&e=${expires}&s=${signature}`;
}
verify(assetId: string, userId: string, expires: number, signature: string): boolean {
if (expires < Math.floor(Date.now() / 1000)) return false;
const expected = createHmac('sha256', this.secret)
.update(`${assetId}:${userId}:${expires}`)
.digest('base64url');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// So sanh thoi gian hang so. Dung === thi thoat som o byte dau tien khac nhau,
// va chenh lech thoi gian do duoc de do dan tung byte cua chu ky.
return a.length === b.length && timingSafeEqual(a, b);
}
}
play() ở mục 7.1 mâu thuẫn với chính lý do URL có chữ ký tồn tại
play() vừa viết: nó bắt @UseGuards(JwtAuthGuard), tức là đòi header
Authorization. Nhưng URL có chữ ký sinh ra chính vì thẻ
<video src="..."> không gửi được header đó — route mà nó trỏ tới phải chấp nhận yêu
cầu không kèm Bearer token. Giữ nguyên JwtAuthGuard ở đó thì mọi request từ
<video> luôn nhận 401, và verify() vừa viết không bao giờ được gọi tới.
Nối lại cho đúng — thêm endpoint xin URL (đòi xác thực bình thường, vì đây là một lời gọi
fetch từ chính trang, gửi header được) và sửa play() đọc chữ ký từ query thay
vì đọc JWT:
import { Controller, Get, Param, Query, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { MediaService } from './media.service';
import { SignedUrlService } from './signed-url.service';
@Controller('media')
export class StreamController {
constructor(
private readonly media: MediaService,
private readonly signedUrl: SignedUrlService,
) {}
// Doi hoi JwtAuthGuard binh thuong: lay URL nay la mot lenh goi tu chinh
// trang SPA qua fetch/XHR, no gui duoc header Authorization.
@Get(':id/signed-url')
@UseGuards(JwtAuthGuard)
async getSignedUrl(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ url: string }> {
// Kiem quyen xem NGAY tai day — phat URL cho video khong thuoc ve nguoi
// goi thi coi nhu tim khong thay, giong het play() ben duoi.
await this.media.findPlayable(id, user.sub);
return { url: this.signedUrl.sign(id, user.sub) };
}
// KHONG dung JwtAuthGuard — day chinh la route ma URL co chu ky ton tai de
// phuc vu, vi the <video src="..."> khong gui duoc header Authorization.
@Get(':id/play')
async play(
@Param('id') id: string,
// Khong co ValidationPipe toan cuc — @Query() khong dam bao gia tri ton
// tai luc runtime du kieu khai bao la string. Kiem tra tay truoc khi dung.
@Query('u') userId: string | undefined,
@Query('e') expires: string | undefined,
@Query('s') signature: string | undefined,
@Res() response: Response,
): Promise<void> {
if (
userId === undefined ||
expires === undefined ||
signature === undefined ||
!this.signedUrl.verify(id, userId, Number(expires), signature)
) {
throw new UnauthorizedException('URL het han hoac khong hop le');
}
// Nest van lam phan kho: kiem tra quyen, han muc, trang thai video.
const asset = await this.media.findPlayable(id, userId);
response.setHeader('X-Accel-Redirect', `/protected-media/${asset.storageKey}`);
response.setHeader('Content-Type', 'video/mp4');
response.end(); // Body rong. nginx thay header tren va tu gui file.
}
}
Client gọi GET /media/:id/signed-url có xác thực bình thường để xin URL, rồi gắn URL đó vào
thẻ <video>. URL sống 5 phút — đủ để bắt đầu phát, quá ngắn để chia sẻ lại có ý nghĩa.
Đây cũng chính là cơ chế mà các dịch vụ lưu trữ đám mây dùng cho link tải tạm thời.
// Doc lap voi JWT_SECRET vi khac vong doi: URL nay het han sau vai phut,
// khong phai vai phut sau khi dang xuat.
SIGNED_URL_SECRET: z.string().min(32),
Biến này không có .default(), đúng như JWT_SECRET ở mục 2 — nên
thiếu nó là ứng dụng từ chối khởi động ngay:
SIGNED_URL_SECRET: Invalid input: expected string, received undefined. Sinh một khoá nữa,
khác khoá JWT:
echo "SIGNED_URL_SECRET=$(openssl rand -hex 32)" >> .env
echo "SIGNED_URL_SECRET=thay-bang-chuoi-ngau-nhien-tu-openssl-rand-hex-32" >> .env.example
Và mảnh cuối cùng, lại là chỗ dễ quên nhất: MediaModule ở mục 6 chưa biết gì về hai class vừa
viết. Không thêm vào thì hai route của cả mục 7 đơn giản là không tồn tại — ứng dụng vẫn khởi động bình
thường, chỉ có /media/:id/play trả 404.
@Module({
imports: [TypeOrmModule.forFeature([Video]), AuthModule],
controllers: [UploadController, StreamController],
providers: [MediaService, SignedUrlService],
exports: [MediaService],
})
export class MediaModule {}
VID=<videoId tra ve tu buoc upload o muc 6>
# 1. Xin URL co chu ky (co Authorization, nhu mot lenh fetch tu trang)
URL=$(curl -sk --resolve media-forge.local:8443:127.0.0.1 \
"https://media-forge.local:8443/media/$VID/signed-url" \
-H "Authorization: Bearer $TOK" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["url"])')
# 2. Phat bang URL do, KHONG kem Authorization — dung nhu the <video> goi
curl -sk --resolve media-forge.local:8443:127.0.0.1 -D- -o /dev/null \
"https://media-forge.local:8443$URL"
# -> 200, content-length: 26214400, accept-ranges: bytes
# 3. Tua: xin dung 1024 byte tu giua file
curl -sk --resolve media-forge.local:8443:127.0.0.1 -D- -o /dev/null \
-H 'Range: bytes=10485760-10486783' "https://media-forge.local:8443$URL"
# -> 206, content-range: bytes 10485760-10486783/26214400, content-length: 1024
# 4. Go thang vung noi bo tu ngoai vao
curl -sk --resolve media-forge.local:8443:127.0.0.1 -o /dev/null -w '%{http_code}\n' \
"https://media-forge.local:8443/protected-media/$VID.bin"
# -> 404 (chi thi `internal` lam dung viec cua no)
# 5. Sua mot ky tu cuoi cua chu ky
curl -sk --resolve media-forge.local:8443:127.0.0.1 -o /dev/null -w '%{http_code}\n' \
"https://media-forge.local:8443${URL%?}X"
# -> 401
Phép thử số 3 là cái đáng nhìn kỹ nhất: 206 và Content-Range đúng từng byte,
trong khi trong stream.controller.ts bản cuối không còn một dòng nào xử lý
Range. Toàn bộ đoạn tính start/end/416 viết tay ở đầu
mục 7 đã bị xoá, và nginx làm lại phần đó đầy đủ hơn. Đó chính là điều mục 7.1 hứa, đo được bằng đúng một
lệnh curl.
8. Giao diện: hai mốc để nhìn thấy thứ vừa dựng
Backend giờ có xác thực và luồng media hoàn chỉnh, nhưng vẫn chưa nhìn được bằng mắt. Giao diện là một dự án React + TypeScript riêng, dùng Tailwind, gọi sang API qua nginx.
npm create vite@latest media-forge-web -- --template react-ts
cd media-forge-web
npm i
npm i -D tailwindcss @tailwindcss/vite
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
@import "tailwindcss";
Không cần tailwind.config.js: @tailwindcss/vite (Tailwind v4) không bắt buộc
file cấu hình riêng cho trường hợp mặc định này.
8.1. Mốc #1 — đăng nhập và tự làm mới token
Phần đáng nói không phải cái form, mà là lớp bọc quanh fetch. Nó phải nhận ra
401, gọi refresh, rồi thử lại request cũ — và tuyệt đối không được để nhiều request cùng gặp
401 kích hoạt nhiều lần refresh song song. Đó chính là tình huống đua ở mục 3.3, nhìn từ phía client.
let accessToken: string | null = null;
// Bien then chot: MOT promise refresh dung chung cho ca ung dung.
let refreshing: Promise<void> | null = null;
async function refreshOnce(): Promise<void> {
// Da co ai dang refresh thi cho ket qua cua ho, khong goi them lan nua.
refreshing ??= (async () => {
const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error('Phien dang nhap het hieu luc');
const data = (await res.json()) as { accessToken: string };
accessToken = data.accessToken;
})().finally(() => {
refreshing = null;
});
return refreshing;
}
export async function api(path: string, init: RequestInit = {}): Promise<Response> {
const call = (): Promise<Response> =>
fetch(path, {
...init,
credentials: 'include',
headers: {
...init.headers,
...(accessToken === null ? {} : { Authorization: `Bearer ${accessToken}` }),
},
});
const first = await call();
if (first.status !== 401) return first;
await refreshOnce();
return call(); // thu lai dung mot lan
}
// Dang nhap: doi email/mat khau lay access token, va giu no trong bien tren.
// Refresh token khong di qua day — server dat no vao cookie httpOnly.
export async function login(email: string, password: string): Promise<void> {
const res = await fetch('/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Email hoac mat khau khong dung');
const data = (await res.json()) as { accessToken: string };
accessToken = data.accessToken;
}
export function isLoggedIn(): boolean {
return accessToken !== null;
}
Bốn dòng quanh biến refreshing là toàn bộ giá trị của file này. Mười request cùng nhận 401
thì cả mười cùng chờ một lời gọi refresh, và cùng thử lại sau khi nó xong. Bỏ chúng đi thì bạn
bắn mười refresh song song với cùng một token — đúng cái mà server ở mục 3.3 phải dựng khoảng ân hạn để
tha thứ.
Chú ý accessToken là biến trong module, không phải localStorage. Mất tab là mất
nó — và không sao, vì refresh token nằm trong cookie httpOnly nên lần gọi
api() đầu tiên sau khi tải lại trang sẽ nhận 401, tự refresh, rồi chạy tiếp. Đổi lại, một
script chèn được vào trang cũng không đọc được token bằng JavaScript.
Giờ mới tới cái form. Nó không có gì đặc biệt, và đó là chủ ý — mọi thứ khó đã nằm ở trên:
import { useState, type FormEvent } from 'react';
import { login } from '../lib/api';
export function LoginForm({ onDone }: { onDone: () => void }): JSX.Element {
const [email, setEmail] = useState('demo@test.local');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(event: FormEvent): Promise<void> {
event.preventDefault(); // khong de trinh duyet tu nap lai trang
setBusy(true);
setError(null);
try {
await login(email, password);
onDone();
} catch (err) {
// useUnknownInCatchVariables (Part 1 muc 5) buoc thu hep kieu o day
setError(err instanceof Error ? err.message : 'Dang nhap that bai');
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="mx-auto mt-24 w-80 space-y-3">
<h1 className="text-lg font-semibold">Đăng nhập</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mật khẩu"
className="w-full rounded border border-slate-300 px-3 py-2"
/>
{error !== null && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded bg-slate-900 py-2 text-white disabled:opacity-50"
>
{busy ? 'Đang gửi…' : 'Đăng nhập'}
</button>
</form>
);
}
Còn một mảnh nữa mà thiếu nó thì cả ba file trên chỉ nằm im trên đĩa: một chỗ ráp chúng lại. Vite sinh sẵn
src/App.tsx để in trang mẫu — thay toàn bộ nội dung của nó:
import { useState } from 'react';
import { LoginForm } from './components/LoginForm';
export default function App(): JSX.Element {
const [ready, setReady] = useState(false);
if (!ready) return <LoginForm onDone={() => setReady(true)} />;
// Muc 8.2 se thay dong nay bang man upload + trinh phat.
return <p className="mt-24 text-center">Đã đăng nhập.</p>;
}
vite.config.ts cần chuyển tiếp lời gọi API sang đó,
nếu không trình duyệt sẽ chặn vì khác origin: server: { proxy: { '/auth': 'http://localhost:3000', '/media': 'http://localhost:3000' } }
Chạy
npm run dev, mở http://localhost:5173: phải thấy form đăng nhập. Nhập sai
mật khẩu thì hiện đúng thông báo màu đỏ; nhập đúng thì đổi sang "Đã đăng nhập." Chưa thấy chữ nào của
trang mẫu Vite nữa là đúng.
8.2. Mốc #2 — upload có thanh tiến trình và trình phát
Chỗ này có một bất ngờ nhỏ: fetch hiện đại vẫn không báo được tiến trình
upload. Nó theo dõi được luồng tải xuống, nhưng không theo dõi được luồng gửi lên. Muốn có phần trăm thật
thì phải quay lại XMLHttpRequest — một trong số ít trường hợp API cũ vẫn còn là lựa chọn
đúng.
export function uploadWithProgress(
file: File,
token: string,
onProgress: (percent: number) => void,
): Promise<{ videoId: string }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/media/upload');
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
// Ten file goc di kem qua header rieng, encode de an toan qua HTTP header
// (xem src/media/upload.controller.ts o muc 6, noi header nay duoc doc lai).
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
// upload.onprogress — thu ma fetch khong co.
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText) as { videoId: string });
} else {
reject(new Error(`Upload that bai: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Mat ket noi'));
// Gui thang File. Trinh duyet tu doc theo stream, khong nap het vao RAM.
xhr.send(file);
});
}
import { useEffect, useState, type JSX } from 'react';
import { api } from '../lib/api';
export function VideoPlayer({ assetId }: { assetId: string }): JSX.Element {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
// The <video> khong gui duoc header Authorization, nen xin URL co chu ky.
api(`/media/${assetId}/signed-url`)
.then((res) => res.json() as Promise<{ url: string }>)
.then((data) => setSrc(data.url));
}, [assetId]);
if (src === null) return <div className="h-64 animate-pulse rounded-lg bg-slate-200" />;
return <video src={src} controls className="w-full rounded-lg shadow-lg" />;
}
JSX.Element với React 19JSX.Element. Với React 19 và
@types/react 19 — đúng bộ mà npm create vite@latest cài hôm nay — namespace
JSX toàn cục đã bị bỏ, nên thiếu type JSX trong dòng import là
TS2503: Cannot find namespace 'JSX'. Cũng lưu ý: trong dự án Vite react-ts,
tsconfig.json chỉ chứa references, nên
npx tsc --noEmit không kiểm gì cả và luôn im lặng. Lệnh kiểm thật là
npx tsc -b, đúng lệnh mà npm run build gọi.
Còn hai mảnh nữa trước khi nhìn thấy được thứ này. Thứ nhất:
uploadWithProgress đòi một token, mà accessToken ở mục 8.1 là biến
riêng của module — XMLHttpRequest không đi qua api() nên phải lấy token ra bằng
một hàm nhỏ:
// upload.ts can token de dat header Authorization tren XMLHttpRequest —
// XHR khong di qua api() nen phai lay token ra day.
export function getAccessToken(): string | null {
return accessToken;
}
Thứ hai: một màn hình ráp ô chọn file, thanh tiến trình và trình phát lại với nhau.
import { useState, type ChangeEvent, type JSX } from 'react';
import { getAccessToken } from '../lib/api';
import { uploadWithProgress } from '../lib/upload';
import { VideoPlayer } from './VideoPlayer';
export function UploadScreen(): JSX.Element {
const [percent, setPercent] = useState<number | null>(null);
const [videoId, setVideoId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleFile(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0];
const token = getAccessToken();
if (file === undefined || token === null) return;
setError(null);
setVideoId(null);
setPercent(0);
try {
const result = await uploadWithProgress(file, token, setPercent);
setVideoId(result.videoId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload that bai');
} finally {
setPercent(null);
}
}
return (
<div className="mx-auto mt-16 w-full max-w-xl space-y-4 px-4">
<h1 className="text-lg font-semibold">Tải video lên</h1>
<input type="file" accept="video/*" onChange={handleFile} className="w-full" />
{percent !== null && (
<div className="h-2 w-full overflow-hidden rounded bg-slate-200">
<div className="h-full bg-slate-900 transition-all" style={{ width: `${percent}%` }} />
</div>
)}
{error !== null && <p className="text-sm text-red-600">{error}</p>}
{videoId !== null && (
<div className="space-y-2">
<p className="text-sm text-slate-600">Xong. videoId: {videoId}</p>
<VideoPlayer assetId={videoId} />
</div>
)}
</div>
);
}
import { useState, type JSX } from 'react';
import { LoginForm } from './components/LoginForm';
import { UploadScreen } from './components/UploadScreen';
export default function App(): JSX.Element {
const [ready, setReady] = useState(false);
if (!ready) return <LoginForm onDone={() => setReady(true)} />;
return <UploadScreen />;
}
/media/:id/play trả về body rỗng kèm header X-Accel-Redirect,
và chỉ nginx mới hiểu header đó. Trỏ proxy của Vite thẳng vào Node cổng 3000 thì trình
duyệt nhận 200 với 0 byte, header x-accel-redirect lọt nguyên
ra ngoài, và thẻ <video> chết với DEMUXER_ERROR_COULD_NOT_OPEN.
Triệu chứng rất dễ đánh lừa: trình phát vẫn hiện đủ nút, chỉ là
0:00 và bấm không lên — đúng cái bẫy mà chính mục 7 mở đầu đã cảnh báo.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
const gateway = {
target: 'https://127.0.0.1:8443',
secure: false, // chung chi tu ky o muc 4
// nginx khop server_name media-forge.local — dat Host o day de khoi
// phai them mot dong vao /etc/hosts.
headers: { Host: 'media-forge.local' },
};
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/auth': gateway,
'/media': gateway,
},
},
});
Giờ thì chạy được thật: npm run dev, mở http://localhost:5173, đăng nhập bằng
demo@test.local, chọn một file video. Thanh tiến trình chạy từ 0 tới 100, rồi trình phát hiện
ra ngay bên dưới với đúng độ dài của video vừa tải lên.
Kéo thanh tiến trình của trình phát này, mở tab Network của trình duyệt: bạn sẽ thấy đúng những request
206 đã mô tả ở mục 7 — và chúng do nginx trả lời, không phải Node. (Các request đó thường kèm
trạng thái ERR_ABORTED, và đó là bình thường: trình duyệt xin một khoảng byte rồi tự huỷ khi
bạn kéo tiếp sang chỗ khác.)
Và mỗi lần, lời giải đều thuộc một trong ba loại: khoá lại (
pessimistic_write), làm cho
phép tính trở thành nguyên tử (script Lua), hoặc gộp nhiều lời gọi thành một (biến
refreshing). Nhận ra được hình dạng chung này thì lần sau bạn không phải khám phá lại từ
đầu.
child_process, đẩy việc nặng sang worker_threads, nhân bản tiến trình
bằng cluster với nginx cân tải phía trước, dựng hàng đợi để một job hỏng không kéo cả hệ
thống, và đẩy tiến độ về trình duyệt theo thời gian thực qua WebSocket.
Bình luận