NestJSを使って、認証が通ったリクエストのみレスポンスを返すルートを作成してみたい。
TL;DR
今回作ってみたアプリのリポジトリはこちらから見れますー。適当なのでよろしくお願いします。。。
準備
まずプロジェクトを作ろう。プロジェクト名は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を編集して、providers
にPrismaService
を追加する。
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を利用して認証機構作っていく。
アイデアとしては、/users
にPOST
メソッドを送信してトークンを取得し、/users
にGET
メソッドを送信する時に、先ほど取得したトークンをBearerトークンとしてヘッダーに設定する。
どんなストラテジーを選択するにしても、必ず@nestjs/passport
、passport
パッケージをインストールしないといけない。
@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でAuthModule
をimports
配列に追加しないと同様のエラーが発生するので、この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にアクセスして、トークンを準備します。
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アプリを作ってみたい。