GĐ07 — NestJS: DI, guards/pipes/interceptors, auth đầy đủ (Dự án 3: portfolio)
Ghi chú học cho FE (JS/TS mạnh) chuyển sang BE. Mỗi khái niệm: định nghĩa → tại sao quan trọng → cơ chế → ví dụ code ngắn → pitfall thực tế. Tư duy chính cần "unlearn" từ FE: ở BE bạn không render UI, bạn thiết kế contract (request → validate → business logic → response), và mọi thứ đều là dependency được inject, không phải
importtrực tiếp.
Kiểm chứng ngày 2026-10-05: NestJS hiện là major 12 (ra 2026-08-27; chạy thử với
@nestjs/common12.1.2): ESM-only, cần Node 20.19+/22.12+/24+, test runner mặc định là Vitest (snippet test bên dưới viết bằng Jest; với Vitest đổijest.fn()thànhvi.fn()). Nest không có@SkipInterceptor(đã tìm trong@nestjs/commonvà@nestjs/core12.1.2): dùngSetMetadata+Reflector(mục 9). Refresh token rotation cầnjti(mục 11).NODE_ENV∈development/test/production(mục 12).
Kiểm chứng ngày 2026-10-05 (bổ sung): các đoạn code mới về guard toàn cục,
@CurrentUser, filter,timeout,JwtModule.registerAsync, Throttler, vòng đời, cảnh báo DLQ và OAuth (lõi đăng nhập dùng provider giả) đã biên dịch bằngtsc --stricttrên Nest 12.1.2,@nestjs/throttler6.7.1, BullMQ 6.3.11,passport-oauth21.8.0 vàpassport-google-oauth202.0.0, rồi chạy qua supertest trong thư mục tạm; kết quả nằm ở từng mục. Nguồn: Nest rate limiting, Nest performance, RFC 9700 (mục 2.1.1, 4.7.1), OIDC Core, Google OpenID Connect, GitHub: authorizing OAuth apps, GitHub: user emails. Chưa xác minh: không gọi Google/GitHub thật (phần đổicodelấy danh tính chỉ quatsc, chưa chạy); trang web-server của Google không nhắc PKCE nên việc Google nhậncode_challengeở luồng này chưa được xác minh; gói@nestjs/authentication(bản 0.0.1 trên npm, được trang Nest authentication mô tả) chưa chạy thử nên bài này giữ Passport; Fastify adapter chưa tự đo.
1. NestJS là gì — vì sao dùng thay Express thuần#
Định nghĩa. NestJS là một framework Node.js (TypeScript-first) để xây server-side app. Nó opinionated: áp một kiến trúc chuẩn (module + controller + provider) lấy cảm hứng từ Angular. Nest không tự viết HTTP server — nó chạy trên một adapter: mặc định là Express, có thể đổi sang Fastify (tài liệu Nest nói gần gấp đôi trong benchmark của họ; chưa tự đo, phụ thuộc workload).
Tại sao quan trọng. Express thuần cho bạn tự do tuyệt đối → mỗi team tự bịa cấu trúc thư mục, cách wire dependency, cách validate. Sau 6 tháng codebase thành mì spaghetti. Nest ép một khung xương chung: người mới đọc repo Nest nào cũng biết logic nằm ở đâu. So sánh với FE: Express = React thuần với useState khắp nơi; Nest = Next.js có convention rõ ràng.
Cơ chế.
- Nest build một application graph lúc bootstrap: quét decorators (
@Module,@Injectable,@Controller), dựng IoC container, resolve toàn bộ dependency, rồi mount route lên adapter (Express/Fastify). - Request đi qua pipeline:
Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Exception filters. - TS-first: dùng decorators (
experimentalDecorators) + metadata reflection (reflect-metadata) để đọc type ở runtime → nền tảng của DI và validation.
Ví dụ code.
Pitfall thực tế. Nhiều FE nghĩ "Nest nặng, dùng Express cho nhanh". Đúng cho script 1 file, nhưng app thật (auth, DB, nhiều team) thì Nest tiết kiệm hơn nhiều. Ngược lại: đừng chọn Fastify adapter chỉ vì "nhanh" nếu bạn phụ thuộc middleware Express (một số lib chỉ support Express) — kiểm tra tương thích trước.
Từ Express sang Nest: bảng chuyển đổi#
Bạn đã dựng Express ở GĐ04; mỗi thứ ở đó có chỗ đặt riêng trong Nest.
| Express (GĐ04) | Nest | Ghi chú |
|---|---|---|
router.get(...) + handler | @Controller + @Get | controller chỉ nhận request, gọi service |
app.use(fn) toàn cục | NestMiddleware + configure(consumer) | chạy trước guard |
middleware requireAuth | Guard (CanActivate) | trả false ra 403, ném UnauthorizedException ra 401 |
req.user = ... rồi đọc req.user | validate() của strategy + @CurrentUser() | mục 8 |
| validate bằng Zod trong handler | ValidationPipe + DTO | mục 6, 7 |
| error middleware 4 tham số | ExceptionFilter | mục 10 |
res.json(wrap(data)) ở từng route | Interceptor | mục 9 |
new Service(deps) tự tay ở app.ts | DI container | mục 4, 5 |
Middleware và vòng đời module#
Thứ tự chạy mỗi request đã đo trên Nest 12.1.2: middleware, guard, interceptor (trước), handler. Pipe chỉ chạy cho tham số của handler, nên handler không có tham số thì không có gì để pipe xử lý.
Provider có thể khai báo hook vòng đời. Thứ tự đã đo: onModuleInit, onApplicationBootstrap lúc khởi động; khi app.close() hoặc nhận SIGTERM (cần app.enableShutdownHooks()): onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown. Mở kết nối ở onModuleInit, đóng ở onModuleDestroy hoặc onApplicationShutdown. Đã chạy (tsc --strict, supertest).
2. Module — @Module#
Định nghĩa. Module là đơn vị đóng gói một domain/feature. Mỗi Nest app có ít nhất một root module (AppModule); các feature (users, auth, payment...) thành feature module riêng. @Module() nhận 4 mảng:
controllers: khai báo controller thuộc module.providers: service/repository... module này tự tạo & dùng nội bộ.imports: module khác mà module này cần (để dùng provider chúng export).exports: provider mà module này cho phép module khác dùng.
Tại sao quan trọng. Module định nghĩa ranh giới encapsulation của DI. Một provider không tự động dùng được ở mọi nơi — nó chỉ khả dụng trong module khai báo, trừ khi được export và module kia import. Đây là cơ chế chống "mọi thứ nối với mọi thứ".
Cơ chế. Khi bootstrap, Nest duyệt cây module bắt đầu từ root, dựng scope DI cho từng module. Provider trong exports được "nâng" lên để module import thấy được. Import mang tính transitive qua re-export chứ không tự lan: A export X, B import A và muốn cho C dùng X thì B phải re-export A/X.
Ví dụ code.
Pitfall thực tế. Lỗi kinh điển: Nest can't resolve dependencies of AuthService (?). Please make sure UsersService is available... → nguyên nhân 90% là quên exports: [UsersService] trong UsersModule, hoặc quên imports: [UsersModule]. Đừng "sửa" bằng cách khai báo UsersService lại trong providers của AuthModule — làm vậy sẽ tạo 2 instance khác nhau, state không share.
3. Controller — routing layer#
Định nghĩa. Controller nhận HTTP request và trả response. @Controller('users') gắn prefix path; các method decorator (@Get, @Post, @Patch, @Delete) map HTTP verb + sub-path. Param decorators trích dữ liệu: @Param, @Query, @Body, @Headers, @Req.
Tại sao quan trọng. Controller là lớp mỏng: chỉ nhận input, gọi service, trả kết quả. Nó là "adapter" giữa HTTP và business logic. Giữ nó mỏng giúp business logic tái dùng được (từ CLI, cron, queue... không chỉ HTTP).
Cơ chế. Nest đọc metadata từ decorator để build routing table lúc bootstrap. Return value của handler tự động được serialize thành JSON + status 200 (201 cho @Post). Trả Promise/Observable cũng được — Nest tự await.
Ví dụ code.
Pitfall thực tế. Nhồi business logic (query DB, gọi API ngoài) thẳng vào controller → không test được, không tái dùng. Thứ hai: thứ tự route matter — @Get('me') phải đặt trước @Get(':id'), nếu không me bị nuốt bởi param :id.
4. Provider & Service#
Định nghĩa. Provider là bất cứ class nào Nest có thể inject (@Injectable()): service, repository, factory, helper, thậm chí một value. Service là loại provider phổ biến nhất — nơi chứa business logic.
Tại sao quan trọng. Tách logic khỏi controller cho ba lợi ích: (1) test độc lập không cần HTTP; (2) tái dùng logic ở nhiều controller/queue/cron; (3) một chỗ duy nhất để sửa rule nghiệp vụ. Đây là "Single Responsibility" ở tầng kiến trúc.
Cơ chế. @Injectable() đánh dấu class là provider để IoC container quản lý. Khai báo nó trong providers của module → container tạo một instance (singleton) và inject vào bất cứ ai khai báo nó ở constructor.
Ví dụ code.
Pitfall thực tế. Quên @Injectable() trên service → Nest vẫn có thể chạy nếu nó chỉ được inject (decorator chủ yếu cần khi class có dependency), nhưng để nhất quán luôn gắn. Pitfall lớn hơn: God Service — một UsersService 2000 dòng làm cả auth, email, billing. Tách theo domain.
5. Dependency Injection (QUAN TRỌNG — khái niệm mới với FE)#
Định nghĩa. DI là pattern: class không tự tạo dependency của nó (new UserRepository()), mà nhận chúng từ bên ngoài (thường qua constructor). "IoC container" (Inversion of Control) là bộ máy của Nest chịu trách nhiệm tạo và cung cấp các instance đó.
Tại sao quan trọng (đọc kỹ nếu từ FE). Ở FE bạn hầu như luôn import { api } from './api' rồi gọi trực tiếp — dependency bị hard-code. Khi test, bạn phải hack module mock. Với DI:
- Test/mock dễ: trong test bạn inject một fake repo, không đụng DB thật.
- Đổi implementation không sửa consumer: đổi
EmailServicetừ SendGrid sang SES chỉ cần đổi provider binding, code service dùng nó không đổi. - Lifecycle tập trung: container quản lý singleton, tránh tạo trùng kết nối DB.
Cơ chế.
- Constructor injection: Nest đọc type của tham số constructor qua
reflect-metadata. - Provider token: mỗi provider có một "token" — mặc định chính là class. Container tra token → trả instance. Token cũng có thể là string/symbol (dùng
@Inject('TOKEN')). - Scope: mặc định SINGLETON (một instance dùng chung toàn app). Còn
REQUEST(mỗi request một instance — chậm hơn, dùng khi cần state theo request như tenant/user hiện tại) vàTRANSIENT(mỗi lần inject một instance mới).
Ví dụ code.
Pitfall thực tế.
- Circular dependency: A cần B, B cần A → dùng
forwardRef(() => B). Tốt hơn là refactor tách phần chung ra module thứ ba. - Inject provider REQUEST-scoped vào một singleton sẽ "nâng" cả chuỗi lên request-scoped (bubbling) → tụt performance mà không nhận ra.
- FE hay dùng
new Service()trong constructor theo thói quen — làm vậy phá DI, container không quản lý được, mất test-ability.
6. DTO + validation#
Định nghĩa. DTO (Data Transfer Object) là class mô tả shape của dữ liệu vào/ra. Kết hợp class-validator (decorator validate) + class-transformer (chuyển plain object → instance class) + ValidationPipe để tự động kiểm tra body/query.
Tại sao quan trọng. "Never trust the client." Request là dữ liệu ngoài tầm kiểm soát → phải validate ở boundary. DTO cho bạn một nguồn sự thật vừa là type TS (compile-time) vừa là rule runtime. FE quen zod — đây là tương đương ở Nest (class-based).
Cơ chế. ValidationPipe (bật global) chạy trước handler: dùng class-transformer biến @Body() thành instance của DTO, rồi class-validator chạy các decorator. Fail → tự ném 400 Bad Request kèm chi tiết lỗi.
whitelist: true: strip field không khai báo trong DTO (chống mass-assignment).forbidNonWhitelisted: true: có field thừa thì báo lỗi 400 thay vì strip.transform: true: cho phép ép kiểu (vd"42"→42theo type param).
Ví dụ code.
Pitfall thực tế.
- Quên
transform: true→@Query('limit')vẫn là string, so sánh số sai âm thầm. - Nested object cần
@ValidateNested()và@Type(() => ChildDto)(class-transformer), nếu không validation nested bị bỏ qua. - Không bật
whitelist→ client gửi thêm{ isAdmin: true }và nếu bạn spread thẳng vào DB → privilege escalation.
7. Pipes#
Định nghĩa. Pipe là class biến đổi hoặc validate input của handler ngay trước khi handler chạy. Hai nhiệm vụ: transform (đổi kiểu/format) và validation (chặn input xấu). ValidationPipe ở mục 6 chính là một pipe.
Tại sao quan trọng. Đẩy việc parse/validate ra khỏi handler → handler chỉ nhận dữ liệu đã sạch, đúng kiểu. Tái dùng logic validate qua nhiều route.
Cơ chế. Pipe implement PipeTransform, có method transform(value, metadata). Return value được truyền vào handler; throw → request bị chặn (thường 400). Áp ở cấp param, handler, controller, hoặc global.
Ví dụ code.
Pitfall thực tế. ParseUUIDPipe ném 400 khi param không phải UUID (/users/abc; đã chạy trên Nest 12) — đúng ý muốn, nhưng nếu bạn muốn 404 thì phải xử lý khác. UUID hợp lệ nhưng không tồn tại mới đi tiếp tới service và ra 404. Custom pipe đừng nhét business logic (query DB) vào — pipe nên thuần transform/validate; truy vấn DB để kiểm tra tồn tại nên ở service/guard.
8. Guards — authorization (RBAC)#
Định nghĩa. Guard quyết định request có được phép vào handler hay không, trả true/false (hoặc Promise/Observable của boolean). Dùng cho authentication (đã login chưa) và authorization (có quyền không).
Tại sao quan trọng. Tách "ai được làm gì" khỏi business logic. Không phải rải if (!user) throw... trong mỗi handler. Guard chạy trước interceptor & pipe → chặn sớm, tiết kiệm.
Cơ chế. Guard implement CanActivate với canActivate(context: ExecutionContext). ExecutionContext cho phép lấy request. Kết hợp custom decorator (@Roles) + Reflector (đọc metadata gắn trên handler) để làm RBAC (Role-Based Access Control).
Ví dụ code.
Pitfall thực tế.
- Thứ tự guard:
AuthGuardphải chạy trướcRolesGuard(RolesGuard cầnreq.userdo AuthGuard gắn).@UseGuardschạy theo thứ tự khai báo. reflector.getchỉ đọc metadata trên handler; nếu bạn@Rolesở cấp controller, phải dùnggetAllAndOverride([handler, class])để không sót.- Guard trả
false→ Nest ném 403 Forbidden; nếu muốn 401 khi chưa login, để AuthGuard tự ném 401.
Guard toàn cục, @Public() và @CurrentUser#
Gắn @UseGuards từng route dễ quên một route. Cách an toàn hơn: bật guard đăng nhập cho mọi route (APP_GUARD) và đánh dấu ngoại lệ bằng @Public().
Đã chạy (Nest 12.1.2, tsc --strict, supertest): /health ra 200; /me không token 401, có token 200 và @CurrentUser('id') trả u1; DELETE bằng token user 403, token admin 200, không token 401. Khai báo RolesGuard trước JwtAuthGuard: DELETE không token ra 403 thay vì 401, vì RolesGuard chạy khi chưa có req.user. Với @UseGuards(...) và APP_GUARD, thứ tự quyết định status code, nên ghi nó vào test.
9. Interceptors#
Định nghĩa. Interceptor bọc quanh handler: chạy trước và sau khi handler thực thi. Dùng để: transform response, logging, đo thời gian, cache, timeout, map exception. Dựa trên RxJS (Observable).
Tại sao quan trọng. Cross-cutting concern (áp cho mọi route) không nên copy-paste. Ví dụ: bọc mọi response thành { data, timestamp } — làm một lần bằng interceptor global.
Cơ chế. Implement NestInterceptor với intercept(ctx, next). next.handle() trả Observable của kết quả handler. Bạn dùng RxJS operator (map, tap, timeout, catchError) để can thiệp luồng post-handler.
Ví dụ code.
Pitfall thực tế. Wrap response thành { data: ... } global sẽ phá contract những endpoint đã publish (FE đang đọc field cũ) — thống nhất format sớm hoặc đánh dấu route ngoại lệ bằng metadata (xem dưới). Nest không có decorator dựng sẵn kiểu @SkipInterceptor: tự viết bằng SetMetadata và đọc cờ bằng Reflector. timeout() chỉ hủy Observable phía Nest, không hủy query DB đang chạy → cần cấu hình timeout ở tầng DB nữa. Không có catchError, TimeoutError của RxJS không phải HttpException nên filter trả 500; đã chạy với handler ngủ 300 ms và timeout(50): có catchError ra 408.
Đã chạy trên Nest 12.1.2 (interceptor đăng ký bằng APP_INTERCEPTOR): /wrapped trả {"data":{...}}, route gắn @SkipTransform() trả nguyên.
10. Exception filters#
Định nghĩa. Filter bắt exception ném ra từ handler/guard/pipe và biến thành HTTP response chuẩn hoá. Nest có sẵn HttpException và các subclass (NotFoundException, BadRequestException, UnauthorizedException...).
Tại sao quan trọng. Cần một error contract nhất quán để FE parse. Không để lộ stack trace/thông tin DB ra client. Một chỗ tập trung để log + format lỗi.
Cơ chế. Ném HttpException(message, status) → default filter trả JSON { statusCode, message }. Custom filter implement ExceptionFilter + @Catch(...), truy cập response để tự định dạng.
Ví dụ code.
Pitfall thực tế. Bắt hết bằng @Catch() mà quên log lỗi 500 → mất dấu bug production. Đừng trả err.message của lỗi không phải HttpException ra client (có thể lộ path DB, SQL). Với lỗi Prisma/TypeORM (vd unique constraint) nên map sang ConflictException trong service, đừng để rơi xuống 500. Đã chạy (Nest 12.1.2, bản filter dùng HttpAdapterHost): handler ném new Error('select * from users') ra 500, body không chứa chữ select, log server có stack.
11. Auth đầy đủ — JWT access + refresh, hashing#
Định nghĩa. Xác thực (authentication) + phát hành JWT access token (ngắn hạn) và refresh token (dài hạn), có rotation, mật khẩu hash bằng argon2/bcrypt. Dùng Passport (thư viện auth chuẩn Node) qua @nestjs/passport.
Tại sao quan trọng. Đây là "cửa vào" của app — sai sót = rò rỉ tài khoản. Access token ngắn hạn giảm thiệt hại khi bị lộ; refresh token cho UX không phải login lại liên tục; hashing bảo vệ password khi DB bị dump.
Cơ chế.
- Password hashing: KHÔNG bao giờ lưu plaintext.
argon2(khuyến nghị hiện đại, chống GPU) hoặcbcrypt. Hash có salt tự sinh; verify bằng hàm compare (constant-time). - Login: verify password → phát
accessToken(~15m) +refreshToken(~7d, ký secret khác). - Passport strategy:
JwtStrategyextract token từAuthorization: Bearer, verify chữ ký,validate()trả vềuser→ Nest gắn vàoreq.user. - Refresh rotation: mỗi lần dùng refresh token → cấp token mới và vô hiệu token cũ. Phát hiện tái dùng token đã dùng = dấu hiệu bị đánh cắp → thu hồi cả family (mọi refresh token sinh ra từ cùng một lần login). Việc "đánh dấu token cũ đã dùng" phải là một câu lệnh nguyên tử (xem code), không phải "đọc
usedAtrồi mới ghi": hai request song song cùng một token đều thấyusedAt = nullvà cả hai thành công. - Mỗi refresh token cần
jti(JWT ID, ngẫu nhiên): hai token cùng{ sub }ký trong cùng một giây ra chuỗi giống hệt nhau (đã chạy:a === blàtrue), nên không cójtithì "token mới" không khác "token cũ" và không có khoá để tra session. - Lưu refresh token ở đâu: một hàng cho mỗi session trong bảng (vd
refresh_session:jti,user_id,family_id,token_hash,used_at) cùng một hàng cho mỗi family (refresh_family:id,revoked_at) giữ cờ thu hồi, không phải một cộthashedRttrên user (cột đó chỉ chứa được một thiết bị: đăng nhập máy thứ hai ghi đè máy đầu). Lưu hash; refresh token là chuỗi ký có entropy cao nên SHA-256 là đủ, slow hash (argon2) dành cho password. Client giữ refresh token trong httpOnly secure cookie (chống XSS đọc), không lưu localStorage.
Ví dụ code.
(Cần app.use(cookieParser()) trong main.ts. Logout = revokeFamily của session hiện tại và xoá cookie.)
Vì sao cờ thu hồi nằm ở hàng family. Nếu revokeFamily chỉ UPDATE refresh_session … WHERE family_id = $1, nó chỉ thấy những hàng đã commit lúc câu lệnh bắt đầu. Kẻ tấn công rotate T2 → T3 (transaction còn mở) đúng lúc nạn nhân replay T1: revokeFamily chờ khoá hàng T2, thu hồi T2, nhưng không thấy T3 (commit sau khi câu lệnh bắt đầu), nên T3 vẫn dùng được và bỏ qua phát hiện đánh cắp. Với cờ ở hàng family, rotate giữ FOR SHARE trên hàng đó, revokeFamily (cần khoá ghi) phải chờ rotate commit rồi đặt cờ; mọi token trong family, kể cả T3, chết cùng lúc vì tính hợp lệ được suy ra từ cờ chung.
Đã chạy. Luồng tuần tự login → refresh → refresh → replay token cũ chạy trên Nest 12.1.2 (kho session in-memory). Các phép thử race chạy trên PostgreSQL 17 với pg (bảng thử không có cột hash):
- Kiểu "đọc
usedAtrồi ghi", hai request song song cùng token:[200, 200], family còn 2 token sống, không phát hiện reuse. - Cờ thu hồi trên từng hàng session, dựng đúng kịch bản xen kẽ ở trên (giữ transaction của kẻ tấn công mở bằng hai connection):
T1,T2bị thu hồi,T3used=false revoked=false, và dùngT3vẫn ra200. Đây là lỗi. - Cờ ở hàng family (mã ở trên), cùng kịch bản xen kẽ:
revokeFamilythực sự bị chặn chờ khoá (pg_stat_activitythấy 1 phiên chờLock), sau khi kẻ tấn công commit thì family bị thu hồi và dùngT3ra401. - Cờ ở hàng family, 3 request song song cùng một token, lặp 300 lần: cả 300 lần ra đúng một
200và hai401 reuse, không còn token sống nào sau mỗi vòng. Hai401về nguyên tắc có thể là "reuse" hoặc "đã thu hồi" (request đến muộn thấy cờ family đã đặt ở bước kiểm trước transaction); các lần đo không gặp trường hợp thứ hai, nhưng đừng viết test khẳng định đúng dạng401. - Tuần tự: refresh
200, replay401 reuse, thử tiếp401.
Đánh đổi: cả family bị thu hồi, nên token mới của bên thắng cũng chết. Hai refresh song song hợp lệ (vd app mobile gửi đôi) không phân biệt được với replay. Muốn khoan dung thì thêm cửa sổ ngay-sau-khi-rotate ngắn; đừng bỏ bước UPDATE nguyên tử.
Sơ đồ: rotation và phát hiện reuse
Mong đợi: refresh 200, refresh lại đúng token cũ 401, và sau đó cả token mới T2 cũng 401
(vì cờ revoked_at ở hàng family). Đã chạy ở vòng kiểm chứng trước (kết quả ở đoạn "Đã chạy" của mục này); sơ đồ chỉ tóm lại luồng.
Pitfall thực tế.
- Ký access và refresh bằng cùng secret → không phân biệt được token loại nào, mở đường lạm dụng. Dùng 2 secret khác nhau.
- Lưu refresh token plaintext trong DB → DB leak = mất hết session. Luôn hash.
- Không rotate refresh → token bị đánh cắp dùng mãi. Rotate + phát hiện reuse.
- Không gắn
jti→ không phân biệt được các refresh token cùng user; chỉ một cộthashedRttrên user → mỗi user một thiết bị. - Rotation kiểu "đọc
usedAtrồi ghi" có race: hai refresh song song cùng token đều thành công. Claim bằng mộtUPDATE … WHERE used_at IS NULLvà kiểm số hàng bị ảnh hưởng. - Thu hồi bằng cách
UPDATEmọi hàng session của family có race với một rotate đang mở: token mới tạo trong transaction đó không bị thu hồi. Đặt cờ ở một hàng family dùng chung và đểrotatekhoá hàng đó. - Để access token quá dài hạn (vd 30 ngày) → mất token = mất tài khoản lâu; giữ ngắn, dựa vào refresh.
- Đưa dữ liệu nhạy cảm vào JWT payload — nhớ JWT chỉ ký, không mã hoá, ai cũng decode đọc được payload.
JwtModule theo cấu hình và giới hạn tốc độ đăng nhập#
JwtModule.register({}) ở trên truyền secret từng lần ký. Nếu muốn secret access đọc từ ConfigService một lần, dùng registerAsync; hai secret khác nhau vẫn truyền riêng khi ký/verify refresh. Đăng nhập là đích dò mật khẩu, nên thêm @nestjs/throttler.
Đã chạy (@nestjs/throttler 6.7.1): route limit: 3 trả 200, 200, 200, 429, 429 cho năm request liên tiếp. Theo tài liệu Nest: sau reverse proxy phải bật trust proxy thì IP mới đúng, và nhiều instance cần storage dùng chung (mặc định là bộ nhớ trong từng process); hai điểm này chưa chạy. Rate limit qua Redis xem GĐ09.
12. Config — @nestjs/config + validate env#
Định nghĩa. @nestjs/config load biến môi trường (.env) và cung cấp ConfigService để đọc có type. Validate env lúc khởi động bằng Joi hoặc Zod.
Tại sao quan trọng. Tách config khỏi code (12-factor). Validate sớm → app fail fast ngay lúc boot nếu thiếu DATABASE_URL, thay vì crash lúc 3h sáng khi request đầu tiên chạm biến undefined.
Cơ chế. ConfigModule.forRoot({ isGlobal, validationSchema }) đọc .env, chạy schema validate; sai → throw ngay, app không start. ConfigService.get('KEY') trả value đã validate.
Ví dụ code.
Pitfall thực tế. Không validate env → typo DATABSE_URL cho undefined âm thầm, DB connect fail mơ hồ. Commit .env lên git = lộ secret (đưa vào .gitignore, dùng .env.example). isGlobal: true để khỏi import ConfigModule ở mọi feature module.
13. Database integration + repository pattern#
Định nghĩa. Tích hợp DB qua ORM: Prisma (schema-first, type-safe, DX tốt) hoặc TypeORM (decorator entity, quen với người từ Java/.NET). Repository pattern: một lớp trung gian đóng gói mọi truy vấn cho một entity, service không viết query trực tiếp.
Tại sao quan trọng. Repository cô lập tầng dữ liệu → đổi ORM/DB dễ hơn, test service bằng cách mock repo, và tránh rải câu query khắp codebase. Prisma cho type an toàn end-to-end (kết quả query có type chuẩn).
Cơ chế. Bọc client DB trong một provider (PrismaService extends PrismaClient, connect trong onModuleInit). Repository là @Injectable() inject PrismaService, expose method domain (findById, create...). Service inject repository.
Ví dụ code.
Pitfall thực tế.
- N+1 query: loop qua danh sách rồi query từng phần tử. Dùng
include/joinhoặc batch. - Quên
onModuleInit/pool config → cạn connection pool dưới tải. - Prisma 7:
PrismaClientbắt buộc nhận driver adapter, nênPrismaServicegọisuper({ adapter })trong constructor và import client từ thư mụcoutputđã generate (đã chạy trên Prisma 7.10.0). Xem hộp phiên bản ở GĐ05. - Với TypeORM,
synchronize: trueở production = tự đổi schema, có thể mất data. Luôn dùng migration ở prod. - Đừng leak type ORM (
Prisma.User) ra tận controller/response nếu chứa field nhạy cảm (passwordHash) — map sang DTO output hoặcselectkhông chứa field đó.@Exclude()chỉ có tác dụng với instance của class và khi đã bậtClassSerializerInterceptor; Prisma trả object thường nên@Exclude()không làm gì (đã chạy trên Nest 12.1.2: object thường rapasswordHashcả khi bật interceptor; instance class không có interceptor cũng rapasswordHash; chỉ instance class cộng interceptor mới loại field).
14. Swagger auto-doc — @nestjs/swagger#
Định nghĩa. Sinh tài liệu OpenAPI (Swagger UI) tự động từ decorator. @nestjs/swagger đọc DTO, controller, response type để dựng spec + trang UI tương tác.
Tại sao quan trọng. API doc luôn đồng bộ với code (sinh từ chính DTO/controller) → FE và team ngoài tự thử API, giảm hỏi đáp. Đây là "hợp đồng sống" giữa BE và consumer.
Cơ chế. SwaggerModule.setup() quét metadata. Decorator: @ApiTags, @ApiProperty (trên field DTO), @ApiResponse, @ApiBearerAuth (đánh dấu route cần token). Plugin CLI của swagger có thể tự suy field từ type để bớt phải viết @ApiProperty.
Ví dụ code.
Pitfall thực tế. Không bật swagger CLI plugin → phải viết @ApiProperty cho mọi field, hay quên → doc thiếu field. Đừng để Swagger UI public ở production nếu API nội bộ (đặt sau auth hoặc chỉ bật ở non-prod).
15. Testing — unit + e2e#
Định nghĩa. Unit test: test một service cô lập, mock dependency. E2E test: chạy app thật (hoặc gần thật) và bắn HTTP request bằng supertest, kiểm tra toàn pipeline (guard, pipe, controller, service).
Tại sao quan trọng. Unit test bắt lỗi logic nhanh, chạy mili-giây, không cần DB. E2E xác nhận các mảnh ghép đúng với nhau (auth guard có chặn không, validation có chạy không). DI của Nest làm cả hai dễ vì có thể override provider.
Cơ chế. Test.createTestingModule({...}) dựng một DI container test; .overrideProvider(X).useValue(mock) thay dependency. Unit: lấy service ra module.get(UsersService). E2E: app = module.createNestApplication() rồi request(app.getHttpServer()).
Ví dụ code.
Pitfall thực tế. E2E dùng DB thật mà không reset giữa test → test phụ thuộc thứ tự, flaky. Dùng DB test riêng + truncate/transaction rollback mỗi test. Đừng mock quá sâu ở unit test đến mức test chỉ "kiểm tra mock" mà không kiểm tra logic thật.
Dự án 3 (portfolio chính)#
Xây một REST API app thật để làm portfolio — thể hiện toàn bộ GĐ07. Gợi ý: "Mini SaaS / Digital asset store API".
Phạm vi của GĐ07. Phần bắt buộc ở giai đoạn này là auth + RBAC (kể cả OAuth),
ValidationPipe, filter, interceptor, Prisma + repository, Swagger và test. Upload, webhook, job và compose bên dưới là đặc tả đầy đủ của dự án; từng phần được làm sâu ở GĐ10 (queue), GĐ12 (file), GĐ15 (Docker). Mục "Done khi" liệt kê cả các phần đó; phần nào chưa kịp làm thì hoãn sang giai đoạn tương ứng và ghi lại, đừng tick khi chưa chạy được.
Feature bắt buộc (chứng minh năng lực BE):
-
Auth + role đầy đủ
- Register/login, argon2 hashing, JWT access (15m) + refresh (7d) có rotation.
RolesGuard+@Roles('admin'|'user')cho RBAC (vd chỉ admin xoá user, xem tất cả order).- Refresh token lưu hash trong DB, logout = revoke.
-
Upload file lên S3 / Cloudflare R2
- Endpoint nhận file (multipart), validate type/size bằng pipe, đẩy lên R2/S3, lưu key + trả presigned URL khi cần tải.
- Không lưu file vào server local (không scale).
Đáp án
typescriptReady@CurrentUser()vàAuthUserđịnh nghĩa ở mục 8 (phần "Guard toàn cục"). R2 hỗ trợ presigned GET/PUT, không hỗ trợ presigned POST. Chỉ trả URL cho file thuộc user đang đăng nhập (kiểm key theouser.id, trả 404 nếu không phải của họ). -
Payment webhook
- Tích hợp Stripe/SePay: tạo checkout, nhận webhook báo thanh toán thành công.
- Verify signature webhook (chống giả mạo), idempotency (webhook có thể gửi lại — không cộng tiền 2 lần).
Đáp án
typescriptReadyKhoá idempotency (
processed_event.idlà khoá chính) và việc đổi trạng thái đơn nằm chung một transaction nên không có cửa sổ "đánh dấu đã xử lý nhưng chưa ghi". Enqueue sau commit có thể mất nếu process chết ngay giữa chừng: dùng outbox (GĐ10 mục 4) khi mất biên lai là không chấp nhận được. Mẫu idempotency tổng quát: GĐ10 mục 3. -
Background job
- Queue (BullMQ + Redis): sau khi thanh toán → job gửi email biên nhận, xử lý ảnh, hoặc cleanup.
- Xử lý retry + dead-letter khi job fail.
Đáp án
typescriptReadyJob lỗi vĩnh viễn hoặc hết 5 lần thử không bị mất: nó ở lại trong failed set (với
removeOnFail: false; đặt{ age, count }nếu muốn giới hạn dung lượng) và listenerfailedbắn cảnh báo. Có thể dùng queue DLQ riêng (đẩy job vào queuereceipts-deadtừ listener) khi cần xử lý lại có kiểm soát. Chi tiết ở GĐ10. LớpReceiptProcessorchưa chạy; điều kiện cảnh báo đã chạy trên BullMQ 6.3.11 với Redis tạm (attempts: 3): job némUnrecoverableErrorthất bại một lần vớiattemptsMade=1, điều kiện cũ rafalse(không cảnh báo), điều kiện mới ratrue; job némErrorthường chỉ ratrueở lần thứ 3. Cả hai job đều nằm trong failed set.Redis dành cho queue phải đặt
maxmemory-policy noeviction; cache dùng instance Redis riêng (GĐ09, GĐ10).
Kèm theo (chất lượng production):
ValidationPipeglobal (whitelist), exception filter chuẩn hoá lỗi, interceptor logging + transform response.@nestjs/config+ Joi/Zod validate env; secrets qua env, không hardcode.- Prisma + repository pattern + migration (không
synchronizeprod). - Swagger
/docscóaddBearerAuth. - Unit test cho service auth/payment + e2e cho luồng login → tạo order → webhook.
- Dockerfile + docker-compose (app + Postgres + Redis) để chạy 1 lệnh.
Kiến trúc module gợi ý:
Khung và mã dùng chung
Đây là khung và các đoạn then chốt, không phải dự án hoàn chỉnh. Code tham chiếu, chưa chạy (theo phiên bản trong hộp "Kiểm chứng ngày 2026-10-05" ở đầu bài: Nest 12, Prisma 7.10, BullMQ 6, Node 24). Làm theo thứ tự: khung + config + health, rồi auth, rồi users/Prisma, rồi upload, payment, jobs, cuối cùng Docker và test.
Cấu trúc thư mục.
main.ts: các cờ then chốt.
docker-compose.yml (rút gọn).
Lệnh nghiệm thu và kết quả mong đợi (suy ra từ code ở trên; chạy sau docker compose up -d):
| Lệnh | Mong đợi |
|---|---|
curl -i localhost:3000/health/live | 200, không phụ thuộc DB |
curl -i -X POST localhost:3000/auth/register -H 'content-type: application/json' -d '{"email":"a","password":"x"}' | 400 có message nêu email/password |
curl -i localhost:3000/users/me (không token) | 401 |
curl -i -X DELETE localhost:3000/users/<uuid> -H "authorization: Bearer $USER_TOKEN" | 403 (token của user thường) |
curl -i -X POST localhost:3000/webhooks/stripe -d '{}' -H 'stripe-signature: bad' | 400 invalid signature |
Gửi cùng một event hợp lệ hai lần (Stripe CLI stripe trigger hoặc ký tay bằng secret test) | hai lần 200; SELECT count(*) FROM processed_event WHERE id = '<event id>' ra 1; đơn chỉ chuyển paid một lần |
pnpm test / pnpm test:e2e | xanh; e2e có login, tạo order, webhook |
docker compose down rồi docker compose up | app tự kết nối lại DB/Redis, /health/ready về 200 |
Lỗi hay gặp.
- Không bật
rawBody: truehoặc đểexpress.json()parse trước:constructEventluôn báo sai chữ ký. - Verify chữ ký xong nhưng idempotency ghi sau tác dụng phụ ở transaction khác: webhook gửi lại đúng lúc crash sẽ cộng hai lần.
- Trả
500cho event đã xử lý: Stripe retry mãi. Trả200cho bản trùng. - Ký access và refresh cùng secret; hoặc lưu refresh token plaintext (mục 11).
- Dùng
.envtrong image hoặc commit.env: chỉ commit.env.example.
Done khi#
-
Hiểu & giải thích được DI/IoC: vì sao inject qua constructor giúp test/mock, phân biệt singleton vs request scope, xử lý được circular dependency.
Đáp án
Class nhận dependency qua constructor thay vì
new, nên test thay bằng fake ({ provide: Repo, useValue: mock }) mà không đụng DB; đổi implementation chỉ đổi binding. Mặc định singleton;REQUESTscope tạo instance mỗi request (chậm hơn và lan lên mọi provider phụ thuộc nó);TRANSIENTmỗi lần inject một bản. Vòng phụ thuộc: ưu tiên tách phần chung sang module thứ ba, tạm thờiforwardRef. Sai thường gặp:new Service()trong constructor. Xem mục 5. -
Dựng được app Nest có ≥4 feature module, import/export provider đúng, không lỗi "can't resolve dependencies".
Đáp án
Tự kiểm:
AppModuleimportAuthModule,UsersModule,UploadModule,PaymentModule,JobsModule; provider dùng chéo phải ởexportscủa module chủ vàimportscủa module dùng. App khởi động không inNest can't resolve dependencies. Sai thường gặp: khai báo lại provider ở module khác (tạo hai instance). Xem mục 2. -
ValidationPipeglobal chạy: request sai → 400 có message rõ;whiteliststrip field lạ.Đáp án
Tự kiểm:
curl -X POST /auth/register -H 'content-type: application/json' -d '{"email":"a","password":"x","isAdmin":true}'mong đợi400với message choemailvàpassword; vớiforbidNonWhitelisted: truefield lạ cũng ra 400, nếu chỉwhitelist: truethì field lạ bị strip im lặng. Xem mục 6. -
RolesGuard+@Roleschặn đúng: user thường bị 403, chưa login 401; guard order đúng (Auth trước Roles).Đáp án
Chưa đăng nhập:
AuthGuard('jwt')ném401. Đã đăng nhập thiếu role:RolesGuardtrảfalsenên Nest ném403. Thứ tự@UseGuards(AuthGuard('jwt'), RolesGuard)đúng vìRolesGuardcầnreq.user. Tự kiểm: ba curl (không token, token user, token admin) mong đợi 401, 403, 2xx. Xem mục 8. -
Auth hoàn chỉnh: argon2 hash, access + refresh 2 secret khác nhau, refresh rotation + lưu hash, logout revoke được.
Đáp án
Argon2 hash mật khẩu; access ký bằng
JWT_ACCESS_SECRET, refresh bằngJWT_REFRESH_SECRETkhác nhau; mỗi refresh token cójti, lưu hash SHA-256 một hàng/session cùng cờ thu hồi ở hàng family; rotation claim token cũ bằng mộtUPDATE ... WHERE used_at IS NULL; logout gọirevokeFamilyvà xoá cookie. Tự kiểm: refresh hai lần song song cùng token thì đúng một lần thành công. Sai thường gặp: cộthashedRttrên user, hoặc "đọcusedAtrồi ghi". Xem mục 11 (sơ đồ rotation ngay trong mục). -
Interceptor transform/log response; exception filter chuẩn hoá lỗi + không lộ stack/SQL ra client.
Đáp án
Interceptor bọc
{ data }và log thời gian, route ngoại lệ dùng@SkipTransform()(không có@SkipInterceptor). Filter@Catch()luôn log lỗi 500 phía server nhưng chỉ trả{ statusCode, message: 'Internal error' }. Tự kiểm: némnew Error('select * from users')trong handler, response không chứa chữselect, log server có stack. Xem mục 9, 10. -
Config validate env lúc boot (thiếu biến → fail fast); không commit
.env.Đáp án
ConfigModule.forRoot({ validationSchema })ném khi boot nếu thiếuDATABASE_URL. Tự kiểm:unset DATABASE_URL; node dist/main.jsphải thoát ngay với lỗi nêu tên biến;git check-ignore .envin ra.env. Xem mục 12. -
Prisma/TypeORM qua repository pattern, có migration, không N+1 rõ ràng, không leak
passwordHashra response.Đáp án
Service chỉ gọi
UserRepository, khôngprisma.user.*trực tiếp. Có thư mụcprisma/migrationsvà prod dùngprisma migrate deploy(khôngsynchronize). Tránh N+1 bằnginclude/selecttrong một query; không trảpasswordHash: map sang DTO output hoặc dùngselectkhông chứa nó. Tự kiểm:curl /users/<id>không có khoápasswordHash. Xem mục 13. -
Upload S3/R2 hoạt động (presigned URL), payment webhook verify signature + idempotent, background job chạy + retry được.
Đáp án
Upload: validate size/type bằng
ParseFilePipe, lưu key theo user, trả presigned URL ngắn hạn. Webhook:constructEventtrên body thô, bản trùng bị chặn bằngprocessed_eventtrong cùng transaction với việc đổi trạng thái đơn. Job: BullMQattempts+backoff, lỗi vĩnh viễn dùngUnrecoverableError(xem GĐ10). Tự kiểm: gửi một event hai lần chỉ đổi đơn một lần; làm job ném lỗi tạm thì thấy retry. Xem khung Dự án 3. -
Swagger
/docsmô tả đủ endpoint + bearer auth.Đáp án
DocumentBuilder().addBearerAuth()+@ApiBearerAuth()trên route cần token +@ApiPropertyhoặc CLI plugin cho DTO. Tự kiểm: mở/docs, mọi route có trong danh sách, nút Authorize hoạt động, route công khai không có ổ khoá. Xem mục 14. -
Có unit test service (mock provider) + e2e (supertest) cho ít nhất luồng auth và một luồng nghiệp vụ; test xanh.
Đáp án
Unit:
Test.createTestingModulevớioverrideProvider/useValuemock repo, kiểm logic service. E2E:supertesttrênapp.getHttpServer()cho login, tạo order, webhook; DB test riêng, reset giữa các test. Nest 12 mặc định Vitest nên dùngvi.fn(). Tự kiểm: chạy lệnh test hai lần liên tiếp đều xanh (không phụ thuộc thứ tự). Xem mục 15. -
Chạy được bằng
docker-compose up(app + Postgres + Redis).Đáp án
Ba service
app,db(Postgres),redis(--maxmemory-policy noevictioncho queue);depends_onvới healthcheck củadb. Tự kiểm:docker compose up -drồicurl localhost:3000/health/readymong đợi200. Khung compose ở phần Dự án 3. -
Mọi route mặc định cần đăng nhập (guard toàn cục), chỉ route có
@Public()mở; thứ tự guard khớp status code.Đáp án
Đăng ký
ThrottlerGuard,JwtAuthGuard,RolesGuardbằngAPP_GUARDtheo đúng thứ tự đó;JwtAuthGuardđọcIS_PUBLICbằnggetAllAndOverride([handler, class]). Tự kiểm bằng supertest: route không đánh dấu và không token ra401,/healthcó@Public()ra200,DELETEbằng token user ra403, bằng token admin ra2xx. ĐảoRolesGuardlên trướcJwtAuthGuardthì không token ra403(đã đo): đó là dấu hiệu sai thứ tự. Xem mục 8. -
Cảnh báo dead-letter bắn cả với job ném
UnrecoverableErrorở lần thử đầu.Đáp án
Điều kiện
err.name === 'UnrecoverableError' || job.attemptsMade >= (job.opts.attempts ?? 1)trong listenerfailed. Tự kiểm: tạo hai job vớiattempts: 3, một cái némUnrecoverableError, một cái némErrorthường; job đầu phải cảnh báo ngay vớiattemptsMade=1, job sau chỉ cảnh báo ở lần thứ 3, và cả hai còn trong failed set (removeOnFail: false). Sai thường gặp: chỉ soattemptsMadevớiattemptsnên lỗi vĩnh viễn không bao giờ báo động. Xem khung Dự án 3, phần Background job.
Nguyên tắc xuyên suốt để nhớ: Controller mỏng → Service chứa logic → Repository chạm DB. Validate ở boundary. Mọi dependency đều inject, không new. Không tin client. Fail fast ở boot.
🔑 Bổ sung — OAuth 2.0 & Social Login (đăng nhập Google/GitHub)#
Bổ sung theo mục tiêu AI SaaS. JWT/Passport ở trên lo xác thực nội bộ; phần này lo đăng nhập bằng nhà cung cấp thứ 3 — gần như bắt buộc cho SaaS.
OAuth 2.0 là gì#
- Định nghĩa: giao thức uỷ quyền — user cho phép app bạn truy cập thông tin của họ ở provider (Google/GitHub) mà KHÔNG đưa mật khẩu cho bạn.
- Tại sao quan trọng: SaaS cần "Sign in with Google" để giảm ma sát đăng ký; bạn không phải tự lưu/bảo vệ mật khẩu.
- Phân biệt: OAuth 2.0 = authorization (cấp quyền); OpenID Connect (OIDC) = lớp authentication xây trên OAuth, trả về
id_token(JWT) chứa danh tính. Social login thực chất dùng OIDC.
Authorization Code Flow (flow chuẩn cho web server)#
Cơ chế từng bước:
- FE bấm "Login with Google" → redirect tới
accounts.google.comkèmclient_id,redirect_uri,scope,state. - User đồng ý → Google redirect về
redirect_uri?code=...&state=.... - Backend đổi
code+client_secretlấyaccess_token(Google có thêmid_token; GitHub không cóid_token). Gọi server-to-server, secret không lộ ra FE. - Backend lấy danh tính: Google từ
id_tokennhận trực tiếp ở bước 3 hoặc từ userinfo, GitHub bằngGET /uservàGET /user/emails. Đọcsub(khoá danh tính) vàemail_verified→ tìm hoặc tạo user trong DB → phát JWT của hệ thống bạn (như GĐ trên). - Từ đây user dùng JWT nội bộ; không cần giữ token Google.
Đọc mã nguồn passport-google-oauth20 2.0.0 (chưa chạy với Google thật): gói này không verify id_token; nó đổi code rồi gọi userinfo https://www.googleapis.com/oauth2/v3/userinfo và đặt profile.id = sub, profile.emails[0].verified = email_verified. state: true và pkce: true của passport-oauth2 1.8.0 lưu giá trị trong req.session, nên cần express-session; API stateless thường tự viết hai route như bên dưới.
Sơ đồ: Authorization Code Flow (Google, qua backend)
Code tham chiếu, chưa chạy. Mong đợi: client_secret chỉ đi trên đoạn Backend-Google; trình
duyệt chỉ thấy code dùng một lần. Callback thiếu hoặc sai state phải bị từ chối trước khi đổi code.
Hai route và bàn giao cho SPA#
Mỗi provider chỉ cần hai route công khai (@Public(), mục 8): bắt đầu và callback. Phiên bản tự viết, stateless:
Bàn giao cho SPA: callback đặt refresh token vào cookie httpOnly (đúng cookie rt của mục 11) rồi chuyển hướng về SPA; SPA gọi POST /auth/refresh để lấy access token. Không đưa JWT vào URL. Cookie rt chỉ đi kèm request khi SPA và API cùng site (cùng domain đăng ký) hoặc SPA gọi API qua proxy cùng origin.
fetchIdentity mỗi provider khác nhau: GitHub đổi code ở https://github.com/login/oauth/access_token rồi gọi GET https://api.github.com/user lấy id (số, đổi sang chuỗi làm sub) và GET /user/emails lấy email primary cùng cờ verified; Google đổi code ở https://oauth2.googleapis.com/token rồi gọi userinfo lấy sub, email, email_verified. GitHub nhận code_challenge với S256 (không có plain); phía Google chưa xác minh (xem hộp kiểm chứng đầu bài).
Đã chạy (Nest 12.1.2, tsc --strict, supertest, provider giả, kho user trong bộ nhớ, không gọi Google/GitHub thật): GET /auth/google trả 302 tới host provider với state 43 ký tự, có code_challenge và cookie SameSite=Lax; callback với state sai hoặc thiếu cookie trả 400 và provider giả không bị gọi (0 lần); callback đúng trả 302 về WEB_URL/auth/done, đặt cookie rt, không có chuỗi JWT trong header. Hai hàm fetchIdentity thật chỉ biên dịch bằng tsc, chưa chạy.
Danh tính và gộp tài khoản#
Lưu danh tính ở bảng riêng, khoá là (provider, sub):
Quy tắc: tìm theo (provider, sub) trước; chỉ khi chưa có mới xét email, và chỉ gộp khi email do provider xác minh và tài khoản sẵn có cũng đã xác minh email. Nếu không, kẻ tấn công đăng ký tài khoản mật khẩu bằng email của nạn nhân (chưa xác minh) rồi chờ nạn nhân đăng nhập Google: hai bên bị gộp và kẻ tấn công giữ mật khẩu của tài khoản đó.
Đã chạy với kho giả: Google g1 (email đã xác minh, user cục bộ đã xác minh) gộp vào user cũ; GitHub gh9 cùng email gộp vào cùng user (2 dòng user_identity, vẫn 1 user); email emailVerified=false ra 401, không tạo gì; email trùng tài khoản cục bộ chưa xác minh ra 401; cùng sub Google với email đã đổi vẫn vào đúng user, không tạo user mới.
Điểm phải nắm#
statechống CSRF: sinh random, lưu session/cookie, so lại ở callback. Bỏ qua = lỗ hổng.- PKCE: bắt buộc cho SPA/mobile (public client) — chống đánh cắp
code. RFC 9700 (mục 2.1.1) cũng khuyến nghị cho client có secret, kể cả web app, và PKCE còn chống CSRF ở đường callback (mục 4.7.1). Bật cảstatelẫn PKCE:statelà cách chắc chắn khi provider chưa hỗ trợ PKCE. - Danh tính là
(provider, sub), không phải email.subkhông đổi suốt đời tài khoản còn email thì đổi được (OIDC Core mục 2; Google khuyên rõ không dùngemaillàm khoá). Email chỉ để gộp tài khoản, xem mục "Danh tính và gộp tài khoản" bên dưới. - Chỉ tin email đã
email_verifiedtừ provider (GitHub: mụcprimarycóverified: truetrong/user/emails, cần scopeuser:email). - Đừng lưu access_token provider nếu không cần gọi API của họ tiếp; chỉ cần danh tính là đủ.
Pitfall thực tế#
- Redirect URI phải khớp tuyệt đối với cấu hình trên console provider (kể cả trailing slash) → lỗi
redirect_uri_mismatch. - Nhầm
id_token(danh tính) vớiaccess_token(gọi API). id_tokenlấy từ nơi khác ngoài token endpoint (client gửi lên, bên thứ ba) mà không verify chữ ký → giả mạo. Nhận trực tiếp từ token endpoint qua HTTPS cùngclient_secretthì Google nói không bắt buộc verify chữ ký.- Cookie
stateđặtsameSite: 'strict': callback là điều hướng từ site provider nên cookie không được gửi, theo quy tắc SameSite trình duyệt có thể không gửi cookie này kèm điều hướng từ site khác (chưa đo), nên callback thấy thiếu cookie và ra400. Dùnglax. - Đặt JWT hoặc refresh token vào query của URL chuyển hướng về SPA: lọt vào lịch sử và log.
Done bổ sung khi#
-
Dự án 3 có "Login with Google/GitHub" → tạo user → phát JWT nội bộ, có
state, xử lý account linking cơ bản.Đáp án
Code tham chiếu, chưa chạy. Cách tự kiểm: (1) bấm "Login with Google" rồi xem URL redirect có đủ
client_id,redirect_uri,scope,state; (2) sửastatetrên URL callback thì backend phải trả400/401và không tạo user; (3) đăng nhập bằng Google rồi GitHub với cùng email đã verify thì bảngusersvẫn một hàng, bảng liên kết có haiprovider; (4)email_verified = falsethì không gộp tài khoản, không phát JWT. Bước đổicodelấy token luôn nằm ở backend; JWT trả về là của hệ thống bạn (mục 11), không lưuaccess_tokencủa provider nếu không cần gọi API của họ.typescriptReadyCờ
verifiedcủapassport-google-oauth20phụ thuộc phiên bản gói: kiểm tra trên profile thật khi làm bài. -
Callback chặn
statesai trước khi đổicode, và danh tính lưu theo(provider, sub).Đáp án
Tự kiểm bằng provider giả (hoặc bản thật ở local):
GET /auth/google/callback?code=c&state=WRONGkèm cookieoauth_txhợp lệ phải ra400và provider không bị gọi; không có cookie cũng400. Sau một lần đăng nhập đúng, bảnguser_identitycó đúng một dòng(google, <sub>); đổi email của tài khoản Google rồi đăng nhập lại vẫn vào cùng user, không tạo user mới (khoá làsub, không phải email). Cookieoauth_txdùngsameSite: 'lax'và bị xoá ở callback. Xem mục "Hai route và bàn giao cho SPA". -
Gộp tài khoản không mở đường chiếm tài khoản: chỉ gộp khi cả hai phía đã xác minh email, và JWT không đi qua URL.
Đáp án
Tình huống kiểm: có user mật khẩu
b@x.comchưa xác minh email; đăng nhập GitHub vớib@x.comđã xác minh phải ra401, không thêm dònguser_identity(đã đo với kho giả:401, số dòng giữ nguyên). Với user đã xác minh email thì gộp thành công và vẫn một hàngusers. Callback chỉ đặt cookiertrồi chuyển hướng vềWEB_URL/auth/done; kiểmLocationkhông chứatoken=hay chuỗi bắt đầueyJ. Xem mục "Danh tính và gộp tài khoản".