Trong bài 2 của series này, mình đã hướng dẫn các bạn xử lý authentication với JWT, Passport và Bcrypt. Nó còn khá nhiều chỗ để cải thiện. Trong bài viết này, chúng ta sẽ tìm hiểu về cơ chế refresh tokens.

Theo dõi source code tại đâybranch dev nha

Link postman để test cho dễ.

Vì sao cần Refresh tokens?

Cho tới giờ, chúng ta chỉ thực hiện JWT access tokens tức là xác thực bypass người dùng. Và những token này có thời gian hết hạn cụ thể, thông thường sẽ ngắn hạn để bảo đảm an toàn. Nếu ai đó cố tình đánh cắp nó từ người dùng, token chỉ dử dụng được trong 1 khoản thời gian ngắn đến khi nó hết hạn.

Sau khi User đăng nhập thành công, chúng ta gửi cho người dùng access token. Giả sử nó sẽ hết hạn trong vòng 15 phút, trong lúc này, nó được dùng để authenticate trong hệ thống của chúng ta.

Sau khi hết hạn, user cần phải login lại bằng email + password. Điều này gây ra trải nghiệm không tốt cho người dùng. Mặc khác, ta tăng thời hạn cho token làm cho hệ thống của chúng ta kém an toàn hơn như mình đã nói ở trên.

Giải pháp cho vấn đề ở trên là refresh tokens. Ý tưởng cơ bản ở đây là khi đăng nhập thành công, chúng ta tạo 2 JWT token riêng biệt. Đầu tiên là access token với thời hạn trong 15 phút. Cái còn lại là refresh token có thời hạn lâu hơn nhiều, ví dụ 1 tuần hoặc 1 tháng tùy các bạn.

Cơ chế hoạt động của Refresh tokens

Người dùng lưu cả 2 token trong cookie nhưng chỉ sử dụng access token để authentication. Nó hoạt động trong 15 phút không có vấn đề gì. Khi API trả về thông báo hết hạn, người dùng cần thực hiện refresh bằng mã thứ 2 mình có nói ở trên.

Để refresh token, User cần gọi đến endpoint nơi mà hệ thống đã khai báo sẵn để handle việc này. Lần này thay vì sử dụng access token, thì user sẽ lấy refresh token (chính là token thứ 2 mình nói ở trên). Nếu nó hợp lệ và chưa hết hạn, người dùng sẽ nhận được access token mới. Nhờ đó, user không cần đăng nhập lại.

Giải quyết một số vấn đề tiềm ẩn

Thật không may khi mà refresh token bị đánh cắp. Nó là 1 phần dữ liệu nhạy cảm và quan trọng, có thể coi như là email + password.

Chúng ta cần giải quyết vấn đề theo 1 cách nào đó. Cách đơn giản nhất để làm như vậy là thay đổi JWT secret khi chúng ta biết về 1 sự cố rò rỉ dữ liệu nào đó. Làm như vậy khiến cho tất cả các token của hệ thống đều không hợp lệ, do đó không thể sử dụng được.

Tuy nhiên chúng ta lại không muốn tất cả người dùng logout khỏi hệ thống. Ví dụ chúng ta chỉ muốn duy nhất 1 refresh token nào đó bị vô hiệu hóa. Tuy nhiên, JWT bản chất của nó là không trạng thái.

Một trong những giải pháp mà chúng ta có thể xử lý là tạo ra một blacklist. Mỗi khi user yêu cầu refresh token, chúng ta sẽ kiểm tra nó có tồn tại trong danh sách đen không. Đây là một giải pháp không được tối ưu cho hiệu suất. Kiểm tra danh sách đen sau mỗi lần refresh token và luôn cập nhật nó là một nhiệm vụ khó khăn.

Một giải pháp thay thế, là lưu refresh token trong database khi đăng nhập. Khi user thực hiện refresh, chúng ta kiểm tra refresh token tồn tại trong database. Đơn giản nếu không có thì ta sẽ reject request hoặc trả về false hay bất cứ gì đó. Nhờ làm như trên, chúng ta có thể dễ dàng quản lý cũng như vô hiệu hóa refresh token của 1 user bằng cách xóa nó khỏi database.

Logout

Khi user đăng xuất khỏi hệ thống, chúng ta xóa JWT token ở cookie. Đây là hướng giải quyết đơn giản và khả thi cho access token có thời gian hết hạn ngắn, nhưng nó vẫn có một số vấn đề cho refresh token. Những token này mặc dù đã xóa ở trình duyệt nhưng nó vẫn còn hạn trong 1 thời gian dài.

Chúng ta có thể giải quyết vấn đề này bằng cách xóa refresh token khỏi database sau khi người dùng Logout. Nếu ai đó cố gắng sử dụng refresh token trước khi nó hết hạn thì đã bị hệ thống kiểm tra trong database và reject request.

Database bị rò rỉ (bị tấn công)

Mình đã chia sẻ rằng refresh token là dữ liệu khá là nhạy cảm. Nếu bị lộ, kẻ tấn công dễ dàng mạo danh người dùng của chúng ta.

Nó hoạt động như là password, và đây cũng là lí do ta lưu trữ password dưới dạng hash (mã băm) thay vì thuần túy. Chúng ta cũng có thể làm tương tự việc lưu trữ refresh token giúp bảo mật hơn.

Nếu chúng ta băm token trước khi lưu vào database, chúng ta sẽ ngăn chặn chúng tấn công ngay cả khi database bị rò rỉ.

Thực hiện Refresh token trong NestJS

Đầu tiên ta cần khai báo biến môi trường để đảm bảo secret dùng để tạo ra refresh token khác với access token.

SECRETKEY=quangdatuit
EXPIRESIN=60

SECRETKEY_REFRESH=quangdatuit_refresh
EXPIRESIN_REFRESH=2592000

Mình giảm thời gian của access token xuống 1 phút để test cho nó dễ.

Ta sẽ thêm 1 field vào Document User để lưu trữ refresh token. Đây là cách cơ bản để giải quyết vấn đề này. Giả dụ ta đăng nhập ở nhiều platform thì lại sinh ra nhiều refresh token. Thì ta nên tạo ra 1 Document (table) riêng nhằm lưu lại nó.

import { Schema, Document } from 'mongoose';

const UserSchema = new Schema(
  {
    name: String,
    email: String,
    password: String,
    refreshToken: String,
  },
  {
    collection: 'users',
  },
);

UserSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'user',
  justOne: false,
});

export { UserSchema };

export interface User extends Document {
  name: string;
  email: string;
  password: string;
  refreshToken: string;
}

Ta update lại auth.service.ts một xíu để trả về thêm refresh token

private async _createToken({ email }): Promise<any> {
  const accessToken = this.jwtService.sign({ email });
  const refreshToken = this.jwtService.sign(
    { email },
    {
      secret: process.env.SECRETKEY_REFRESH,
      expiresIn: process.env.EXPIRESIN_REFRESH,
    },
  );
  await this.userService.update(
    { email: email },
    { refreshToken: refreshToken },
  );
  return {
    expiresIn: process.env.EXPIRESIN,
    accessToken,
    refreshToken,
  };
}

Cập nhật thêm user.service.ts function update

async update(filter, update) {
  if (update.refreshToken) {
    update.refreshToken = await bcrypt.hash(
        this.reverse(update.refreshToken),
        10,
      );
  }
  return await this.userRepository.findByConditionAndUpdate(filter, update);
}

private reverse(s) {
  return s.split('').reverse().join('');
}

Các function này không có gì mới hết, các bạn có thể coi lại bài trước để hiểu hơn.
Và lí do mình sử dụng function reverse bạn có thể theo dõi tại đây

Call API coi response có gì nha

Kiểm tra data 1 xíu

Tạo endpoint dùng để refresh token

Bây giờ chúng ta bắt đầu xử lý refresh token. Ta khai báo function để kiểm tra refresh token có thật sự tồn tại. user.service.ts

async getUserRefreshToken(refreshToken: string, email: string) {
  const user = await this.findByEmail(email);
  if (!user) {
    throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
  }

  const is_equal = await bcrypt.compare(
      this.reverse(refreshToken),
      user.refreshToken,
    );

  if (!is_equal) {
    throw new HttpException('Invalid credentials', HttpStatus.UNAUTHORIZED);
  }

  return user;
}

Bây giờ ta sẽ tạo strategy mới cho Passport.
Tạo file user/jwt-refresh-token.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
import { UserService } from './services/user.service';

@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(
  Strategy,
  'jwt-refresh-token',
) {
  constructor(private readonly userService: UserService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.SECRETKEY_REFRESH,
      ignoreExpiration: true,
      passReqToCallback: true,
    });
  }

  async validate(request: Request, { email }) {
    const refreshToken = request.headers['authorization'].split(' ')[1];
    return await this.userService.getUserRefreshToken(refreshToken, email);
  }
}

Nhớ khai báo class vào phần providers trong UserModule

Cập nhật lại jwt.strategy.ts một xíu:

...
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly authService: AuthService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.SECRETKEY,
      ignoreExpiration: true,
    });
  }
  ...
}
import { JwtRefreshTokenStrategy } from './jwt-refresh-token.strategy';

...

providers: [
  UserRepository,
  UserService,
  JwtStrategy,
  AuthService,
  JwtRefreshTokenStrategy,
],

...

Bây giờ ta khai báo controller ngay thôi nào. Mở file auth.controller.ts:

@UseGuards(AuthGuard('jwt-refresh-token'))
@Post('refresh')
async refresh(@Req() req: any) {
  return await this.authService.refresh(req.user);
}

Tiếp tục vào auth.service.ts khai báo phương thức refresh:

async refresh(user: User) {
  const token = await this._createToken(user);

  return {
    email: user.email,
    ...token,
  };
}

Xử lý logout

Mở file auth.controller.ts:

@UseGuards(AuthGuard())
@Post('logout')
async getProfile(@Req() req: any) {
  await this.authService.logout(req.user);
  return {
    statusCode: 200,
  };
}

Mở file auth.service.ts:

async logout(user: User) {
  await this.userService.update({ email: user.email }, { refreshToken: null });
  return true;
}

Chỗ này ta chủ yếu clear refresh token thôi chớ hong có gì trơn.

Kết luận

Bằng cách thực hiện những điều ở trên, bây giờ chúng ta đã có một quy trình làm mới access token đầy đủ rồi á. Đồng thời cũng đã giải quyết một số vấn đề mà các bạn có thể gặp phải trong quá trình triển khai authenticate. Vẫn còn phải cải tiến nhiều hơn để nó có thể hoạt động trơn tru. Ở đây mình chỉ hướng dẫn cơ bản để các bạn hiểu rõ cách hoạt động nó như thế nào.

Nếu có suy nghĩ hay nhận xét gì cứ mạnh dạng để lời bình luận bên dưới.

Nguồn tham khảo:

Rất mong được sự ủng hộ của mọi người để mình có động lực ra những bài viết tiếp theo.
{\__/}
( ~.~ )
/ > ♥️ I LOVE YOU 3000

JUST DO IT!