お問い合わせ
NestJs で二段階認証を実装する
投稿日:2023-08-31
@prompta
(株)ブロックセブンスソフトウェア
NestJs
2段階認証
sqlite

# 概要

情報流出により一般的なメールアドレスやid パスワードを使用した認証情報の安全性の優位性はもはや無くなってしまっている為、スマートフォンアプリによる、コード認証、生体認証などの導入はもはや必須になっています。
今回は比較的簡単に導入可能なGoogleAuthenticatorの二段階認証をNestJsのJwt認証に追加して導入してみます。
Nest公式のドキュメントに認証に関する実装例がありますのでこちら実装している前提で2段階認証の部分のみご紹介いたしましす。

# 2段階認証用のライブラリをインストール

npm install otplib

# QR作成用のライブラリをインストール

  • GoogleAuthenticatorで認識させるためのQRコードを作成するために以下のライブラリをインストールします。
npm install --save qrcode

# swagger用ライブラリをインストール

  • 今回はフロントエンドの実装は行わないのでSwaggerUIからリクエストを送るためライブラリをインストールします。
npm install --save @nestjs/swagger

main.ts追加します。

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('Cats example')
    .setDescription('The cats API description')
    .setVersion('1.0')
    .addTag('cats')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  await app.listen(3000);
}
bootstrap();

これで/apiにアクセスするとswaggerUIが表示されるようになります。

# prisma sqlite3を導入する

  • 二段階認証時に入力するコード検証に必要なシークレット情報をsqliteに情報を保存します。以下のライブラリをインストールします。
npm install prisma --save-dev
  • 初期化
npx prisma init
  • スキーマを追加し
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}
model User {
  id        String    @id @default(cuid())
  mail      String
  password  String
  isTwoFactorAuthenticationEnabled Boolean?
  twoFactorAuthenticationSecret String @default("")
}
  • マイグレーション
npx prisma migrate dev
  • Userテーブルに適当な情報を追加しておきます。
  • パスワードはbycripハッシュ計算値を登録します。作り方はこちらなどからサイト上で作成できます。
INSERT INTO "User" ("id", "mail", "password", "isTwoFactorAuthenticationEnabled", "twoFactorAuthenticationSecret") VALUES ('Dw09l1Nx0RK3w6y', 'user@block-7th-soft.co.jp', '$2b$10$...', 0, '');

# 実装内容

# 2段階認証用のサービス作成

./src/domain/auth-two-factor/auth-two-factor.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { authenticator } from 'otplib';
import { toFileStream } from 'qrcode';
import { PrismaClient } from '@prisma/client';
import { AdminUserResponse } from '../admin-auth/dto/response/admin-auth.response';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthTwoFactorService {
  constructor(private jwtService: JwtService) {}
  private prisma = new PrismaClient();
  async generateTwoFactorAuthenticationSecret(user: AdminUserResponse) {
    const secret = authenticator.generateSecret(128);
    const otpauthUrl = authenticator.keyuri(
      user.mail,
      '2 factor authorize',
      secret,
    );
    const dbUser = await this.prisma.user.findFirst({ where: { id: user.id } });
    if (dbUser.isTwoFactorAuthenticationEnabled) {
      throw new UnauthorizedException('already two factor enabled');
    }

    await this.prisma.user.update({
      data: { twoFactorAuthenticationSecret: secret },
      where: { id: user.id },
    });

    return {
      secret,
      otpauthUrl,
    };
  }

  async isTwoFactorAuthenticationCodeValid(
    twoFactorAuthenticationCode: string,
    user: AdminUserResponse,
  ) {
    const dbUser = await this.prisma.user.findUnique({
      where: { id: user.id },
    });
    if (!dbUser) {
      throw new UnauthorizedException('no user exist');
    }
    const verify = authenticator.verify({
      token: twoFactorAuthenticationCode,
      secret: dbUser.twoFactorAuthenticationSecret,
    });
    if (!verify) {
      throw new UnauthorizedException('Wrong authentication code');
    }
    if (!dbUser.isTwoFactorAuthenticationEnabled) {
      await this.prisma.user.update({
        where: { id: dbUser.id },
        data: { isTwoFactorAuthenticationEnabled: true },
      });
    }
    return verify;
  }

  async loginWith2fa(userWithoutPsw: Partial<AdminUserResponse>) {
    const payload = {
      id: userWithoutPsw.id,
      email: userWithoutPsw.mail,
      isTwoFactorPassed: true,
    };
    const accessToken = await this.jwtService.signAsync(payload);
    return { accessToken };
  }
  async qrCodeStreamPipe(stream: Response, otpPathUrl: string) {
    return toFileStream(stream, otpPathUrl);
  }
}

# 2段階認証ガード作成

  • strategyのライブラリをインストールしていない場合は
npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local

./src/domain/auth-two-factor/strategy/two-factor.strategy.ts

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class TwoFactorStrategy extends PassportStrategy(
  Strategy,
  'two-factor',
) {
  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('SECRET'),
    });
  }

  async validate(payload: any) {
    return payload.isTwoFactorPassed ? payload : null;
  }
}

./src/domain/auth-two-factor/guard/two-factor.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class TwoFactorGuard extends AuthGuard('two-factor') {}

# コントローラーの作成

./src/domain/auth-two-factor/auth-two-factor.controller.ts

import {
  Body,
  Controller,
  HttpCode,
  Post,
  Req,
  Res,
  UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../admin-auth/guard/jwt-auth-guard';
import { AuthTowFactorCodeRequest } from './dto/auth-tow-facotor-code.request';
import { AuthTwoFactorService } from './auth-two-factor.service';
import { TwoFactorGuard } from './guard/two-factor.guard';

@ApiTags('auth-two-factor')
@Controller('auth-two-factor')
export class AuthTwoFactorController {
  constructor(private authService: AuthTwoFactorService) {}
  @ApiOperation({ summary: '2段階認証 ログイン' })
  @Post('2fa/authenticate')
  @HttpCode(200)
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth('token')
  async authenticate(@Req() request, @Body() body: AuthTowFactorCodeRequest) {
    await this.authService.isTwoFactorAuthenticationCodeValid(
      body.twoFactorAuthenticationCode,
      request.user,
    );
    return this.authService.loginWith2fa(request.user);
  }

  @ApiOperation({ summary: '2factor qrコード作成' })
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth('token')
  @Post('2fa/generate-qr')
  async generateQrCode(@Req() request, @Res() response) {
    const { otpauthUrl } =
      await this.authService.generateTwoFactorAuthenticationSecret(
        request.user,
      );
    response.setHeader('content-type', 'image/png');
    this.authService.qrCodeStreamPipe(response, otpauthUrl);
  }

  @ApiOperation({ summary: '2factor 認証check' })
  @UseGuards(TwoFactorGuard)
  @ApiBearerAuth('token')
  @Post('2fa/check')
  async check(@Req() request) {
    return request.user;
  }
}

# モジュール作成

import { Module } from '@nestjs/common';
import { AuthTwoFactorService } from './auth-two-factor.service';
import { AuthTwoFactorController } from './auth-two-factor.controller';
import { JwtModule } from '@nestjs/jwt';
import { JwtConfigService } from '../../utils/Jwt-config-service';
import { TwoFactorStrategy } from './strategy/two-factor.strategy';

@Module({
  providers: [AuthTwoFactorService, TwoFactorStrategy],
  imports: [
    JwtModule.registerAsync({
      useClass: JwtConfigService,
    }),
  ],
  controllers: [AuthTwoFactorController],
})
export class AuthTwoFactorModule {}

# そのほか

  • JwtModule設定用サービス
    ./src/utils/Jwt-config-service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt';

@Injectable()
export class JwtConfigService implements JwtOptionsFactory {
  constructor(private configService: ConfigService) {}
  createJwtOptions(): JwtModuleOptions {
    return {
      secret: this.configService.get<string>('SECRET'),
      signOptions: { expiresIn: '12000s' },
    };
  }
}
  • リクエストの型
    class-validatorを使用していますので、インストールしていない場合は
npm i class-validator

./src/domain/auth-two-factor/dto/auth-tow-facotor-code.request.ts

import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class AuthTowFactorCodeRequest {
  @ApiProperty({ description: 'twoFactorAuthenticationCode', example: '0000' })
  @IsString()
  twoFactorAuthenticationCode: string;
}
  • レスポンスの型
    ./src/domain/admin-auth/dto/response/admin-auth.response.ts
import { ApiProperty } from '@nestjs/swagger';

export class AdminAuthResponse {
  @ApiProperty({ description: 'bearerToken' })
  accessToken: string;
}
export class AdminUserResponse {
  @ApiProperty({ description: 'id' })
  id: string;
  @ApiProperty({ description: 'mail' })
  mail: string;

  @ApiProperty({ description: 'isTwoFactorPassed' })
  isTwoFactorPassed?: boolean;
}

# QR作成

  • ユーザーログイン処理後コード照合に必要なsecret情報をsqliteにて保存する
async generateTwoFactorAuthenticationSecret(user: AdminUserResponse) {
  const secret = authenticator.generateSecret(128);
  const otpauthUrl = authenticator.keyuri(
    user.mail,
    '2 factor authorize',
    secret,
  );
  const dbUser = await this.prisma.user.findFirst({ where: { id: user.id } });
  if (dbUser.isTwoFactorAuthenticationEnabled) {
    throw new UnauthorizedException('already two factor enabled');
  }

  await this.prisma.user.update({
    data: { twoFactorAuthenticationSecret: secret },
    where: { id: user.id },
  });

  return {
    secret,
    otpauthUrl,
  };
}

isTwoFactorAuthenticationEnabledはすでにQRコードおよびコード認証完了したときにtrueになります。
こちらのフラグが立っている場合は、再度QRコードを呼び出すとSECRETの値が変わってしまい、GoogleAuthenticatorの発行するコードと一致しなくなってしまう為、認証済みの場合は制限をかける様にしています。

authenticator.generateSecret(128)でユーザ秘匿情報を作成し、これをデータベースに保存します。
authenticator.keyuriにてGoogleAuthenticatorに読み込ませるURLを作成しアプリで読み込ませるQRコードの画像を生成します。

async qrCodeStreamPipe(stream: Response, otpPathUrl: string) {
  return toFileStream(stream, otpPathUrl);
}

QRコードの画像として直接出力します。

# QRコードを読み込ませる。

  • ユーザログイン後にswaggerを使ってBearerTokenを設定してアクセスすると
    以下の様なQRコードが作成出来るのでこちらをGoogleAuthenticatorに読み込ませます。

# アプリからコードを発行する

  • QRコードを読み込ませると認証コードが発行出来る様になります。

# コードの認証を行う。

  • コードを作成したsecret情報と照合する
async isTwoFactorAuthenticationCodeValid(
  twoFactorAuthenticationCode: string,
  user: AdminUserResponse,
) {
  const dbUser = await this.prisma.user.findUnique({
    where: { id: user.id },
  });
  if (!dbUser) {
    throw new UnauthorizedException('no user exist');
  }
  const verify = authenticator.verify({
    token: twoFactorAuthenticationCode,
    secret: dbUser.twoFactorAuthenticationSecret,
  });
  if (!verify) {
    throw new UnauthorizedException('Wrong authentication code');
  }
  if (!dbUser.isTwoFactorAuthenticationEnabled) {
    await this.prisma.user.update({
      where: { id: dbUser.id },
      data: { isTwoFactorAuthenticationEnabled: true },
    });
  }
  return verify;
}
const verify = authenticator.verify({
  token: twoFactorAuthenticationCode,
  secret: dbUser.twoFactorAuthenticationSecret,
});

認証するコードの値がユーザー固有の情報と照合できるか確認します。
時間が過ぎて期限切れのコードは認証出来ません。
認証が成功した場合は認証済みとしてisTwoFactorAuthenticationEnabledフラグを立てる

if (!dbUser.isTwoFactorAuthenticationEnabled) {
  await this.prisma.user.update({
    where: { id: dbUser.id },
    data: { isTwoFactorAuthenticationEnabled: true },
  });
}

認証に成功後はisTwoFactorPassedフラグを立ててこのフラグが2段階認証突破の印とします。
再度beartokenを作成し、以降はこちらのtokenを使ってアクセスします。

async loginWith2fa(userWithoutPsw: Partial<AdminUserResponse>) {
  const payload = {
    id: userWithoutPsw.id,
    email: userWithoutPsw.mail,
    isTwoFactorPassed: true,
  };
  const accessToken = await this.jwtService.signAsync(payload);
  return { accessToken };
}

# 2段階認証用のガードを作成する

  • 2段階認証後のtokenでなければAPIアクセス不可能にするためのガードを作成します。
    ./src/domain/auth-two-factor/strategy/two-factor.strategy.ts
    基本的には公式で紹介されているstrategyの内容とほぼ同じですが、validate処理のみ
    isTwoFactorPassedの情報を確認してアクセス可否を判断しています。
async validate(payload: any) {
  return payload.isTwoFactorPassed ? payload : null;
}
  • ガードの使用方法
    使用方法はコントローラーからデコレータで呼び出します。
@ApiOperation({ summary: '2factor 認証check' })
@UseGuards(TwoFactorGuard)
@ApiBearerAuth('token')
@Post('2fa/check')
async check(@Req() request) {
  return request.user;
}

これで2段階認証済みのtokenでなければアクセス出来なくなります。

# その他考慮すべき点

# 何からの原因でアプリが使えない状況になった場合

ユーザーがスマフォをなくしてコード認証が使えなくなったケースや、ユーザー固有の秘匿情報が変更されてしまった場合は二度とログインできなくなってしまう恐れがあるので、一度コード認証をリセット出来る様にする仕組みが必要になります。

# ユーザー情報固有の情報

住所や電話番号を表示する画面では必ず2段階認証を行うようにするなど2段階認証後も特にセンシティブなページに対しては都度2段階認証をかける様にしたほうが良いでしょう。