Passport là thư viện xác thực (authentication) phổ biến nhất của node.js, được cộng đồng biết đến rộng rãi và đã được dùng thành công trong nhiều ứng dụng production. Việc tích hợp thư viện này vào ứng dụng Nest khá đơn giản nhờ module @nestjs/passport. Ở mức tổng quan, Passport thực hiện một loạt bước để:
- Xác thực (authenticate) một user bằng cách kiểm tra “credentials” của họ (ví dụ username/password, JSON Web Token (JWT), hoặc identity token từ một Identity Provider)
- Quản lý trạng thái đã xác thực (bằng cách phát hành một token có thể mang theo được, như JWT, hoặc tạo một Express session)
- Gắn thông tin về user đã xác thực vào object
Requestđể dùng tiếp trong các route handler
Passport có một hệ sinh thái strategy phong phú, hiện thực nhiều cơ chế xác thực khác nhau. Về ý tưởng thì đơn giản, nhưng số lượng strategy của Passport mà bạn có thể chọn rất lớn và đa dạng. Passport trừu tượng hóa các bước khác nhau đó thành một pattern chuẩn, và module @nestjs/passport bọc lại (wrap) và chuẩn hóa pattern này thành các construct quen thuộc của Nest.
Ghi chú người dịch Nếu bạn từng làm PHP/Laravel, Passport ở đây tương tự tinh thần của Laravel Guards/Providers (hoặc Laravel Passport cho OAuth) — một lớp trừu tượng đứng giữa “cách bạn lấy credentials” và “cách bạn xác nhận user hợp lệ”. Còn nếu quen Express thuần, @nestjs/passport chính là lớp bọc decorator/DI quanh middleware passport.authenticate() mà bạn vẫn hay gắn trực tiếp vào route.
Trong chương này, chúng ta sẽ triển khai một giải pháp xác thực hoàn chỉnh từ đầu đến cuối (end-to-end) cho một RESTful API server, sử dụng các module mạnh mẽ và linh hoạt này. Bạn có thể áp dụng các khái niệm được mô tả ở đây để triển khai bất kỳ Passport strategy nào nhằm tùy biến cơ chế xác thực của riêng bạn. Bạn có thể làm theo các bước trong chương này để xây dựng ví dụ hoàn chỉnh này.
Yêu cầu xác thực (Authentication requirements)
Hãy làm rõ yêu cầu của chúng ta. Với use case này, client sẽ bắt đầu bằng việc xác thực bằng username và password. Sau khi xác thực thành công, server sẽ phát hành một JWT, có thể gửi kèm dưới dạng bearer token trong authorization header ở các request tiếp theo để chứng minh đã xác thực. Chúng ta cũng sẽ tạo một route được bảo vệ (protected route), chỉ cho phép truy cập khi request chứa một JWT hợp lệ.
Chúng ta sẽ bắt đầu với yêu cầu đầu tiên: xác thực user. Sau đó mở rộng bằng việc phát hành JWT. Cuối cùng, tạo một protected route kiểm tra JWT hợp lệ trên request.
Đầu tiên cần cài các package cần thiết. Passport cung cấp một strategy gọi là passport-local, hiện thực cơ chế xác thực username/password, phù hợp với phần yêu cầu này của use case.
$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local
Notice Với bất kỳ Passport strategy nào bạn chọn, bạn luôn cần package @nestjs/passport và passport. Sau đó, bạn cần cài package riêng cho strategy đó (ví dụ passport-jwt hoặc passport-local), package hiện thực cơ chế xác thực cụ thể mà bạn đang xây dựng. Ngoài ra, bạn cũng có thể cài type definition cho bất kỳ Passport strategy nào, như ví dụ trên với @types/passport-local, giúp hỗ trợ khi viết code TypeScript.
Triển khai Passport strategy
Bây giờ chúng ta đã sẵn sàng triển khai tính năng xác thực. Hãy bắt đầu với tổng quan quy trình dùng cho bất kỳ Passport strategy nào. Có thể xem Passport như một mini-framework độc lập. Điểm hay của framework này là nó trừu tượng hóa quá trình xác thực thành vài bước cơ bản mà bạn tùy biến dựa trên strategy đang triển khai. Nó giống một framework vì bạn cấu hình nó bằng cách cung cấp các tham số tùy biến (dưới dạng plain JSON object) và code tùy biến dưới dạng callback function, mà Passport sẽ gọi vào đúng thời điểm. Module @nestjs/passport bọc framework này lại theo phong cách Nest, giúp tích hợp dễ dàng vào ứng dụng Nest. Chúng ta sẽ dùng @nestjs/passport ở phần dưới, nhưng trước tiên hãy xem cách Passport thuần (vanilla Passport) hoạt động.
Với Passport thuần, bạn cấu hình một strategy bằng cách cung cấp hai thứ:
- Một tập các option đặc thù cho strategy đó. Ví dụ, với JWT strategy, bạn có thể cung cấp một secret để ký token.
- Một “verify callback” — nơi bạn báo cho Passport biết cách tương tác với user store của bạn (nơi bạn quản lý user account). Ở đây, bạn kiểm tra xem user có tồn tại không (và/hoặc tạo user mới), và credentials của họ có hợp lệ không. Passport library kỳ vọng callback này trả về đầy đủ thông tin user nếu việc kiểm tra thành công, hoặc null nếu thất bại (thất bại được định nghĩa là không tìm thấy user, hoặc, trong trường hợp passport-local, password không khớp).
Với @nestjs/passport, bạn cấu hình một Passport strategy bằng cách extend class PassportStrategy. Bạn truyền các option của strategy (mục 1 ở trên) bằng cách gọi phương thức super() trong subclass của mình, có thể kèm theo một options object. Bạn cung cấp verify callback (mục 2 ở trên) bằng cách implement phương thức validate() trong subclass.
Chúng ta sẽ bắt đầu bằng việc generate một AuthModule, và trong đó, một AuthService:
$ nest g module auth
$ nest g service auth
Khi triển khai AuthService, chúng ta sẽ thấy hữu ích khi đóng gói các thao tác liên quan đến user vào một UsersService, vậy hãy generate module và service đó ngay bây giờ:
$ nest g module users
$ nest g service users
Thay nội dung mặc định của các file được generate như dưới đây. Với app mẫu này, UsersService chỉ đơn giản duy trì một danh sách user hard-code trong memory, và một hàm find để lấy user theo username. Trong app thực tế, đây chính là chỗ bạn sẽ xây dựng user model và tầng persistence, dùng thư viện bạn chọn (ví dụ TypeORM, Sequelize, Mongoose, v.v.).
import { Injectable } from '@nestjs/common';
// This should be a real class/interface representing a user entity
export type User = any;
@Injectable()
export class UsersService {
private readonly users = [
{
userId: 1,
username: 'john',
password: 'changeme',
},
{
userId: 2,
username: 'maria',
password: 'guess',
},
];
async findOne(username: string): Promise<User | undefined> {
return this.users.find(user => user.username === username);
}
}
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor() {
this.users = [
{
userId: 1,
username: 'john',
password: 'changeme',
},
{
userId: 2,
username: 'maria',
password: 'guess',
},
];
}
async findOne(username) {
return this.users.find(user => user.username === username);
}
}
Trong UsersModule, thay đổi duy nhất cần làm là thêm UsersService vào mảng exports của decorator @Module để nó có thể được nhìn thấy từ bên ngoài module này (ta sẽ sớm dùng nó trong AuthService).
import { Module } from '@nestjs/common';
import { UsersService } from './users.service.js';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
import { Module } from '@nestjs/common';
import { UsersService } from './users.service.js';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
AuthService của chúng ta có nhiệm vụ lấy thông tin user và kiểm tra password. Ta tạo một phương thức validateUser() cho mục đích này. Trong code dưới đây, ta dùng toán tử spread ES6 tiện lợi để bỏ property password ra khỏi user object trước khi trả về. Chúng ta sẽ gọi phương thức validateUser() này từ Passport local strategy trong ít phút nữa.
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.usersService.findOne(username);
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
}
import { Injectable, Dependencies } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
@Injectable()
@Dependencies(UsersService)
export class AuthService {
constructor(usersService) {
this.usersService = usersService;
}
async validateUser(username, pass) {
const user = await this.usersService.findOne(username);
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
}
Warning Warning Tất nhiên trong ứng dụng thực tế, bạn sẽ không lưu password dưới dạng plain text. Thay vào đó bạn nên dùng một thư viện như bcrypt, với thuật toán one-way hash có salt. Với cách này, bạn chỉ lưu password đã hash, rồi so sánh password đã lưu với bản hash của password đầu vào, nhờ đó không bao giờ lưu hay để lộ password của user dưới dạng plain text. Để giữ app mẫu đơn giản, chúng ta vi phạm nguyên tắc bắt buộc đó và dùng plain text. Đừng làm vậy trong app thực tế của bạn!
Giờ, cập nhật AuthModule để import UsersModule.
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { UsersModule } from '../users/users.module.js';
@Module({
imports: [UsersModule],
providers: [AuthService],
})
export class AuthModule {}
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { UsersModule } from '../users/users.module.js';
@Module({
imports: [UsersModule],
providers: [AuthService],
})
export class AuthModule {}
Triển khai Passport local
Bây giờ chúng ta có thể triển khai local authentication strategy của Passport. Tạo một file tên local.strategy.ts trong thư mục auth, và thêm code sau:
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service.js';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException, Dependencies } from '@nestjs/common';
import { AuthService } from './auth.service.js';
@Injectable()
@Dependencies(AuthService)
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(authService) {
super();
this.authService = authService;
}
async validate(username, password) {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
Chúng ta đã làm theo công thức đã mô tả ở trên cho mọi Passport strategy. Với use case dùng passport-local, không có option cấu hình nào, nên constructor của ta chỉ đơn giản gọi super(), không kèm options object.
Gợi ý Chúng ta có thể truyền một options object vào lời gọi super() để tùy biến hành vi của passport strategy. Trong ví dụ này, passport-local strategy theo mặc định kỳ vọng các property tên username và password trong request body. Truyền một options object để chỉ định tên property khác, ví dụ: super({ usernameField: 'email' }). Xem tài liệu Passport để biết thêm.
Chúng ta cũng đã triển khai phương thức validate(). Với mỗi strategy, Passport sẽ gọi verify function (được triển khai bằng phương thức validate() trong @nestjs/passport) với một tập tham số đặc thù phù hợp cho strategy đó. Với local-strategy, Passport kỳ vọng một phương thức validate() với signature: validate(username: string, password:string): any.
Phần lớn công việc kiểm tra được thực hiện trong AuthService (với sự hỗ trợ của UsersService), nên phương thức này khá đơn giản. Phương thức validate() cho bất kỳ Passport strategy nào cũng sẽ theo một pattern tương tự, chỉ khác nhau ở chi tiết cách credentials được biểu diễn. Nếu tìm thấy user và credentials hợp lệ, user được trả về để Passport hoàn tất công việc của nó (ví dụ tạo property user trên object Request), và pipeline xử lý request tiếp tục. Nếu không tìm thấy, ta throw một exception và để tầng exception của chúng ta xử lý.
Thông thường, điểm khác biệt đáng kể duy nhất trong phương thức validate() giữa các strategy là cách bạn xác định user có tồn tại và hợp lệ hay không. Ví dụ, với JWT strategy, tùy theo yêu cầu, ta có thể kiểm tra userId mang trong token đã decode có khớp với một record trong database user hay không, hoặc khớp với một danh sách token đã bị revoke. Vì vậy, pattern subclass hóa và implement việc validate đặc thù cho từng strategy này nhất quán, gọn gàng và dễ mở rộng.
Chúng ta cần cấu hình AuthModule để dùng các tính năng Passport vừa định nghĩa. Cập nhật auth.module.ts như sau:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy.js';
@Module({
imports: [UsersModule, PassportModule],
providers: [AuthService, LocalStrategy],
})
export class AuthModule {}
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy.js';
@Module({
imports: [UsersModule, PassportModule],
providers: [AuthService, LocalStrategy],
})
export class AuthModule {}
Guard có sẵn của Passport (Built-in Passport Guards)
Chương Guards mô tả chức năng chính của Guard: xác định một request có được route handler xử lý hay không. Điều đó vẫn đúng, và ta sẽ sớm dùng khả năng quen thuộc đó. Tuy nhiên, trong ngữ cảnh dùng module @nestjs/passport, ta sẽ giới thiệu thêm một biến thể nhỏ có thể gây bối rối lúc đầu, nên hãy thảo luận ngay bây giờ. Hãy xem app của bạn có thể tồn tại ở hai trạng thái, xét từ góc độ xác thực:
- user/client chưa đăng nhập (chưa được xác thực)
- user/client đã đăng nhập (đã được xác thực)
Ở trường hợp đầu (user chưa đăng nhập), ta cần thực hiện hai chức năng riêng biệt:
-
Giới hạn các route mà user chưa xác thực có thể truy cập (tức từ chối truy cập vào route bị bảo vệ). Ta sẽ dùng Guard theo cách quen thuộc để xử lý chức năng này, bằng cách gắn một Guard vào các route bị bảo vệ. Như bạn có thể dự đoán, ta sẽ kiểm tra sự có mặt của một JWT hợp lệ trong Guard này, nên ta sẽ làm việc với Guard này sau, khi đã phát hành JWT thành công.
-
Khởi động bước xác thực khi một user chưa xác thực trước đó thử đăng nhập. Đây là bước ta sẽ phát hành JWT cho một user hợp lệ. Nghĩ một chút, ta biết ta sẽ cần
POSTcredentials username/password để khởi động xác thực, nên ta sẽ thiết lập một routePOST /auth/loginđể xử lý điều đó. Điều này dẫn đến câu hỏi: chính xác thì làm sao ta gọi passport-local strategy trong route đó?
Câu trả lời khá đơn giản: bằng cách dùng một loại Guard khác, hơi khác biệt. Module @nestjs/passport cung cấp cho ta một Guard có sẵn làm việc này. Guard này gọi Passport strategy và khởi động các bước đã mô tả ở trên (lấy credentials, chạy verify function, tạo property user, v.v).
Trường hợp thứ hai kể trên (user đã đăng nhập) chỉ đơn giản dựa vào loại Guard chuẩn ta đã thảo luận, để cho phép truy cập route bị bảo vệ với user đã đăng nhập.
Ghi chú người dịch Đây là điểm dễ gây nhầm khi mới học Nest: Guard không chỉ dùng để “chặn” request (như authorization guard bạn hay viết), mà AuthGuard('local') ở đây còn kiêm luôn việc thực thi bước xác thực (chạy strategy, gọi validate(), gắn req.user) trước khi handler chạy. Nếu quen Express, hãy hình dung nó như middleware passport.authenticate('local') bạn gắn trước route handler — chỉ khác là Nest gói nó lại dưới dạng Guard để tận dụng cơ chế DI và decorator.
Route đăng nhập (Login route)
Với strategy đã có sẵn, bây giờ ta có thể triển khai một route /auth/login cơ bản, và áp Guard có sẵn để khởi động luồng passport-local.
Mở file app.controller.ts và thay nội dung của nó bằng đoạn sau:
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller()
export class AppController {
@UseGuards(AuthGuard('local'))
@Post('auth/login')
async login(@Request() req) {
return req.user;
}
}
import { Controller, Bind, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller()
export class AppController {
@UseGuards(AuthGuard('local'))
@Post('auth/login')
@Bind(Request())
async login(req) {
return req.user;
}
}
Với @UseGuards(AuthGuard('local')), ta đang dùng một AuthGuard mà @nestjs/passport tự động tạo sẵn cho ta khi ta extend passport-local strategy. Hãy phân tích điều này. Passport local strategy của ta có tên mặc định là 'local'. Ta tham chiếu tên đó trong decorator @UseGuards() để gắn với code do package passport-local cung cấp. Điều này dùng để phân biệt strategy nào cần gọi trong trường hợp app có nhiều Passport strategy (mỗi strategy có thể tự tạo một AuthGuard riêng). Hiện ta chỉ có một strategy như vậy, nhưng ta sẽ sớm thêm strategy thứ hai, nên việc phân biệt này là cần thiết.
Để test route, ta sẽ dùng thư viện cURL thông dụng. Bạn có thể test với bất kỳ object user nào hard-code trong UsersService.
$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"userId":1,"username":"john"}
Cách này hoạt động, nhưng truyền tên strategy trực tiếp vào AuthGuard() sẽ tạo ra magic string trong codebase. Thay vào đó, ta khuyến nghị tạo class riêng của bạn, như dưới đây:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
Giờ, ta có thể cập nhật route handler /auth/login và dùng LocalAuthGuard thay thế:
@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
return req.user;
}
Route đăng xuất (Logout route)
Để đăng xuất, ta có thể tạo thêm một route gọi req.logout() để xóa session của user. Đây là cách làm điển hình trong xác thực dựa trên session, nhưng không áp dụng cho JWT.
@UseGuards(LocalAuthGuard)
@Post('auth/logout')
async logout(@Request() req) {
return req.logout();
}
Tính năng JWT
Ta đã sẵn sàng chuyển sang phần JWT của hệ thống auth. Hãy xem lại và làm rõ thêm yêu cầu:
- Cho phép user xác thực bằng username/password, trả về một JWT để dùng cho các lần gọi tiếp theo tới các API endpoint được bảo vệ. Ta đã đi được nửa đường để đạt yêu cầu này. Để hoàn tất, ta cần viết code phát hành JWT.
- Tạo các API route được bảo vệ dựa trên sự có mặt của một JWT hợp lệ dưới dạng bearer token
Ta sẽ cần cài thêm vài package để hỗ trợ yêu cầu JWT:
$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt
Package @nestjs/jwt (xem thêm tại đây) là một utility package hỗ trợ xử lý JWT. Package passport-jwt là package của Passport hiện thực JWT strategy, và @types/passport-jwt cung cấp type definition TypeScript.
Hãy xem kỹ hơn cách một request POST /auth/login được xử lý. Ta đã decorate route này bằng AuthGuard có sẵn do passport-local strategy cung cấp. Điều này nghĩa là:
- Route handler chỉ được gọi nếu user đã được xác thực (validated)
- Tham số
reqsẽ chứa một propertyuser(được điền bởi Passport trong quá trình xác thực passport-local)
Với điều này, giờ ta có thể tạo một JWT thật, và trả về nó trong route này. Để giữ các service được module hóa gọn gàng, ta sẽ xử lý việc tạo JWT trong authService. Mở file auth.service.ts trong thư mục auth, và thêm phương thức login(), cùng import JwtService như sau:
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService
) {}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.usersService.findOne(username);
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
async login(user: any) {
const payload = { username: user.username, sub: user.userId };
return {
access_token: this.jwtService.sign(payload),
};
}
}
import { Injectable, Dependencies } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { JwtService } from '@nestjs/jwt';
@Dependencies(UsersService, JwtService)
@Injectable()
export class AuthService {
constructor(usersService, jwtService) {
this.usersService = usersService;
this.jwtService = jwtService;
}
async validateUser(username, pass) {
const user = await this.usersService.findOne(username);
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
async login(user) {
const payload = { username: user.username, sub: user.userId };
return {
access_token: this.jwtService.sign(payload),
};
}
}
Ta dùng thư viện @nestjs/jwt, cung cấp hàm sign() để tạo JWT từ một tập con các property của object user, rồi trả về dưới dạng object đơn giản với một property duy nhất access_token. Lưu ý: ta chọn tên property sub để chứa giá trị userId cho nhất quán với chuẩn JWT. Đừng quên inject provider JwtService vào AuthService.
Giờ ta cần cập nhật AuthModule để import các dependency mới và cấu hình JwtModule.
Đầu tiên, tạo constants.ts trong thư mục auth, và thêm code sau:
export const jwtConstants = {
secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};
export const jwtConstants = {
secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};
Ta sẽ dùng cái này để chia sẻ key giữa bước ký (sign) và kiểm tra (verify) JWT.
Warning Warning Không để lộ key này công khai. Ta làm vậy ở đây để minh họa rõ code đang làm gì, nhưng trong hệ thống production, bạn phải bảo vệ key này bằng các biện pháp phù hợp như secrets vault, environment variable, hoặc configuration service.
Giờ, mở auth.module.ts trong thư mục auth và cập nhật như sau:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LocalStrategy } from './local.strategy.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants.js';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LocalStrategy } from './local.strategy.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants.js';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}
Ta cấu hình JwtModule bằng register(), truyền vào một configuration object. Xem tại đây để biết thêm về JwtModule của Nest, và tại đây để biết thêm chi tiết về các option cấu hình sẵn có.
Giờ ta có thể cập nhật route /auth/login để trả về một JWT.
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard.js';
import { AuthService } from './auth/auth.service.js';
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
return this.authService.login(req.user);
}
}
import { Controller, Bind, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard.js';
import { AuthService } from './auth/auth.service.js';
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
@Bind(Request())
async login(req) {
return this.authService.login(req.user);
}
}
Hãy test lại các route bằng cURL. Bạn có thể test với bất kỳ object user nào hard-code trong UsersService.
$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated
Triển khai Passport JWT
Giờ ta có thể xử lý yêu cầu cuối cùng: bảo vệ endpoint bằng cách yêu cầu một JWT hợp lệ phải có mặt trên request. Passport cũng hỗ trợ ta ở đây. Nó cung cấp strategy passport-jwt để bảo vệ RESTful endpoint bằng JSON Web Token. Bắt đầu bằng cách tạo file tên jwt.strategy.ts trong thư mục auth, và thêm code sau:
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants.js';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants.js';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload) {
return { userId: payload.sub, username: payload.username };
}
}
Với JwtStrategy, ta đã theo cùng công thức đã mô tả ở trên cho mọi Passport strategy. Strategy này cần một số khởi tạo, nên ta làm điều đó bằng cách truyền một options object trong lời gọi super(). Bạn có thể đọc thêm về các option sẵn có tại đây. Trong trường hợp của ta, các option này là:
jwtFromRequest: cung cấp phương thức để trích xuất JWT từRequest. Ta sẽ dùng cách chuẩn là cung cấp bearer token trong Authorization header của các API request. Các option khác được mô tả tại đây.ignoreExpiration: để rõ ràng, ta chọn giá trị mặc địnhfalse, giao trách nhiệm đảm bảo JWT chưa hết hạn cho module Passport. Điều này nghĩa là nếu route của ta nhận một JWT đã hết hạn, request sẽ bị từ chối và trả về response401 Unauthorized. Passport tự động xử lý việc này khá tiện lợi cho ta.secretOrKey: ta dùng cách nhanh gọn là cung cấp một symmetric secret để ký token. Các option khác, như một PEM-encoded public key, có thể phù hợp hơn cho app production (xem tại đây để biết thêm). Trong mọi trường hợp, như đã cảnh báo trước đó, không để lộ secret này công khai.
Phương thức validate() đáng để bàn thêm. Với jwt-strategy, Passport đầu tiên kiểm tra signature của JWT và decode JSON. Sau đó nó gọi phương thức validate() của ta, truyền JSON đã decode làm tham số duy nhất. Dựa vào cách JWT signing hoạt động, ta được đảm bảo đang nhận một token hợp lệ mà ta đã ký và phát hành trước đó cho một user hợp lệ.
Kết quả của tất cả điều này, response cho callback validate() của ta khá đơn giản: ta chỉ trả về một object chứa property userId và username. Nhắc lại, Passport sẽ xây dựng một object user dựa trên giá trị trả về của phương thức validate(), và gắn nó thành một property trên object Request.
Ngoài ra, bạn có thể trả về một array, trong đó giá trị đầu tiên được dùng để tạo object user, và giá trị thứ hai được dùng để tạo object authInfo.
Cũng cần lưu ý rằng cách làm này để lại cho ta chỗ (‘hook’ có thể nói vậy) để inject business logic khác vào quy trình. Ví dụ, ta có thể thực hiện một database lookup trong phương thức validate() để lấy thêm thông tin về user, tạo ra một object user phong phú hơn trong Request. Đây cũng là nơi ta có thể quyết định làm thêm việc kiểm tra token, ví dụ tìm userId trong một danh sách token đã bị revoke, cho phép ta thực hiện việc thu hồi token. Mô hình ta đã triển khai trong code mẫu ở đây là mô hình “stateless JWT” nhanh, nơi mỗi lệnh gọi API được ủy quyền (authorize) ngay lập tức dựa trên sự có mặt của một JWT hợp lệ, và một chút thông tin về người gọi (userId và username) sẵn có trong pipeline Request của ta.
Ghi chú người dịch Đây chính là điểm khác biệt cốt lõi giữa “stateless JWT” và session truyền thống (kiểu Express express-session hay PHP session): server không lưu trạng thái đăng nhập ở đâu cả, mọi thông tin cần thiết nằm trong chữ ký của token. Ưu điểm là scale ngang dễ (không cần chia sẻ session store giữa các instance), nhược điểm là bạn không thể “revoke” một token đã phát hành trừ khi tự cài thêm cơ chế blacklist — khác với session, nơi bạn chỉ cần xóa record trong session store là user bị đăng xuất ngay.
Thêm JwtStrategy mới làm provider trong AuthModule:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LocalStrategy } from './local.strategy.js';
import { JwtStrategy } from './jwt.strategy.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants.js';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LocalStrategy } from './local.strategy.js';
import { JwtStrategy } from './jwt.strategy.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants.js';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
Bằng cách import cùng một secret đã dùng khi ký JWT, ta đảm bảo pha verify do Passport thực hiện, và pha sign do AuthService thực hiện, dùng chung một secret.
Cuối cùng, ta định nghĩa class JwtAuthGuard extend AuthGuard có sẵn:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Triển khai protected route và JWT strategy guard
Bây giờ ta có thể triển khai protected route và Guard liên quan tới nó.
Mở file app.controller.ts và cập nhật như dưới đây:
import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard.js';
import { LocalAuthGuard } from './auth/local-auth.guard.js';
import { AuthService } from './auth/auth.service.js';
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
return this.authService.login(req.user);
}
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
}
import { Controller, Dependencies, Bind, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard.js';
import { LocalAuthGuard } from './auth/local-auth.guard.js';
import { AuthService } from './auth/auth.service.js';
@Dependencies(AuthService)
@Controller()
export class AppController {
constructor(authService) {
this.authService = authService;
}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
@Bind(Request())
async login(req) {
return this.authService.login(req.user);
}
@UseGuards(JwtAuthGuard)
@Get('profile')
@Bind(Request())
getProfile(req) {
return req.user;
}
}
Một lần nữa, ta áp AuthGuard mà module @nestjs/passport tự động tạo sẵn cho ta khi ta cấu hình module passport-jwt. Guard này được tham chiếu bằng tên mặc định của nó, jwt. Khi route GET /profile được gọi, Guard sẽ tự động gọi passport-jwt strategy đã được cấu hình tùy biến của ta, kiểm tra JWT, và gắn property user vào object Request.
Đảm bảo app đang chạy, và test các route bằng cURL.
$ # GET /profile
$ curl http://localhost:3000/profile
$ # result -> {"statusCode":401,"message":"Unauthorized"}
$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }
$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
$ # result -> {"userId":1,"username":"john"}
Lưu ý rằng trong AuthModule, ta đã cấu hình JWT có thời hạn 60 giây. Đây có lẽ là thời hạn quá ngắn, và việc xử lý chi tiết hết hạn và refresh token vượt ra ngoài phạm vi bài viết này. Tuy nhiên, ta chọn vậy để minh họa một đặc tính quan trọng của JWT và passport-jwt strategy. Nếu bạn chờ 60 giây sau khi xác thực rồi mới thử gọi GET /profile, bạn sẽ nhận response 401 Unauthorized. Đó là vì Passport tự động kiểm tra thời gian hết hạn của JWT, giúp bạn khỏi phải làm việc đó trong ứng dụng của mình.
Ta đã hoàn tất việc triển khai xác thực JWT. Các client JavaScript (như Angular/React/Vue), và các app JavaScript khác, giờ có thể xác thực và giao tiếp an toàn với API Server của ta.
Mở rộng guard (Extending guards)
Trong hầu hết trường hợp, dùng class AuthGuard có sẵn là đủ. Tuy nhiên, có thể có use case bạn muốn đơn giản là mở rộng logic xử lý lỗi hoặc xác thực mặc định. Để làm điều đó, bạn có thể extend class có sẵn và override phương thức trong một subclass.
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
// Add your custom authentication logic here
// for example, call super.logIn(request) to establish a session.
return super.canActivate(context);
}
handleRequest(err, user, info) {
// You can throw an exception based on either "info" or "err" arguments
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
Ngoài việc mở rộng logic xử lý lỗi và xác thực mặc định, ta có thể cho phép xác thực đi qua một chuỗi (chain) các strategy. Strategy đầu tiên thành công, redirect, hoặc lỗi sẽ dừng chuỗi lại. Các lần xác thực thất bại sẽ tiếp tục lần lượt qua từng strategy, và cuối cùng thất bại hoàn toàn nếu tất cả strategy đều thất bại.
export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }
Kích hoạt xác thực toàn cục (Enable authentication globally)
Nếu đa số endpoint của bạn nên được bảo vệ theo mặc định, bạn có thể đăng ký authentication guard như một global guard, và thay vì dùng decorator @UseGuards() trên từng controller, bạn có thể đơn giản đánh dấu route nào nên là public.
Đầu tiên, đăng ký JwtAuthGuard như một global guard bằng cấu trúc sau (trong bất kỳ module nào):
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
Với điều này, Nest sẽ tự động gắn JwtAuthGuard vào tất cả endpoint.
Giờ ta cần cung cấp một cơ chế để khai báo route là public. Để làm điều đó, ta có thể tạo một decorator tùy biến dùng hàm factory decorator SetMetadata.
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Trong file trên, ta export hai constant. Một là metadata key tên IS_PUBLIC_KEY, và cái còn lại là decorator mới ta sẽ đặt tên Public (bạn có thể đặt tên khác như SkipAuth hay AllowAnon, tùy vào project của bạn).
Giờ khi đã có decorator @Public() tùy biến, ta có thể dùng nó để decorate bất kỳ phương thức nào, như sau:
@Public()
@Get()
findAll() {
return [];
}
Cuối cùng, ta cần JwtAuthGuard trả về true khi tìm thấy metadata "isPublic". Để làm điều đó, ta sẽ dùng class Reflector (đọc thêm tại đây).
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
Ghi chú người dịch Pattern global guard + decorator @Public() này rất giống middleware toàn cục trong Laravel (áp cho mọi route trong web/api group) kết hợp với một cách “loại trừ” route cụ thể — chỉ khác là Nest dùng metadata (qua Reflector) để đánh dấu, thay vì khai báo danh sách route ngoại lệ trong file config như Laravel hay làm với middleware groups.
Strategy theo phạm vi request (Request-scoped strategies)
Passport API dựa trên việc đăng ký strategy vào instance toàn cục (global) của thư viện. Do đó strategy không được thiết kế để có option phụ thuộc vào request, hay để được khởi tạo động theo từng request (đọc thêm về provider request-scoped). Khi bạn cấu hình strategy của mình ở request scope, Nest sẽ không bao giờ khởi tạo nó, vì nó không gắn với route cụ thể nào. Không có cách nào về mặt vật lý để xác định “strategy request-scoped” nào nên được thực thi cho mỗi request.
Tuy nhiên, có cách để resolve động các provider request-scoped bên trong strategy. Để làm điều đó, ta tận dụng tính năng module reference.
Đầu tiên, mở file local.strategy.ts và inject ModuleRef theo cách thông thường:
constructor(private moduleRef: ModuleRef) {
super({
passReqToCallback: true,
});
}
Gợi ý Class ModuleRef được import từ package @nestjs/core.
Nhớ đặt property cấu hình passReqToCallback thành true, như đã chỉ ở trên.
Ở bước tiếp theo, instance request sẽ được dùng để lấy context identifier hiện tại, thay vì tạo một context identifier mới (đọc thêm về request context tại đây).
Giờ, bên trong phương thức validate() của class LocalStrategy, dùng phương thức getByRequest() của class ContextIdFactory để tạo một context id dựa trên object request, và truyền nó vào lời gọi resolve():
async validate(
request: Request,
username: string,
password: string,
) {
const contextId = ContextIdFactory.getByRequest(request);
// "AuthService" is a request-scoped provider
const authService = await this.moduleRef.resolve(AuthService, contextId);
...
}
Trong ví dụ trên, phương thức resolve() sẽ trả về (bất đồng bộ) instance request-scoped của provider AuthService (ta giả định AuthService được đánh dấu là provider request-scoped).
Ghi chú người dịch Nếu bạn từng viết Go và dùng context.Context để truyền dữ liệu theo scope của một request (request ID, deadline, user đăng nhập…), ContextIdFactory + moduleRef.resolve() ở đây đóng vai trò tương tự: nó cho Nest biết “instance nào của provider này thuộc về request nào” — vì bản thân Passport chỉ đăng ký strategy một lần duy nhất ở scope toàn cục, không biết gì về khái niệm request-scoped provider của Nest.
Tùy biến Passport (Customize Passport)
Bất kỳ option tùy biến chuẩn của Passport đều có thể được truyền theo cách tương tự, dùng phương thức register(). Các option sẵn có tùy thuộc vào strategy đang được triển khai. Ví dụ:
PassportModule.register({ session: true });
Bạn cũng có thể truyền cho strategy một options object trong constructor của chúng để cấu hình chúng. Với local strategy bạn có thể truyền ví dụ:
constructor(private authService: AuthService) {
super({
usernameField: 'email',
passwordField: 'password',
});
}
Xem trang chính thức của Passport để biết tên các property.
Strategy có tên (Named strategies)
Khi triển khai một strategy, bạn có thể cung cấp một tên cho nó bằng cách truyền tham số thứ hai vào hàm PassportStrategy. Nếu bạn không làm điều này, mỗi strategy sẽ có một tên mặc định (ví dụ, ‘jwt’ cho jwt-strategy):
export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')
Sau đó, bạn tham chiếu strategy này qua một decorator như @UseGuards(AuthGuard('myjwt')).
GraphQL
Để dùng một AuthGuard với GraphQL, extend class AuthGuard có sẵn và override phương thức getRequest().
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
Để lấy user hiện tại đã xác thực trong graphql resolver của bạn, bạn có thể định nghĩa một decorator @CurrentUser():
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);
Để dùng decorator trên trong resolver của bạn, hãy chắc chắn thêm nó làm tham số của query hoặc mutation:
@Query(() => User)
@UseGuards(GqlAuthGuard)
whoAmI(@CurrentUser() user: User) {
return this.usersService.findById(user.id);
}
Với passport-local strategy, bạn cũng cần thêm các argument của GraphQL context vào request body để Passport có thể truy cập chúng khi validate. Nếu không, bạn sẽ gặp lỗi Unauthorized.
@Injectable()
export class GqlLocalAuthGuard extends AuthGuard('local') {
getRequest(context: ExecutionContext) {
const gqlExecutionContext = GqlExecutionContext.create(context);
const gqlContext = gqlExecutionContext.getContext();
const gqlArgs = gqlExecutionContext.getArgs();
gqlContext.req.body = { ...gqlContext.req.body, ...gqlArgs };
return gqlContext.req;
}
}