Setup Boilerplate cho dự án NestJS - Phần 4: JWT/Passport Authentication với thuật toán bất đối xứng từ node:crypto
Đây là bài viết nằm trong Series NestJS thực chiến, các bạn có thể xem toàn bộ bài viết ở link: https://viblo.asia/s/nestjs-thuc-chien-MkNLr3kaVgA
Đặt vấn đề
Trong lập trình, việc sử dụng JWT để xác thực đã quá thông dụng với chúng ta. Nhưng cũng vì thế mà đa phần mọi người bắt buộc phải triển khai nó một cách nhanh chóng để theo kịp tiến độ dự án, điều đó có thể làm cho các bạn mới học hoặc chưa tiếp xúc nhiều với JWT phải sử dụng đến các source code có sẵn trên internet. Đôi khi làm cho họ không hiểu được tường tận (đa phần vì không có thời gian để tìm hiểu) cách mà JWT hoặc Passport.js hoạt động cũng như cần triển khai như thế nào để đạt hiệu quả và tối ưu tính bảo mật.
Ở bài viết này mình sẽ hướng dẫn và giải thích cách mà chúng ta ứng dụng JWT và Passport.js vào dự án để thực hiện authentication và authorization. Bên cạnh đó cũng ứng dụng package node:crypto vừa được NodeJS hoàn thiện ở version 19 để tạo ra các cặp key bất đối xứng dùng cho JWT. Hy vọng có thể giúp ích cho các bạn trong việc lập trình với dự án của mình.
Thông tin package
- NodeJS v19
- "@nestjs/jwt": "^10.0.3"
- "@nestjs/passport": "^9.0.3"
- "passport": "^0.6.0"
- "passport-local": "^1.0.0"
- "passport-jwt": "^4.0.1"
- "bcryptjs": "^2.4.3"
- "@types/passport-local": "^1.0.35"
- "@types/passport-jwt": "^3.0.8"
Các bạn có thể tải về toàn bộ source code của phần này ở đây
1. JWT hoạt động như thế nào
1.1 Cấu trúc
JWT thì không còn gì quá xa lạ với chúng ta nên mình sẽ nói sơ qua để tập trung vào phần code. Cấu trúc của một JWT sẽ như hình bên dưới, gồm có 3 phần ngăn cách nhau bởi dấu .
:
- Header: chứa loại token (typ) và thuật toán (alg) dùng để mã hóa (HMAC SHA256 - HS256 hoặc RSA).
- Payload: chứa các nội dung của thông tin (claims) và được chia làm 3 loại: reserved, public và private.
- Signature: được tạo ra bằng cách kết hợp Header, Payload và Secret key. JWT sẽ căn cứ vào phần này để verify xem token có hợp lệ hay không.
Lưu ý: các phần của token được convert sang Base-64 nên có thể dễ dàng revert lại, do đó chúng ta không nên để các sensitive data như password bên trong claims.
1.2 Cách JWT verify token
Quá trình xác thực tính hợp lệ trong JWT diễn ra như sau:
- Đầu tiên sẽ tạo ra giá trị S1 = giá trị của Signature trong token.
- Package JWT sẽ sign thông tin trong Header và Payload kết hợp với Secret key để ra giá trị Signature S2.
- So sánh giữa S1 = S2, nếu bằng nhau thì token hợp lệ và ngược lại.
2. Tại sao phải sử dụng Passport.js
Ở phần này chúng ta sẽ cùng nhau giải quyết câu hỏi: "Với package JWT chúng ta hoàn toàn có thể tự mình sign và verify token để tiến hành authentication cho dự án. Vậy tại sao phải cần cài thêm Passport.js, có lợi ích gì hay không mà đa số các bài hướng dẫn về authentication đều sử dụng nó với JWT?"
Passport.js là một middleware xác thực user trong Node.js, cung cấp các chiến lược (strategy) xác thực khác nhau như OAuth, OpenID, Local Strategy, v.v. Việc sử dụng Passport.js giúp cho việc xác thực user trở nên dễ dàng và tiện lợi hơn nhờ vào các ưu điểm sau:
-
Đơn giản hóa quá trình xác thực người dùng: Passport.js cung cấp cho các chiến lược xác thực phổ biến. Điều này giúp đơn giản hóa quá trình xác thực người dùng và không phải viết code xác thực lại từ đầu.
-
Cải thiện tính bảo mật của ứng dụng: Passport.js được thiết kế để giảm thiểu các lỗ hổng bảo mật có thể xảy ra trong quá trình xác thực user. Nó sử dụng các chiến lược xác thực được chứng minh là an toàn và cung cấp các phương tiện để tùy chỉnh và cấu hình theo nhu cầu của ứng dụng.
-
Hỗ trợ nhiều loại xác thực: Passport.js hỗ trợ nhiều loại xác thực khác nhau, bao gồm xác thực bằng local strategy, OAuth, OpenID, v.v. Điều này giúp cho ứng dụng của chúng ta trở nên linh hoạt và có thể tích hợp với nhiều dịch vụ xác thực khác nhau. Đó là những lý do tại sao mà theo mình chúng ta nên dùng Passport.js cho JWT. Trong dự án này quá trình xác thực của chúng ta sử dụng các strategy như sau:
-
User gọi API đăng nhập để lấy access token. Passport-local sẽ thông qua auth service sẽ tiến hành validate thông tin đăng nhập và trả về token cho user.
-
User gửi kèm access token khi gọi các API khác. Passport-jwt sẽ tiến hành validate access token và quyết định xem user có quyền truy cập hay không.
-
User gửi refresh token khi gọi API để renew access token mới. Passport-jwt sẽ tiến hành validate refresh token và quyết định xem token có hợp lệ để renew không.
3 Cài đặt Passport-local
Cài đặt các package cần thiết:
-
npm install --save @nestjs/passport passport passport-local bcryptjs
-
npm install --save-dev @types/passport-local
3.1 Sign up
Trước khi bắt đầu với passport chúng ta cần tách API sign up ra khỏi API create user ở bài viết trước để logic được rõ ràng và đúng tiêu chuẩn hơn. Đi đến thư mục src/modules và tạo thêm module auth với lệnh nest g res auth
(chúng ta không cần entity cho module này nên tất cả chọn No).
Sau đó tiến hành tạo API sign up:
- Cập nhật lại
AuthController
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignUpDto } from './dto/sign-up.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly auth_service: AuthService) {}
@Post('sign-up')
async signUp(@Body() sign_up_dto: SignUpDto) {
return await this.auth_service.signUp(sign_up_dto);
}
}
- Thêm vào
SignUpDto
import {
IsEmail,
IsNotEmpty,
IsStrongPassword,
MaxLength,
} from 'class-validator';
export class SignUpDto {
@IsNotEmpty()
@MaxLength(50)
first_name: string;
@IsNotEmpty()
@MaxLength(50)
last_name: string;
@IsNotEmpty()
@MaxLength(50)
@IsEmail()
email: string;
@IsNotEmpty()
@IsStrongPassword()
password: string;
}
- Thêm method
signUp
trongAuthService
import * as bcrypt from 'bcryptjs';
import { SignUpDto } from './dto/sign-up.dto';
import { UsersService } from '@modules/users/users.service';
import { ConflictException,Injectable } from '@nestjs/common';
@Injectable()
export class AuthService {
private SALT_ROUND = 11;
constructor(
private readonly users_service: UsersService,
) {}
async signUp(sign_up_dto: SignUpDto) {
try {
const existed_user = await this.users_service.findOneByCondition({
email: sign_up_dto.email,
});
if (existed_user) {
throw new ConflictException('Email already existed!!');
}
const hashed_password = await bcrypt.hash(
sign_up_dto.password,
this.SALT_ROUND,
);
const user = await this.users_service.create({
...sign_up_dto,
username: `${sign_up_dto.email.split('@')[0]}${Math.floor(
10 + Math.random() * (999 - 10),
)}`, // Random username
password: hashed_password,
});
return user;
} catch (error) {
throw error;
}
}
}
- Giải thích:
- Vì email chúng ta đã thiết kế là unique nên cần kiểm tra trước khi tạo user mới.
- Tương tự với username nhưng thay vì cho user nhập vào, mình sẽ cắt từ email và thêm vào 2-3 số phía sau
- Chúng ta sẽ dùng package bcrypt để hash password.
Gọi POST http://localhost:3333/auth/sign-up để test thử API vừa tạo:
Lát nữa chúng ta sẽ đổi response thành access token nên không cần quan tâm serialize response.
3.2 Sign in
Quá trình đăng nhập của chúng ta sẽ được xử lí theo quy trình bên dưới.
Mô tả:
- Đầu tiên request sẽ đến guard dựa theo Request Lifecycle. Chúng ta sẽ tạo
JwtAuthGuard
kế thừa từ@nestjs/passport
kèm strategy namelocal
để kích hoạt strategy. - Sau khi request đến guard thì strategy của passport-local có name trùng với
local
sẽ được kích hoạt. Nhận vào thông tin đăng nhập (chuyển đổi) và sau đó gửi đến methodvalidate
của nó. - Method
validate
sẽ dùng các thông tin vừa được xử lí gọi service ởAuthService
để xác thực user. - Ở
AuthService
dùng bcrypt kiểm tra xem password có trùng khớp hay không. Trả về user hoặc lỗi dựa theo kết quả. - Nếu thông tin hợp lệ sẽ chuyển đến logic bên trong method
signIn
củaAuthController
- Method
signIn
dùng thông tin user để tạo raaccess_token
vàrefresh_token
trả về cho user.
Tiến hành tạo các file để triển khai passport-local:
- Strategy:
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
import { Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly auth_service: AuthService) {
super({ usernameField: 'email' }); // Mặc định là username, đổi sang email
}
async validate(email: string, password: string) {
const user = await this.auth_service.getAuthenticatedUser(email, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
- Chú thích: mặc định passport-local nhận 2 fields là username và password, do chúng ta dùng email và password nên cần đổi lại ở contructor cho phù hợp.
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
- Có thể các bạn sẽ thắc mắc về giá trị
local
trongAuthGuard
ở đâu ra khi ở trênLocalStrategy
không có đề cập tới. Lý giải là do ởLocalStrategy
phía trên chúng ta không khai báo stategy name nên passport-local tự động lấy giá trị mặc định làlocal
(xem hình bên dưới), do đó ởLocalAuthGuard
chúng ta chỉ cần sử dụng. Vì thế trong trường hợp nếu các bạn thêm vào strategy name ởLocalStrategy
thì bắt buộc ởLocalAuthGuard
phải khớp mới có thể hoạt động.
Các bạn có thể override lại các method của
AuthGuard
để mở rộng logic xác thực hoặc xử lý lỗi mặc định. Xem thêm ở đây.
- Logic xác thực user ở
AuthService
:
...
async getAuthenticatedUser(email: string, password: string): Promise<User> {
try {
const user = await this.users_service.getUserByEmail(email);
await this.verifyPlainContentWithHashedContent(password, user.password);
return user;
} catch (error) {
throw new BadRequestException('Wrong credentials!!');
}
}
private async verifyPlainContentWithHashedContent(
plain_text: string,
hashed_text: string,
) {
const is_matching = await bcrypt.compare(plain_text, hashed_text);
if (!is_matching) {
throw new BadRequestException();
}
}
...
- Giải thích:
getAuthenticatedUser
sẽ chịu trách nhiệm kiểm tra xem thông tin đăng nhập của user có hợp lệ hay không.- Mình tách việc so sánh plain password với hashed password ra để tái sử dụng với chức năng refresh token.
Sau khi đã xác nhận user hợp lệ, việc tiếp theo chúng ta cần làm là tạo ra cặp access_token
và refresh_token
để trả về cho user.
Giành cho bạn nào chưa biết thì refresh_token được dùng để tạo lại access_token mới khi access_token cũ hết hạn mà không cần phải đăng nhập lại. Vì nguyên nhân bảo mật nên access_token thường có thời gian hết hạn ngắn, tránh được trường hợp token bị leak thì hacker chỉ có thể sử dụng trong một khoảng thời gian ngắn.
- Tạo function generate token. Do lát nữa chúng ta sẽ thay secret key thành cặp key bất đối xứng nên không cần lưu vào env.
...
generateAccessToken(payload: TokenPayload) {
return this.jwt_service.sign(payload, {
secret: 'access_token_secret',
expiresIn: `${this.config_service.get<string>(
'JWT_ACCESS_TOKEN_EXPIRATION_TIME',
)}s`,
});
}
generateRefreshToken(payload: TokenPayload) {
return this.jwt_service.sign(payload, {
secret: 'refresh_token_secret',
expiresIn: `${this.config_service.get<string>(
'JWT_REFRESH_TOKEN_EXPIRATION_TIME',
)}s`,
});
}
...
- Thêm biến môi trường
...
JWT_ACCESS_TOKEN_EXPIRATION_TIME=1800 // = 30 phút
JWT_REFRESH_TOKEN_EXPIRATION_TIME=25200 // = 1 tuần
- Thêm vào method
signIn
ởAuthController
để kết hợp các logic chúng ta vừa tạo xong.
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './guards/local.guard';
import { RequestWithUser } from 'src/types/requests.type';
...
@UseGuards(LocalAuthGuard)
@Post('sign-in')
async signIn(@Req() request: RequestWithUser) {
const { user } = request;
return await this.auth_service.signIn(user._id.toString());
}
- Để JWT có thể hoạt động chúng ta cần import
JwtModule
vàoAuthModule
, do chúng ta dùng secret key và expiration time riêng trong các methodjwt.sign
nên không cần truyền gì vào option củaJwtModule.register
. Sau đó là thêmLocalStrategy
vào provider.
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtModule } from '@nestjs/jwt';
...
@Module({
imports: [UsersModule, PassportModule, JwtModule.register({})],
controllers: [AuthController],
providers: [ AuthService, LocalStrategy ]
...
Nếu các bạn gặp lỗi "Unknown authentication strategy" thường là do 2 nguyên nhân:
- Strategy name ở guard và strategy không trùng khớp với nhau.
- Quên không để strategy vào provider.
Tất cả đã hoàn thiện, mình sẽ thử login với account vừa tạo khi nảy để kiểm tra kết quả, POST http://localhost:3333/auth/sign-in.
- Khi thông tin không chính xác
- Khi thông tin chính xác
Vậy là chúng ta đã xong phần đăng nhập với passport-local, tiếp theo chúng ta sẽ đến với phần xác thực tính hợp lệ token với passport-jwt để xem user có quyền truy cập vào API hay không.
4. Cài đặt Passport-jwt
Khi đã có token, user sẽ dùng token đó để truy vấn API, chúng ta sẽ dùng passport-jwt để tự động kiểm tra tính hợp lệ của token mà user gửi lên.
Như đã nói ở trên nếu không dùng passport thì chúng ta phải tự viết 1 guard hoặc middleware để verify token, việc đó ít nhiều sẽ tốn thời gian và đôi khi nếu chúng ta sử dụng không đúng cách có thể làm giảm performance.
Tiến hành cài đặt
-
npm install --save passport-jwt
-
npm install --save-dev @types/passport-jwt
4.1 Verify access token
Quá trình verify token được triển khai như hình bên dưới:
- Request sẽ đến guard
JwtAccessTokenGuard
đầu tiên, tương tự vớiLocalAuthGuard
nó được kế thừa từ@nestjs/passport
kèm stategy namejwt
. - Tương ứng với strategy name
jwt
thì strategyJwtAccessTokenStrategy
sẽ được kích hoạt. Nhận vào thông tin đăng nhập từ request và tiến hành verify token dựa theo secret key được cung cấp trong constructor. Nếu token hợp lệ sẽ chuyển đến methodvalidate
ngược lại trả về lỗi 401 Unauthorized. - Method
validate
: đến đây thì user đã được xác thực, chúng ta sẽ lấy ra thông tin đầy đủ của user dựa theouser_id
trong payload của token. Sau đó chuyển đến logic bên trong method handler của Controller.
Nội dung các file ở trên như sau:
- Strategy:
import { UsersService } from '@modules/users/users.service';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { TokenPayload } from '../interfaces/token.interface';
@Injectable()
export class JwtAccessTokenStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly users_service: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'access_token_secret',
});
}
async validate(payload: TokenPayload) {
return await this.users_service.findOne(payload.user_id);
}
}
-
Giải thích:
jwtFromRequest
: nhận vào function có chức năng retrieve token từ request. Chúng ta dùngExtractJwt.fromAuthHeaderAsBearerToken
để lấy jwt token từ headersAuthorization
. Tùy theo nhu cầu dự án các bạn có thể lấy ra token từ các nơi khác dựa theo các method cung cấp bởiExtractJwt
như: fromBodyField, fromUrlQueryParameter, fromExtractors,...ignoreExpiration
: nếu làtrue
thì token hợp lệ nhưng hết hạn vẫn có thể sử dụng để truy cập.secretOrKey
: secret key cho khóa đối xứng hoặc public key cho khóa bất đối xứng. Passport-jwt sẽ dùng key này để giải mã và verify token.
-
Guard:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAccessTokenGuard extends AuthGuard('jwt') {}
- Giải thích: tương tự với
LocalAuthGuard
giá trị mặc định nếu không khai báo strategy name củaJwtStrategy
làjwt
, do chúng ta không khai báo ởJwtAccessTokenStrategy
nên chỉ cần để giá trị mặc định làjwt
. - Thêm
JwtAccessTokenStrategy
vừa tạo vào provider củaAuthModule
để triển khai strategy này.
import { JwtAccessTokenStrategy } from './strategies/jwt-access-token.strategy';
...
@Module({
...
providers: [ AuthService, LocalStrategy, JwtAccessTokenStrategy ]
...
Việc cấu hình chỉ đơn giản vậy thôi, giờ chúng ta sẽ tích hợp vào API findAll
của UserController
để kiểm tra xem đã hoạt động hay chưa.
...
@SerializeOptions({
excludePrefixes: ['first', 'last'],
})
@Get()
@UseGuards(JwtAccessTokenGuard) // Thêm vào đây
findAll() {
return this.users_service.findAll();
}
...
Truy cập http://localhost:3333/users để xem kết quả
- Không thêm access token hoặc token không hợp lệ
- Thêm access token vừa tạo từ API login
4.2 Sử dụng JWT cho phạm vi controller
Trong một số trường hợp, khi hầu hết các API của 1 module đều cần xác thực thì việc thêm vào cho từng method handler sẽ mất thời gian và gây lặp code không cần thiết. Để giải quyết vấn đề đó, thay vì thêm vào cho từng method handler chúng ta sẽ thêm cho controller để áp dụng cho module đó hoặc phạm vi rộng hơn nữa là dùng APP_GUARD
cho tất cả các module.
Ví dụ chúng ta sẽ thêm vào authentication cho toàn bộ API của module Topic ngoại trừ API findAll (GET /topics).
...
@Controller('topics')
@UseGuards(JwtAccessTokenGuard)
export class TopicsController { ... }
Với kiến thức về guard thì chúng ta đã biết nó sẽ được áp dụng cho toàn bộ controller, vậy làm sao để giải quyết được yêu cầu thứ 2 là loại bỏ xác thực cho API findAll (GET /topics).
4.3 Decorator bypass authentication
Để loại bỏ authentication cho 1 API cụ thể chúng ta sẽ tạo ra decorator sử dụng SetMetadata
decorator factory function.
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Sau đó chúng ta cần cập nhật thêm cho JwtAccessTokenGuard
để thêm vào một phần logic authentication
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from 'src/decorators/auth.decorators';
@Injectable()
export class JwtAccessTokenGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
Giải thích:
- Ở
Public
decorator chúng ta đã assign metadataisPublic
với giá trịtrue
vào method handle. Lưu ý việc assign này được thực hiện ở build time. JwtAccessTokenGuard
sẽ override lại methodcanActive
và như tên của nó method này sẽ được gọi khi request đến guard và trước khi đến strategy. Nếu kết quả return làtrue
thì sẽ được phép truy cập API mà không cần trigger strategy.- Bên trong method
canActive
mình dùngreflector
kết hợp vớicontext
để lấy ra metadata mà chúng ta đã assign bằng decoratorPublic
(trong trường hợp API findAll thìcontext.getHandler()
sẽ là tên method handlefindAll
vàcontext.getClass()
là tên controllerTopicsController
)
Thêm decorator cho API findAll
...
@Controller('topics')
@UseGuards(JwtAccessTokenGuard)
export class TopicsController {
...
@Get()
@Public() // <=== Thêm vào đây
findAll() {
return this.topicsService.findAll();
}
...
Tiến hành kiểm tra xem mọi thứ đã hoạt động chưa
- Gọi API tạo topic, POST http://localhost:3333/topics
- Gọi API findAll topic, GET http://localhost:3333/topics
Có thể thấy kết quả đã như chúng ta mong đợi, API findAll đã được public, không cần token vẫn có thể truy cập.
4.4 API Refresh Token
Việc xử lí logic refresh token như sau:
- Request sẽ đến
JwtRefreshTokenGuard
đầu tiên, tương tự với 2 guard ở trên nó được kế thừa từ@nestjs/passport
kèm stategy namejwt-refresh-token
. - Tương ứng với strategy name
jwt-refresh-token
từJwtRefreshTokenGuard
thìJwtRefreshTokenStrategy
được kích hoạt. Logic verify ở đây sẽ tương tự vớiJwtAccessTokenStrategy
. - Ở method
validate
chúng ta cần lấy refresh token từ request gửi đến để so sánh xem có trùng khớp với token chúng ta đã lưu trong database hay không. - Sau khi xác nhận token trùng khớp sẽ chuyển đến logic bên trong method
refreshAccessToken
để tạoaccess_token
mới trả về cho user.
Chúng ta sẽ đi vào nội dung các file trên để tìm hiểu chi tiết hơn
- Trước tiên chúng ta cần thêm vào logic lưu refresh token của user vào database sau khi họ đăng ký hoặc đăng nhập
...
async signUp(sign_up_dto: SignUpDto) {
try {
...
const refresh_token = this.generateRefreshToken({
user_id: user._id.toString(),
});
await this.storeRefreshToken(user._id.toString(), refresh_token);
return {
access_token: this.generateAccessToken({
user_id: user._id.toString(),
}),
refresh_token,
};
} catch (error) {
throw error;
}
}
async signIn(user_id: string) {
try {
const access_token = this.generateAccessToken({
user_id,
});
const refresh_token = this.generateRefreshToken({
user_id,
});
await this.storeRefreshToken(user_id, refresh_token);
return {
access_token,
refresh_token,
};
} catch (error) {
throw error;
}
}
async storeRefreshToken(user_id: string, token: string): Promise<void> {
try {
const hashed_token = await bcrypt.hash(token, this.SALT_ROUND);
await this.users_service.setCurrentRefreshToken(user_id, hashed_token);
} catch (error) {
throw error;
}
}
...
- Strategy:
import { Request } from 'express';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthService } from '../auth.service';
import { TokenPayload } from '../interfaces/token.interface';
@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(
Strategy,
'refresh_token',
) {
constructor(
private readonly auth_service: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'refresh_token_secret',
passReqToCallback: true,
});
}
async validate(request: Request, payload: TokenPayload) {
return await this.auth_service.getUserIfRefreshTokenMatched(
payload.user_id,
request.headers.authorization.split('Bearer ')[1],
);
}
}
...
async getUserIfRefreshTokenMatched(
user_id: string,
refresh_token: string,
): Promise<User> {
try {
const user = await this.users_service.findOneByCondition({
_id: user_id,
});
if (!user) {
throw new UnauthorizedException();
}
await this.verifyPlainContentWithHashedContent(
refresh_token,
user.current_refresh_token,
);
return user;
} catch (error) {
throw error;
}
}
...
-
Giải thích:
passReqToCallback
: chúng ta thêm vào option này để truyền thông tin request vào methodvalidate
. Do đó ở methodvalidate
chúng ta sẽ có thêm thông tin request thay vì chỉ có payload của token. Từ request chúng ta lấy ra refresh token. Vì token chúng ta dùng có dạngBearer {token}
nên chúng ta cần xử lý để lấy ra chính xác nội dung token.- Chúng ta lấy ra thông tin user và dùng bcrypt để kiểm tra với hashed refresh token được lưu trong database ở method
getUserIfRefreshTokenMatched
.
-
Sau khi verify token và kiểm tra với database hợp lệ thì chúng ta sẽ trả về
access_token
mới cho user.
...
@UseGuards(JwtRefreshTokenGuard)
@Post('refresh')
async refreshAccessToken(@Req() request: RequestWithUser) {
const { user } = request;
const access_token = this.auth_service.generateAccessToken({
user_id: user._id.toString(),
});
return {
access_token,
};
}
...
Tiến hành gọi API POST http://localhost:3333/auth/refresh để kiểm tra kết quả:
- Thử dùng access token để gọi
- Dùng refresh token để gọi
Lưu ý quan trọng: package bcryptjs có giới hạn max length là 72 bytes, vì thế khi dùng refresh_token để hash thì các ký tự dư sẽ bị cắt bỏ, dẫn đến tình trạng so sánh luôn trả về true vì phần đầu (header) của refresh token luôn luôn giống nhau. Hậu quả làm cho token cũ hay mới đều có thể generate access_token, để khắc phục chúng ta sẽ dùng method scrypt của node:crypto thay cho bcrypt, mình sẽ bổ sung cách khắc phục sớm nhất có thể ở phần dưới. - Cảm ơn góp ý của bạn Đào Văn Quyền
4.5 Authorization
Phân quyền cũng là một phần không thể thiếu trong dự án của chúng ta, mình sẽ lấy ví dụ ở module user khi muốn xóa user thì bắt buộc phải là Admin. Quá trình triển khai sẽ như bên dưới:
- Đầu tiên chúng ta cần tạo decorator để set metadata cho method handler. Tương tự với decorator
Public
ở trên, chúng ta sẽ dùng để lấy ra các role được phép truy cập vào method handler đó. - Do thông tin user đang đăng nhập ở
JwtAccessTokenGuard
hiện tại chưa có thông tin role nên cần chỉnh sửa lại để thêm vào. - Sau đó chúng ta tạo thêm guard mang tên
RolesGuard
để triển khai logic so sánh role. Bên trong guard này sẽ lấy thông tin các role được phép truy cập từ metadata và kiểm tra xem user đang đăng nhập có nằm trong danh sách role vừa lấy ra không.
Nội dung file lần lượt theo trình tự như sau:
- Roles decorator:
import { SetMetadata } from '@nestjs/common';
export const ROLES = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES, roles);
- Cập nhật lại method get user trong
JwtAccessTokenGuard
. Mình sẽ tập trung vào logic authorization nên nội dung methodgetUserWithRole
các bạn có thể copy từ repo của mình ở đây.
...
export class JwtAccessTokenStrategy extends PassportStrategy(Strategy) {
...
async validate(payload: TokenPayload) {
return await this.users_service.getUserWithRole(payload.user_id);
// user có dạng: user: {..., role: 'User'}
}
...
- Tạo
RolesGuard
:
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { ROLES } from 'src/decorators/roles.decorator';
import { RequestWithUser } from 'src/types/requests.type';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly refector: Reflector) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const roles: string[] = this.refector.getAllAndOverride(ROLES, [
context.getHandler(),
context.getClass(),
]);
const request: RequestWithUser = context.switchToHttp().getRequest();
return roles.includes(request.user.role as unknown as string);
}
}
- Giải thích:
- Biến
roles
là thông tin từ metadata - Chúng ta lấy ra thông tin user trả về từ method
validate
trong request bằng cách dùng methodgetRequest
từcontext
.
- Biến
Do mình không tạo interface mới cho request dạng này nên mới phải ép kiểu role về unknown sau đó đến string. Các bạn có thể tạo interface để cho code rõ ràng minh bạch hơn.
- Cuối cùng kết hợp mọi thứ vào API xóa user:
import { Roles } from 'src/decorators/roles.decorator';
import { RolesGuard } from '@modules/auth/guards/roles.guard';
import { USER_ROLE } from '@modules/user-roles/entities/user-role.entity';
...
@Delete(':id')
@Roles(USER_ROLE.ADMIN)
@UseGuards(RolesGuard)
@UseGuards(JwtAccessTokenGuard)
remove(@Param('id') id: string) {
return this.users_service.remove(id);
}
Lưu ý quan trọng: RolesGuard
bắt buộc phải được đặt ở trên JwtAccessTokenGuard
để có thể lấy ra thông tin user đang đăng nhập. Vì theo Request Lifecycle các guard nằm trong cùng scope sẽ được thực thi từ dưới lên, do đó JwtAccessTokenGuard
cần đặt ở dưới để xử lý lấy ra user truyền vào request sau đó RolesGuard
mới có thể lấy ra sử dụng.
Vậy là chúng ta đã phân quyền xong, nếu dự án các bạn có thêm các quyền khác thì có thể triển khai mở rộng dựa vào đây. Bài viết đang hơi dài nên các bạn hãy giúp mình test phần này. Nếu có lỗi gì hay comment bên dưới để chúng ta cùng giải quyết.
5. Khóa bất đối xứng
5.1 Tại sao dùng khóa bất đối xứng?
Từ đầu bài viết đến giờ chúng ta đã dùng secret key để mã hóa và giải mã token, đó gọi là khóa đối xứng, còn một loại khóa nữa cũng thường được sử dụng đó là khóa bất đối xứng. Chúng ta sẽ tìm hiểu sơ qua về 2 loại khóa này với JWT:
-
Khóa đối xứng: sử dụng cùng một secret key để sign và verify JWT. Khi sử dụng khóa đối xứng, sẽ sử dụng cùng một secret key để tạo và xác thực JWT. Vì vậy, nó được coi là nhanh và hiệu quả nhưng không thể sử dụng để thực hiện xác thực đối với bên thứ ba.
-
Khóa bất đối xứng: sử dụng hai khóa: public key và private key. Khi sử dụng khóa bất đối xứng, chúng ta sử dụng private key để sign JWT và public key để verify JWT. Tính toàn vẹn của dữ liệu được đảm bảo bởi private key, trong khi tính xác thực của bên thứ ba được đảm bảo bởi public key. Tuy nhiên, việc sử dụng khóa bất đối xứng thường gây ra tốn kém về mặt thời gian và tài nguyên hơn so với sử dụng khóa đối xứng.
5.2 Cách sử dụng
Có nhiều cách để tạo ra các cặp key bất đối xứng (OpenSSL, node-rsa, ...), tuy nhiên ở đây chúng ta sẽ tận dụng package node:crypto
vừa mới được hoàn thiện ở NodeJS version 19 để tạo ra 2 cặp key cho 2 trường hợp là access token và refresh token.
Package node:crypto có sẵn trong NodeJS các version sau này nên chúng ta không cần cài đặt.
import * as crypto from 'node:crypto';
import * as path from 'node:path';
import * as fs from 'fs';
function checkExistFolder(name: string) {
const check_path = path.join(__dirname, `../../${name}`);
!fs.existsSync(check_path) && fs.mkdir(check_path, (err) => err);
}
function getAccessTokenKeyPair() {
checkExistFolder('secure');
const access_token_private_key_path = path.join(
__dirname,
'../../secure/access_token_private.key',
);
const access_token_public_key_path = path.join(
__dirname,
'../../secure/access_token_public.key',
);
// Kiểm tra xem file khóa đã tồn tại chưa
const access_token_private_key_exists = fs.existsSync(
access_token_private_key_path,
);
const access_token_public_key_exists = fs.existsSync(
access_token_public_key_path,
);
if (!access_token_private_key_exists || !access_token_public_key_exists) {
// Nếu file khóa không tồn tại, tạo cặp khóa mới
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
// Lưu khóa bí mật và khóa công khai vào file
fs.writeFileSync(access_token_private_key_path, privateKey);
fs.writeFileSync(access_token_public_key_path, publicKey);
}
// Đọc khóa bí mật và khóa công khai từ file
const access_token_private_key = fs.readFileSync(
access_token_private_key_path,
'utf-8',
);
const access_token_public_key = fs.readFileSync(
access_token_public_key_path,
'utf-8',
);
return {
access_token_private_key,
access_token_public_key,
};
}
function getRefreshTokenKeyPair() {
checkExistFolder('secure');
const refresh_token_private_key_path = path.join(
__dirname,
'../../secure/refresh_token_private.key',
);
const refresh_token_public_key_path = path.join(
__dirname,
'../../secure/refresh_token_public.key',
);
// Kiểm tra xem file khóa đã tồn tại chưa
const refresh_token_private_key_exists = fs.existsSync(
refresh_token_private_key_path,
);
const refresh_token_public_key_exists = fs.existsSync(
refresh_token_public_key_path,
);
if (!refresh_token_private_key_exists || !refresh_token_public_key_exists) {
// Nếu file khóa không tồn tại, tạo cặp khóa mới
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
// Lưu khóa bí mật và khóa công khai vào file
fs.writeFileSync(refresh_token_private_key_path, privateKey);
fs.writeFileSync(refresh_token_public_key_path, publicKey);
}
// Đọc khóa bí mật và khóa công khai từ file
const refresh_token_private_key = fs.readFileSync(
refresh_token_private_key_path,
'utf-8',
);
const refresh_token_public_key = fs.readFileSync(
refresh_token_public_key_path,
'utf-8',
);
return {
refresh_token_private_key,
refresh_token_public_key,
};
}
export const { access_token_private_key, access_token_public_key } =
getAccessTokenKeyPair();
export const { refresh_token_private_key, refresh_token_public_key } =
getRefreshTokenKeyPair();
- Giải thích:
- Mình sẽ cho lưu các cặp key ở cùng level với thư mục src để sau này khi deploy sẽ dễ quản lí hơn. Chúng ta sẽ kiểm tra xem thư mục secure có tồn tại hay không, nếu không thì dùng
fs
để tạo. - Chúng ta cũng sẽ kiểm tra xem các cặp key đã tồn tại hay chưa, nếu chưa thì sẽ tạo mới còn không thì sẽ đọc từ file và sử dụng.
- Để tạo ra các cặp key từ
node:crypto
chúng ta sử dụng methodgenerateKeyPairSync
với các parameter như sau:rsa
: thuật toán dùng để tạo key. Hiện tại support các thuật toán sau: RSA, RSA-PSS, DSA, EC, Ed25519, Ed448, X25519, X448, and DHmodulusLength
: kích thước khóa tính bằng bits (RSA, DSA). Khuyến nghị dùng 4096, mình dùng 2048 để giảm độ dài token lại cho các bạn dễ quan sát.publicKeyEncoding
vàprivateKeyEncoding
là tùy chọn để chỉ định định dạng và kiểu mã hóa của khóa được tạo ra:type: 'spki
' chỉ định kiểu mã hóa khóa công khai, trong trường hợp này là "SubjectPublicKeyInfo".type: 'pkcs8'
chỉ định kiểu mã hóa khóa bí mật, trong trường hợp này là "Private-Key Information Syntax Standard #8".format: 'pem'
chỉ định định dạng mã hóa, trong trường hợp này là "Privacy-Enhanced Mail (PEM)".
- Mình sẽ cho lưu các cặp key ở cùng level với thư mục src để sau này khi deploy sẽ dễ quản lí hơn. Chúng ta sẽ kiểm tra xem thư mục secure có tồn tại hay không, nếu không thì dùng
Để tránh việc đọc các key từ file lặp đi lặp lại nhiều lần, chúng ta sẽ gọi một lần và export giá trị các key ra.
Sau khi đã có các cặp key chúng ta sẽ thay đổi lại cho các hàm sign token ứng với các private key tương ứng
import { access_token_private_key, refresh_token_private_key } from src/constraints/jwt.constraint';
...
generateAccessToken(payload: TokenPayload) {
return this.jwt_service.sign(payload, {
algorithm: 'RS256',
privateKey: access_token_private_key,
// secret: 'access_token_secret',
expiresIn: `${this.config_service.get<string>(
'JWT_ACCESS_TOKEN_EXPIRATION_TIME',
)}s`,
});
}
generateRefreshToken(payload: TokenPayload) {
return this.jwt_service.sign(payload, {
algorithm: 'RS256',
privateKey: refresh_token_private_key
// secret: 'refresh_token_secret',
expiresIn: `${this.config_service.get<string>(
'JWT_REFRESH_TOKEN_EXPIRATION_TIME',
)}s`,
});
}
- Giải thích:
- Chúng ta sẽ chuyển từ dùng option
secret
sangprivate key
, và nội dung sẽ là private key được tạo ở trên. algorithm: 'RS256'
do sử dụng khóa bất đối xứng nên chúng ta cần chỉ định đúng thuật toán. Nếu không sẽ gặp lỗisecretOrPrivateKey must be a symmetric key when using HS256
- Chúng ta sẽ chuyển từ dùng option
Tiến hành gọi lại API POST http://localhost:3333/auth/sign-in để kiểm tra, có thể thấy độ dài token đã thay đổi so với khi nảy chúng ta tạo với khóa đối xứng.
Lấy token vừa tạo gọi API GET http://localhost:3333/users.
Chúng ta sẽ gặp lỗi 401 do chưa cập nhật lại key để verify cho JWT.
import { access_token_public_key } from 'src/constraints/jwt.constraint';
...
@Injectable()
export class JwtAccessTokenStrategy extends PassportStrategy(Strategy) {
constructor(private readonly users_service: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: true,
secretOrKey: access_token_public_key,
});
}
...
Thử lại với API GET http://localhost:3333/users. Kết quả thành công như bên dưới. Các bạn nhớ chỉnh lại key cho JwtRefreshTokenStrategy
.
Vậy là chúng ta đã cài đặt xong khóa bất đối xứng cho JWT. Các bạn có thể thấy chúng ta dùng access_token_private_key
để sign và dùng access_token_public_key
để verify token.
6. Các trường hợp cần quan tâm
Dưới đây là một số trường hợp các bạn có thể gặp phải, mình sẽ đưa ra hướng giải quyết và sau này nếu có thời gian mình sẽ cập nhật thêm ví dụ.
6.1 Xử lí khi token bị leak
Đây là trường hợp thường thấy, bằng cách nào đó hacker có được token (access token hoặc refresh token) của người dùng. Để ngăn chặn vấn đề đó thì việc đổi secret key hay private key là không hay, vì nó có thể làm token của tất cả user khác bị invalid.
Cách giải quyết theo cá nhân mình thì chúng ta sẽ tạo một collection(table) để lưu các blacklist token. Khi đó các token nằm trong danh sách này sẽ không thể truy cập vào API. Tuy nhiên các bạn cần lưu ý việc này sẽ làm giảm hiệu năng đi một tí vì chúng ta phải tốn thêm một bước kiểm tra token có nằm trong blacklist hay không.
Để cải thiện tốc độ truy vấn blacklist token chúng ta có thể đặt cronjob hoặc ttl cho các record trong database gần với thời gian tồn tại của token để tự động xóa các token đã hết hạn.
6.2 Xử lí token cũ khi user đổi password hoặc bị admin delete block
Trường hợp này ít gặp hơn nhưng vẫn cần được quan tâm. Khi user đổi password xong hoặc vì lý do nào đó admin block account của user, cặp token của họ vẫn còn tồn tại, đặc biệt là với refresh token.
Chúng ta có thể giải quyết vấn đề trên bằng blacklisting như trường hợp trên hoặc dùng property iat
hay thời gian token được tạo của JWT. Cụ thể chúng ta sẽ có một property để biểu thị thời gian user đổi password hoặc bị block, khi có request validate API chúng ta sẽ lấy ra iat
từ token và so sánh với thời gian đổi password hoặc bị block của user, nếu thời gian tạo token là sau khoảng thời gian đó thì hợp lệ.
Trên đây là các trường hợp mình thấy cần lưu ý khi chúng ta lập trình với JWT, nếu các bạn có thêm trường hợp nào cần nhắc nhở mọi người thì hãy comment bên dưới để mình thêm vào nha.
Kết luận
Chúng ta đã hoàn thành cấu hình authentication cho dự án với JWT. Ở bài viết này chúng ta đã đi qua khái niệm về JWT, các thành phần cũng như cách thức mà JWT verify token bên trong nó. Kế đến chúng ta cũng đã triển khai thành công logic access token và refresh token kết hợp với Passport.js. Ngoài ra chúng ta đã phân quyền cho các API ứng với các role cụ thể có trong dự án. Cuối cùng là tìm hiểu về khái niệm và cách triển khai khóa bất đối xứng sử dụng package node:crypto để tăng tính linh hoạt của JWT trong quá trình phát triển dự án.
Bài viết tiếp theo chúng ta sẽ cùng tìm hiểu về API documentation mà hầu như mọi người đều nghe qua, đó là Swagger. Tuy nhiên mình sẽ đi sâu vào cách dùng Swagger để viết một API documentation chuyên nghiệp và đầy đủ nhất, để giúp người sử dụng có thể hiểu rõ và nắm bắt được nội dung chúng ta phát triển cho từng API.
Cảm ơn các bạn đã giành thời gian đọc bài viết, hy vọng sẽ giúp ích được cho các bạn trong quá trình lập trình của bản thân.
Tài liệu tham khảo
- Son, N.H. (2023) JWT - từ CƠ bản đến Chi Tiết, Viblo. Available at: https://viblo.asia/p/jwt-tu-co-ban-den-chi-tiet-LzD5dXwe5jY#_1-jwt---json-web-token-la-gi-1 (Accessed: 20 May 2023).
- auth0.com (no date) Jwt.io, JSON Web Tokens. Available at: https://jwt.io/ (Accessed: 20 May 2023).
- Documentation: Nestjs - a progressive node.js framework (no date) NestJS. Available at: https://docs.nestjs.com/security/authentication (Accessed: 20 May 2023).
All rights reserved