たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

NestJSでPrismaとPassportを組み合わせていろいろ試してみる

NestJSを使って、認証が通ったリクエストのみレスポンスを返すルートを作成してみたい。

TL;DR

今回作ってみたアプリのリポジトリはこちらから見れますー。適当なのでよろしくお願いします。。。

github.com

準備

まずプロジェクトを作ろう。プロジェクト名はnest-jwt-appとかにしておこうか。

nest new nest-jwt-app

Prisma

では、Prismaを導入してみよう。データベースにはSQLiteを使うことにする。

npm install prisma
npx prisma init

作成されたschema.prismaを編集して、SQLiteを扱えるようにする。

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

prismaフォルダの中にdev.dbファイルを作成する。

touch prisma/dev.db

.envにDATABASE_URL環境変数を追加する。

DATABASE_URL="file:./dev.db"

schema.prismaを編集して、Userモデルを追加する。

// prisma/schema.prisma
model User {
  id    String @id @default(cuid())
  email String @unique

  name    String

  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  isDeleted Boolean   @default(false) @map("is_deleted")
  deletedAt DateTime? @map("deleted_at")

  @@map("users")
}

作成したモデルの内容をデータベースに反映させる。

npx prisma db push

これで自動的に@prisma/clientがインストールされる。

Prismaサービスを実装する

PrismaClientのインスタンスを作成し、データベースへの接続を行ってくれるPrismaServiceを作成する。

// src/prisma.service.ts
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on('beforeExit', async () => {
      await app.close();
    });
  }
}
Usersサービスを作成する

nest g resourceを実行してusersモジュールを作成する。

作成されたusers.service.tsを編集してPrismaServiceをインジェクトするように修正する。

// src/users/users.service.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { User, Prisma } from '@prisma/client';

@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  async create(data: Prisma.UserCreateInput): Promise<User> {
    return this.prisma.user.create({
      data,
    });
  }

  async findAll(params: {
    skip?: number;
    take?: number;
    cursor?: Prisma.UserWhereUniqueInput;
    where?: Prisma.UserWhereInput;
    orderBy?: Prisma.UserOrderByWithRelationInput;
  }): Promise<User[]> {
    const { skip, take, cursor, where, orderBy } = params;
    return this.prisma.user.findMany({
      skip,
      take,
      cursor,
      where,
      orderBy,
    });
  }

  async findOne(
    userWhereUniqueInput: Prisma.UserWhereUniqueInput,
  ): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: userWhereUniqueInput,
    });
  }

  update(params: {
    where: Prisma.UserWhereUniqueInput;
    data: Prisma.UserUpdateInput;
  }): Promise<User> {
    const { where, data } = params;
    return this.prisma.user.update({
      data,
      where,
    });
  }

  remove(where: Prisma.UserWhereUniqueInput): Promise<User> {
    return this.prisma.user.delete({
      where,
    });
  }
}

users.module.tsを編集して、providersPrismaServiceを追加する。

import { Module } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  controllers: [UsersController],
  providers: [UsersService, PrismaService],
})
export class UsersModule {}
Usersコントローラーを編集する

users.controller.tsを編集して、更新したUsersServiceのメソッドを活用できるようにする。

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create({
      email: createUserDto.email,
      name: createUserDto.name,
    });
  }

  @Get()
  findAll() {
    return this.usersService.findAll({
      skip: 0,
    });
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne({
      id,
    });
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update({
      where: {
        id: updateUserDto.id,
      },
      data: {
        email: updateUserDto.email,
        name: updateUserDto.name,
      },
    });
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.usersService.remove({
      id,
    });
  }
}

ここで、サーバーを立ち上げてみる。

npm run start:dev

うまく動いたら、リクエストが通るかを確認する。

curl -X GET http://localhost:3000/users

Passport

Passportを利用して認証機構作っていく。

イデアとしては、/usersPOSTメソッドを送信してトークンを取得し、/usersGETメソッドを送信する時に、先ほど取得したトークンをBearerトークンとしてヘッダーに設定する。

どんなストラテジーを選択するにしても、必ず@nestjs/passportpassportパッケージをインストールしないといけない。

  • @nestjs/passport
  • passport
npm install --save @nestjs/passport passport

あとは、個別のストラテジーに合わせてパッケージをインストールする。

  • passport-local
    • @types/passport-local
npm install --save passport-local
npm install --save-dev @types/passport-local
  • passport-jwt
    • @types/passport-jwt
npm install --save passport-jwt
npm install --save-dev @types/passport-jwt

Authモジュールを作成する

Authモジュールを作成してみる。

nest g module auth

作成されたauthフォルダの中にAuthサービスを作成してみる。

nest g service auth

JWTのストラテジークラスを作成する

JWTのストラテジークラスを実装するために必要なパッケージをインストールする。

  • @nestjs/jwt
  • passport-jwt
    • @types/passport-jwt
npm install --save @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt

ストラテジー用のクラスはPassportStrategyクラスを継承する。 constructorの中で必ずsuperメソッドを呼び出す。

ストラテジーによっては設定オプションを必要とするものもある。その場合はsuperメソッドを呼び出す際に引数として渡す。

そしてvalidateメソッドを実装することで、@UseGuards(AuthGuard('ストラテジー名'))をコントローラーで指定した際に、そのデコレーターを指定したリクエストが発生したタイミングで、毎回このストラテジーvalidateメソッドが呼び出されるようになる。

このvalidateの中で認証等を行い、有効であると判断された場合にユーザーのデータを返し、失敗した場合はnullを返すことが期待されている。

// src/auth/jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtConstants } from '../constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-strategy') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

PassportStrategyの第二引数に一意なストラテジー名を渡すことができる。 これにより、@UseGuards(AuthGuard('ストラテジー名'))を呼び出す際にその名前を渡すことで、このストラテジーvalidateメソッドが呼び出されるようになる。

ちなみに、名前を渡さない場合はデフォルトのjwtが使用される。

そして、@Moduleデコレーターのproviders配列に作成したストラテジーを追加する必要がある。

import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
...

@Module({
  ...
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

この設定を忘れてしまうと、jwt-strategyと関連するストラテジーを解決することができず、エラーが起きてしまう。あと、app.module.tsでAuthModuleimports配列に追加しないと同様のエラーが発生するので、この2箇所では絶対に設定を忘れないようにする。

ちなみに、validateメソッドの返り値はRequestオブジェクトにセットされコントローラー内でreq.userで参照可能になる。

そして、auth.module.tsのimports配列にJwtModule.registerの返り値を追加する。設定オブジェクトのsecretには、JwtStrategyで使用しているキーと同じ値を渡す。有効期限はお好みで設定する。

// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
JWTのGuardを実装する

@UseGuards(AuthGuard('ストラテジー名'))で呼び出してもいいが、公式ドキュメントでは@UseGuardsにクラスを渡すことが推奨されているので独自のクラスを作成する。

ストラテジーのGuardを実装する場合は、@nestjs/passportからエクスポートされているAuthGuardメソッドにストラテジーの名前を渡して返ってきた値を継承させる必要がある。

// src/auth/guards/jwt-auth.guard.ts

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt-strategy') {}

実際にGuardを利用する場合は@UseGuardsデコレーターを使用して、先ほど実装したクラスをそのまま渡す。

// src/users/users.controller.ts
...
  @UseGuards(JwtAuthGuard)
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne({
      id,
    });
  }
...

これで、/users/:idにリクエストが来た場合に、JWTストラテジーvalidateメソッドが呼び出される。

トークンを用意する

jwt.ioにアクセスして、トークンを準備します。

jwt.io

subの部分に、データベースに登録されているユーザーのUIDを指定します。

your-256-bit-secretの部分にauth/constants.tsに設定したキーを入力します。

Encodedに表示されているトークンを控えておく。

実際にリクエストを送る

curlを使ってリクエストを送ってみる。

curl http://localhost:3000/users/cl59uzu560000z3zz7rsh1sx4 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjbDU5dXp1NTYwMDAwejN6ejdyc2gxc3g0IiwiaWF0IjoxNTE2MjM5MDIyfQ.wFx0YKWte1oaV68i9jQbKWTwfJ5jLtrTefrZ_wvgJMI"

{"statusCode":500,"message":"Internal server error"}のようなエラーではなくて、{"id":"cl59uzu560000z3zz7rsh1sx4","email":"example@example.com","name":"田中太郎","createdAt":"2022-07-06T17:13:33.787Z","updatedAt":"2022-07-06T17:13:33.787Z","isDeleted":false,"deletedAt":null}のような結果が帰って来れば成功。

将来的には、BFFとして作ったFirebase Functionsの関数からの問い合わせに対応するNestJSアプリを作ってみたい。