Khi phát triển ứng dụng của chúng ta, hệ thống càng lớn, user càng nhiều thì càng có tính bảo mật hơn đối với tài khoản người dùng. Một trong những cách có thể cải thiện nó là triển khai cơ chế xác thực hai yếu tố. Bài này chúng ta sẽ áp dụng chúng vào dự án với NestJS và Google Authenticator.

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

Link postman để test cho dễ.

Adding two-factor authentication

Core idea của việc xác thực hai yếu tố là xác nhận người dùng theo 2 cách. Có sự khác biệt giữa xác thực 2 bước (two-step) và xác thực 2 yếu tố (two-factor). Ví dụ với máy ATM, để sử dụng được chúng ta cần có cả thẻ và mã PIN. Chúng ta gọi đó là xác thứ 2 yếu tố và nó yêu cầu thứ chúng ta cóthứ chúng ta biết. Thay vào đó, việc yêu cầu mật khẩu và mã PIN có thể được gọi là xác thực 2 bước.

Đầu tiên chúng ta cần cài thư viện để để xử lý setcret key cho từng user.

npm install otplib qrcode

qrcode để trả về ảnh qr, sau đó dùng ứng dụng Google Authenticator quét cho dễ.

Cùng với việc tạo riêng setcret key cho từng user, chúng ta cũng tạo 1 URL với giao thức otpauth://. Nó được Google Authenticator sử dụng để tạo OTP. Ngoài ta cũng cần cung cấp thêm tên để hiển thị trên ứng dụng này. Ta cấu hình cái nhẹ tên vào file .env:

TWO_FACTOR_AUTHENTICATION_APP_NAME=NQDAT.COM

Như trên đã nói, mỗi user sẽ có 1 setcret key khác nhau nên ta cập nhật lại model một xíu và thêm cái mode là có sử dụng xác thực 2 bước hay không.

import { Schema, Document } from 'mongoose';

const UserSchema = new Schema(
  {
    ...
    twoFactorAuthenticationSecret: String,
    isTwoFactorAuthenticationEnabled: { type: Boolean, default: false },
  },
  ...
);


export { UserSchema };

export interface User extends Document {
  ...
  twoFactorAuthenticationSecret: string;
  isTwoFactorAuthenticationEnabled: boolean;
}

Trong UserService khai báo 2 hàm để bật cái mode và 1 hàm để lưu cái setcret key này lại.

...
@Injectable()
export class UserService {
  ...

  async setTwoFactorAuthenticationSecret(secret: string, user_id: string) {
    return this.userRepository.findByIdAndUpdate(user_id, {
      twoFactorAuthenticationSecret: secret,
    });
  }

  async turnOnTwoFactorAuthentication(user_id: string) {
    return this.userRepository.findByIdAndUpdate(user_id, {
      isTwoFactorAuthenticationEnabled: true,
    });
  }
}

Controller

import {
  ClassSerializerInterceptor,
  Controller,
  Post,
  UseInterceptors,
  Res,
  UseGuards,
  Req,
  HttpCode,
  Body,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TwoFactorAuthenticationService } from '../services/twoFactorAuthentication.service';
import { UserService } from '../services/user.service';
import { AuthService } from '../services/auth.service';

@Controller('2fa')
@UseInterceptors(ClassSerializerInterceptor)
export class TwoFactorAuthenticationController {
  constructor(
    private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
    private readonly userService: UserService,
    private readonly authService: AuthService,
  ) {}

  @Post('generate')
  @UseGuards(AuthGuard('jwt'))
  async generate(@Res() response: any, @Req() request: any) {
    const { otpauthUrl } =
      await this.twoFactorAuthenticationService.generateTwoFactorAuthenticationSecret(
        request.user,
      );

    return this.twoFactorAuthenticationService.pipeQrCodeStream(
      response,
      otpauthUrl,
    );
  }

  @Post('turn-on')
  @HttpCode(200)
  @UseGuards(AuthGuard('jwt'))
  async turnOnTwoFactorAuthentication(@Req() request: any, @Body('code') code) {
    const isCodeValid =
      await this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
        code,
        request.user,
      );
    if (!isCodeValid) {
      throw new UnauthorizedException('Wrong authentication code');
    }
    await this.userService.turnOnTwoFactorAuthentication(request.user.id);
  }

  @Post('authenticate')
  @UseGuards(AuthGuard('jwt'))
  async authenticate(@Req() request: any, @Body('code') code) {
    const isCodeValid =
      await this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
        code,
        request.user,
      );
    if (!isCodeValid) {
      throw new UnauthorizedException('Wrong authentication code');
    }
    return this.authService.getAccess2FA(request.user);
  }
}

controller generate trả về qr code để chúng ta quét thêm vào ứng dụng Google Authenticator.

turnOnTwoFactorAuthentication dùng để bật mode xác thực 2 yếu tố cho user.

authenticate dùng để lấy token sau khi nhập code OTP vào.

Tiếp theo ta tiến hành khai báo các function xử lý bên trong từng service đã khai báo:


import { Injectable } from '@nestjs/common';
import { UserService } from './user.service';
import { ConfigService } from '@nestjs/config';
import { User } from '../models/user.model';
import { authenticator } from 'otplib';
import { toFileStream } from 'qrcode';

@Injectable()
export class TwoFactorAuthenticationService {
  constructor(
    private readonly userService: UserService,
    private readonly configService: ConfigService,
  ) {}

  async pipeQrCodeStream(stream: Response, otpAuthUrl: string) {
    return toFileStream(stream, otpAuthUrl);
  }

  async generateTwoFactorAuthenticationSecret(user: User) {
    const secret = authenticator.generateSecret();
    const otpAuthUrl = authenticator.keyuri(
      user.email,
      this.configService.get('TWO_FACTOR_AUTHENTICATION_APP_NAME'),
      secret,
    );
    await this.userService.setTwoFactorAuthenticationSecret(secret, user._id);
    return {
      secret,
      otpAuthUrl,
    };
  }

  async isTwoFactorAuthenticationCodeValid(code, user) {
    return authenticator.verify({
      token: code,
      secret: user.twoFactorAuthenticationSecret,
    });
  }
}
...
@Injectable()
export class AuthService {
  ...

  async getAccess2FA(user: User) {
    return await this._createToken(user, true);
  }

  private async _createToken(
    { email },
    isSecondFactorAuthenticated = false,
  ): Promise<any> {
    const accessToken = this.jwtService.sign({
      email,
      isSecondFactorAuthenticated,
    });
    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,
      expiresInRefresh: process.env.EXPIRESIN_REFRESH,
    };
  }
...
}

Ở phần AuthService ta thêm getAccess2FA để set vào bên trong jwt của chúng ta có cái mode check cái token đó đã xác thực 2 yếu tố hay chưa.

Sau khi sử dụng xác thực 2 bước vào hệ thống thì chúng ta cũng cần sử dụng strategy mới. Ta khai báo

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthService } from './services/auth.service';

@Injectable()
export class JwtTwoFactorStrategy extends PassportStrategy(
  Strategy,
  'jwt-two-factor',
) {
  constructor(private readonly authService: AuthService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.SECRETKEY,
      ignoreExpiration: true,
    });
  }

  async validate({ email, isSecondFactorAuthenticated }) {
    const user = await this.authService.validateUser(email);

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

    if (user.isTwoFactorAuthenticationEnabled && !isSecondFactorAuthenticated) {
      throw new HttpException('Permission denied', HttpStatus.FORBIDDEN);
    }

    return user;
  }
}

Phần này mình kiểm tra có bật xác thực và đã xác thực hay chưa, còn và mình dùng jwt-two-factor thay cho mặc định jwt .

Vào UserController sửa lại 1 xíu chỗ lấy thông tin user

@Controller('user')
export class UserController {
...
  @UseGuards(AuthGuard('jwt-two-factor'))
  @Get('profile')
  async getProfile(@Req() req: any) {
    return req.user;
  }
...
}

Và sau cùng khai báo những file tạo mới vào module, thì nó mới chạy được nha:

...
import { TwoFactorAuthenticationController } from './controllers/twoFactorAuthentication.controller';
import { TwoFactorAuthenticationService } from './services/twoFactorAuthentication.service';
import { JwtTwoFactorStrategy } from './jwtTwoFactor.strategy';

// @Global()
@Module({
  imports: [
    ConfigModule.forRoot(),
    ...
  ],
  controllers: [
    AuthController,
    UserController,
    TwoFactorAuthenticationController,
  ],
  providers: [
    UserService,
    AuthService,
    UserRepository,
    JwtStrategy,
    TwoFactorAuthenticationService,
    JwtTwoFactorStrategy,
  ],
  exports: [UserService],
})
export class UserModule {}

Bây giở mở postman lên, sử dụng token ở bài trước ( xem tại đây ) để bật cái mode xác thực 2 yếu tố.

Khi đó postman trả về cho chúng ta cái qrcode.

Dùng ứng dụng Google Authenticator để add vào. Khi có ta sẽ nhận được mã code.

Tiếp tục call api xác thực authenticate, lúc này sẽ sử dụng access token mới để call api profile .

Vậy là xong á.

Kết luận

Trong bài viết này, chúng ta đã triển khai quy trình xác thực hai yếu tố, hoạt động đầy đủ. Người dùng hiện có thể tạo khóa bí mật, duy nhất và chúng ta hiển thị chúng bằng hình ảnh QR. 

Cách tiếp cận trên có thể được hưởng lợi từ các tính năng bổ sung. Một ví dụ là hỗ trợ mã dự phòng mà người dùng có thể sử dụng trong trường hợp mất điện thoại. Tôi khuyến khích bạn cải thiện flow được trình bày trong bài viết này.

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!