🌱

NestJS + GraphQL + Prisma + MySQL でJWT認証をつくる

2022/01/20に公開

NestJSでGraphQLとPrismaを使ってJWTを使った認証をつくってみます。
DBにはMySQLを使います。

できること

  • GraphQLによるAPI
  • DBのスキーマ定義からObject TypeやInput Typeなどの定義を自動生成
  • GraphQLのスキーマ定義の自動生成
  • ValidationPipeを使った自動的なバリデーション
  • パスワードをハッシュ化して保存
  • メールアドレスとパスワードによる認証(NestJSのデフォルトはユーザ名とパスワード)
  • JWTトークンを使った認証

開発環境構築

VS Codeの拡張機能Remote - Containersを使ってDocker上に開発環境をつくります。

ここではプロジェクトのディレクトリをnest-graphql-prismaとします。
プロジェクトのディレクトリをVS Codeで開き、直下に*.devcontainer*ディレクトリを作成します。

.
└ nest-graphql-prisma/
   └ .devcontainer/

.devcontainerディレクトリに下記ファイルを作成します。

.devcontainer/devcontainer.json
{
    "name": "nest-graphql-prisma",
    "dockerComposeFile": "docker-compose.yml",
    "service": "node",

    "workspaceFolder": "/src",

    "settings": {
    },

    "extensions": [
    ],
}
.devcontainer/docker-compose.yml
version: "3"

services:
    node:
        image: node:16-bullseye
        environment:
          TZ: "Asia/Tokyo"
        ports:
            - "3000:3000"
        volumes:
            - ../:/src
        working_dir: /src
        depends_on:
            - mysql
        command: /bin/sh -c "while sleep 1000; do :; done"

    mysql:
        platform: linux/x86_64
        image: mysql:5.7
        environment:
            MYSQL_DATABASE: nest-db
            MYSQL_USER: nest-user
            MYSQL_PASSWORD: nest-pass
            MYSQL_ROOT_PASSWORD: nest-pass
            TZ: "Asia/Tokyo"
        ports:
            - "33306:3306"
        volumes:
            - db-data:/var/lib/mysql
volumes:
    db-data:

タイムゾーンには"Asia/Tokyo"を設定しています。
ファイルを作成したらVS CodeでRemote-Containers: Reopen in Containerを実行します。

Nest CLIのインストールとNestプロジェクトの作成

下記のコマンドを実行してNest CLIのインストールとNestプロジェクトの作成をします。
今回Nestプロジェクトの名前をapiにしました。

$ npm i -g @nestjs/cli
$ nest new api

下記のように聞かれるので今回はnpmを選択しました。

? Which package manager would you ❤️  to use? (Use arrow keys)
❯ npm 
  yarn 
  pnpm 

インストールが終了後、下記コマンドを実行することでNestJSが起動します。

$ cd api
$ npm run start

起動後、http://localhost:3000にアクセスすると下記のように表示されます。

Hello World!

起動することが確認できたらいったんcontrol + cで終了します。
参考:First steps | NestJS

Prismaのインストール

下記コマンドを実行してPrismaをインストールします。

$ npm install prisma --save-dev

続いて下記コマンドでPrismaの初期設定をします。

$ npx prisma init

api/prisma/schema.prismaが作成されます。
デフォルトではPostgreSQLを使う設定になっているのでMySQLを使うように変更します。

api/prisma/schema.prism
// 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 = "postgresql"
+  provider = "mysql"
  url      = env("DATABASE_URL")
}

また、.envファイルも作成され、DB接続設定が記述されているので、デフォルトの接続設定から今回の接続設定に変更します。

api/.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

- DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+ DATABASE_URL="mysql://root:nest-pass@mysql:3306/nest-db"

参考:Prisma | NestJS

GraphQLのパッケージをインストール

下記コマンドでGraphQLのパッケージをインストールします。

$ npm i @nestjs/graphql graphql@^15 apollo-server-express

インストールが終わったらapi/src/app.module.tsにGraphQLModuleを追加します。

api/src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
+ import { GraphQLModule } from '@nestjs/graphql';
+ import { join } from 'path';

@Module({
-   imports: [],
+   imports: [
+     GraphQLModule.forRoot({
+       autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
+     }),
+   ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

autoSchemaFileのプロパティを設定することでNestJS起動時に自動でGraphQLのスキーマ定義ファイルが作成されるようになります。

参考:Harnessing the power of TypeScript & GraphQL | NestJS

ValidationPipeが使えるように設定

自動的にバリデーションが実行できるようにts:api/src/main.tsにグローバルスコープでValidationPipeを追加します。

api/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
+ import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
+   app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

参考:Global scoped pipes | NestJS

prisma-nestjs-graphqlのインストール

下記コマンドでPrismaのスキーマファイルからObject TypeやInput Typeを生成してくれるprisma-nestjs-graphqlをインストールします。

$ npm install --save-dev prisma-nestjs-graphql

api/prisma/schema.prismにgeneratorを追加します。

api/prisma/schema.prism
// 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 = "mysql"
  url      = env("DATABASE_URL")
}

+ generator nestgraphql {
+   provider = "node node_modules/prisma-nestjs-graphql"
+   output = "../src/@generated/prisma-nestjs-graphql"
+   fields_Validator_from = "class-validator"
+   fields_Validator_input = true
+   decorate_1_type        = "CreateOne*Args"
+   decorate_1_field       = data
+   decorate_1_name        = ValidateNested
+   decorate_1_from        = "class-validator"
+   decorate_1_arguments   = "[]"
+   decorate_2_type        = "CreateOne*Args"
+   decorate_2_field       = data
+   decorate_2_from        = "class-transformer"
+   decorate_2_arguments   = "['() => {propertyType.0}']"
+   decorate_2_name        = Type
+ }

参考:prisma-nestjs-graphql | GitHub

  fields_Validator_from = "class-validator"
  fields_Validator_input = true
  decorate_1_type        = "CreateOne*Args"
  decorate_1_field       = data
  decorate_1_name        = ValidateNested
  decorate_1_from        = "class-validator"
  decorate_1_arguments   = "[]"
  decorate_2_type        = "CreateOne*Args"
  decorate_2_field       = data
  decorate_2_from        = "class-transformer"
  decorate_2_arguments   = "['() => {propertyType.0}']"
  decorate_2_name        = Type

この部分はモデル定義のディレクティブで指定したバリデーションをNestJSのValidationPipeをつかってバリデーションするための設定です。
今回はprisma-nestjs-graphqlによって生成された正規表現CreateOne*Argsにマッチするファイルに対してバリデーションを追加しています。

参考:decorate | prisma-nestjs-graphql | GitHub

Userモデルを定義

api/prisma/schema.prismにUserモデルの定義を追加します。

model User {
  /// @Field(() => ID)
  id          Int       @id @default(autoincrement())
  /// @Validator.@IsEmail()
  email       String    @unique
  /// @Validator.IsNotEmpty()
  name        String
  /// @HideField()
  /// @Validator.MinLength(8)
  password    String
  /// @HideField({ input: true, output: true })
  createdAt DateTime @default(now())
  /// @HideField({ input: true, output: true })
  updatedAt DateTime @updatedAt
}

///(3つのスラッシュ)からはじまるコメントでディレクティブを記述することでprisma-nestjs-graphqlによって生成されるObject TypeやInput Typeにディレクティブを追加できます。

passwordは@HideField()で出力不可、createdAtとupdatedAtは入出力不可にしています。

また、ここでは下記のようにバリデーションを設定しています。

フィールド名 バリデーション
email IsEmail()
name IsNotEmpty()
password MinLength(8)

参考:https://github.com/unlight/prisma-nestjs-graphql#field-settings
参考:https://github.com/typestack/class-validator#validation-decorators

Prismaクライアントを生成

下記コマンドでPrismaクライアントを生成します。
初回実行時に@prisma/clientパッケージがインストールされていない場合はインストールも実行されます。

$ npx prisma generate

api/src/@generated/prisma-nestjs-graphql配下にObject TypeやInput Type等のファイルが生成されます。
モデルに変更があった場合は必ずこのコマンドを実行します。

このコマンドによって出力されたObject Typeは下記のようになります。

api/src/@generated/prisma-nestjs-graphql/user/user.model.ts
import { Field } from '@nestjs/graphql';
import { ObjectType } from '@nestjs/graphql';
import { ID } from '@nestjs/graphql';
import { HideField } from '@nestjs/graphql';

@ObjectType()
export class User {

    @Field(() => ID, {nullable:false})
    id!: number;

    @Field(() => String, {nullable:false})
    email!: string;

    @Field(() => String, {nullable:false})
    name!: string;

    @HideField()
    password!: string;

    @HideField()
    createdAt!: Date;

    @HideField()
    updatedAt!: Date;
}

また、Input Typeは下記のようになります。

api/src/@generated/prisma-nestjs-graphql/user/user-create.input.ts
import { Field } from '@nestjs/graphql';
import { InputType } from '@nestjs/graphql';
import * as Validator from 'class-validator';
import { HideField } from '@nestjs/graphql';

@InputType()
export class UserCreateInput {

    @Field(() => String, {nullable:false})
    @Validator.IsEmail()
    email!: string;

    @Field(() => String, {nullable:false})
    @Validator.IsNotEmpty()
    name!: string;

    @Field(() => String, {nullable:false})
    @Validator.MinLength(8)
    password!: string;

    @HideField()
    createdAt?: Date | string;

    @HideField()
    updatedAt?: Date | string;
}

Prisma Clientサービスの追加

src配下にprisma.service.tsを作成し、下記のようにします。

api/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();
    });    
  }
}

api/src/app.module.tsのprovidersに作成したPrisma Clientサービスを追加します。

api/src/app.module.tsのproviders
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
+ import { PrismaService } from './prisma.service';

@Module({
  imports: [
    GraphQLModule.forRoot({
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
    }),
  ],
  controllers: [AppController],
-  providers: [AppService],
+  providers: [AppService, PrismaService],
})
export class AppModule {}

参考:Use Prisma Client in your NestJS services | NestJS

マイグレーション

下記コマンドでマイグレーションを実行します。

$ npx prisma migrate dev --name init

マイグレーションを実行した日時でそのマイグレーションの名前がつきますが、--nameに指定することで名前を追加できます。
マイグレーションを実行するとapi/prisma/migrations/20220113135647_init/migration.sqlのようなファイルが作成されますが、今回--name initとしているのでディレクトリ名に20220113135647_initがついています。

bcryptのインストール

パスワードをハッシュ化して保存するためにbcryptをインストールします。

$ npm i bcrypt
$ npm i -D @types/bcrypt

参考:Hashing | NestJS

Users Module、Service、Resolverの作成

下記のコマンドでUsers Module、Service、Resolverを作成します。

$ npx nest generate module users
$ npx nest generate service users
$ npx nest generate resolver users

Users Moduleを編集

PrismaServiceを使用するので下記のようにUsers Moduleを編集します。
また、

api/src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersResolver } from "./users.resolver";
+ import { PrismaService } from 'src/prisma.service';

@Module({
-   providers: [UsersService, UsersResolver],
+   providers: [UsersService, UsersResolver, PrismaService],
+   exports: [UsersService]
})
export class UsersModule {}

後で作成するAuthServiceでUserServiceを使えるようにexports: [UsersService]も追加しています。

Users Serviceを編集

下記のようにUsers Serviceを編集します。

api/src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma.service';
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model'
import { FindFirstUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/find-first-user.args';
import { CreateOneUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/create-one-user.args';
import { FindUniqueUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/find-unique-user.args';

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

    async findFirst(args: FindFirstUserArgs): Promise<User | null> {
        return this.prisma.user.findFirst(args);
    }

    async findUnique(args: FindUniqueUserArgs): Promise<User | null> {
        return this.prisma.user.findUnique(args);
    }

    async createUser(args: CreateOneUserArgs): Promise<User> {
        return this.prisma.user.create(args);
    }
}

findFirstは条件にあうUserを1件取得、findUniqueはユニークキー(ここではidかemail)によってUserを1件取得、createUserはUserを1件作成するメソッドです。

Users Resolverを編集

api/src/users/users.resolver.tsを下記のように編集し、prisma-nestjs-graphqlで生成されたObject TypeやArgsを使ってUserの参照・登録の処理を追加します。

api/src/users/users.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import * as bcrypt from 'bcrypt';
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model'
import { CreateOneUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/create-one-user.args';
import { UsersService } from 'src/users/users.service';
import { FindFirstUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/find-first-user.args';

@Resolver(() => User)
export class UsersResolver {
    constructor(private readonly userService: UsersService) {}

    @Query(() => User)
    user(
        @Args() args: FindFirstUserArgs
    ) {
        return this.userService.findFirst(args)
    }

    @Mutation(() => User)
    async createUser(
        @Args() args: CreateOneUserArgs
    ) {
        args.data.password = await bcrypt.hash(args.data.password, 10);
        return this.userService.createUser(args);
    }
}

ここではbcryptを使い、ラウンド数を10でパスワードをハッシュ化しています。

User登録の動作確認

Userの登録ができるか動作確認をします。
下記コマンドでNestJSのサーバーを起動させます。

$ npm run start

サーバーが起動後http://localhost:3000/graphqlにアクセスするとGraphQL playgroundが表示されます。

参考:GraphQL playground | NestJS

下記を入力してユーザーを登録してみます。

mutation {
  createUser(
    data: {
        email: "test@example.com",
        name: "test",
        password: "password12345",
    }
  ) {
    id
    email
    name
  }
}

登録に問題がなければ下記のように表示されます。

{
  "data": {
    "createUser": {
      "id": "1",
      "email": "test@example.com",
      "name": "test"
    }
  }
}

User登録の動作確認

下記コマンドを実行するとPrismaをGUIで操作できるPrisma Studioが起動します。

$ npx prisma studio

Prisma Studio起動後、http://localhost:5556/にアクセスするとデータベースをGUIで操作できます。
Userテーブルを選択して登録したデータを表示すると、パスワードがハッシュ化されて登録されていることが確認できます。

登録されたUserをPrisma Studioで確認

バリデーションが実行されるか、下記のようにすべての項目を空にして確認します。

mutation {
  createUser(
    data: {
        email: "",
        name: "",
        password: "",
    }
  ) {
    id
    email
    name
  }
}

バリデーションがうまく実行されると下記の結果のようにエラーが返ってきます。

{
  "errors": [
    {
      "message": "Bad Request Exception",
      "extensions": {
        "code": "BAD_USER_INPUT",
        "response": {
          "statusCode": 400,
          "message": [
            "data.email must be an email",
            "data.name should not be empty",
            "data.password must be longer than or equal to 8 characters"
          ],
          "error": "Bad Request"
        }
      }
    }
  ],
  "data": null
}

Passportのインストール

下記コマンドで認証に使うPassportをインストールします。

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local

参考:Authentication requirements | NestJS

下記コマンドでJWTを操作するためのパッケージもインストールします。

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

参考:JWT functionality | NestJS

JWTのシークレットキーを設定

JWTで署名に使うシークレットキーを設定します。
ここでは下記のコマンドを実行してシークレットキーを生成します。

node -e "console.log(require('crypto').randomBytes(256).toString('base64'));"

下記のシークレットキーが生成されました。

a1bfWJnEP4Gfh3LSLw+vLAYr7+0N+/oo4ycpfpqfSBYdcWjS1ch/I69a1sxhpSj4gcKbNCe38nTabXxAUWWNDUm73HKhqEU2+oeeLFZ38oA5+qGf2hZmkFb4vHUq/Z0gdsPL2bhhW5fyOPt5YuQke42jLERMa8UmkzAaSY6WBYxIhxJH2Gsm+Af/zMy+76jsKztEdimPVZOYA0OThiiJXJP18K7EIwNKUxmWKW3W9ajGTGkOW6djf8qsWpfkiSoFaMJx2OXmeS9UCtAbotEa89FsBRwGuiPBxzvG89rqgBVDK8YTXXwQFn6/d7EpuMtK+6Kr5Y1ROo07GRA+MqkeAw==

生成したシークレットキーをapi/.envに追加します。

api/.env
JWT_SECRET=a1bfWJnEP4Gfh3LSLw+vLAYr7+0N+/oo4ycpfpqfSBYdcWjS1ch/I69a1sxhpSj4gcKbNCe38nTabXxAUWWNDUm73HKhqEU2+oeeLFZ38oA5+qGf2hZmkFb4vHUq/Z0gdsPL2bhhW5fyOPt5YuQke42jLERMa8UmkzAaSY6WBYxIhxJH2Gsm+Af/zMy+76jsKztEdimPVZOYA0OThiiJXJP18K7EIwNKUxmWKW3W9ajGTGkOW6djf8qsWpfkiSoFaMJx2OXmeS9UCtAbotEa89FsBRwGuiPBxzvG89rqgBVDK8YTXXwQFn6/d7EpuMtK+6Kr5Y1ROo07GRA+MqkeAw==

参考:Hapi Auth using JSON Web Tokens (JWT) | GitHub

ログインに使うInputTypeとObjectTypeを作成

今回ログインの認証にはメールアドレスとパスワードを使うので、api/src/auth/dtoディレクトリを作成し、その中にファイル名をlogin-user.input.tsでログイン用のInputTypeを下記のように作成します。

api/src/auth/dto/login-user.input.ts
import { Field, InputType } from "@nestjs/graphql";

@InputType()
export class LoginUserInput {
    @Field()
    email: string

    @Field()
    password: string
}

ログイン成功時のレスポンスにJWTのトークンとUserを返すのでapi/src/auth/dtoディレクトリにlogin-response.tsを作成し下記のようにObjectTypeを記述します。

api/src/auth/dto/login-response.ts
import { Field, ObjectType } from "@nestjs/graphql";
import { User } from "src/@generated/prisma-nestjs-graphql/user/user.model";

@ObjectType()
export class LoginResponse {
    @Field()
    access_token: string;

    @Field(() => User)
    user: User;
}

Auth Module、Service、Resolverの作成

下記コマンドでAuth Module、Service、Resolverを作成します。

$ npx nest generate module auth
$ npx nest generate service auth
$ npx nest generate resolver auth

AuthServiceを編集

api/src/auth/auth.service.tsを下記のように編集します。

api/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from 'src/users/users.service';
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model';
import * as bcrypt from 'bcrypt';
import { LoginResponse } from 'src/auth/dto/login-response';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async validateUser(email: string, password: string): Promise<User | null> {
    const user = await this.usersService.findUnique({where: {email: email}});

    if (user && bcrypt.compareSync(password, user.password)) {
      return user;
    }
    
    return null;
  }

  async login(user: User): Promise<LoginResponse> {
    const payload = { email: user.email, sub: user.id };

    return {
      access_token: this.jwtService.sign(payload),
      user: user
    };
  }
}

validateUserではUsersServiceのfindUniqueで入力されたemailからUserを取得しています。
bcrypt.compareSyncで入力されたパスワードとハッシュ化されたパスワードが正しいか比較しています。

loginでは認証を通ったUserにJWTトークンを発行しています。

LocalStrategyの作成

メールアドレスとパスワードによる認証のためのLocalStrategyを作成します。
api/src/auth/strategiesディレクトリを作成し、下記のようにlocal.strategy.tsを作成します。

api/src/auth/strategies/local.strategy.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { User } from "src/@generated/prisma-nestjs-graphql/user/user.model";
import { AuthService } from "src/auth/auth.service";

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
    constructor(private readonly authService: AuthService) {
        super({ usernameField: 'email' })
    }

    async validate(email: string, password: string): Promise<User> {
        const user = this.authService.validateUser(email, password);

        if (!user) {
            throw new UnauthorizedException();
        }

        return user;
    }
}

デフォルトではusernameフィールドとpasswordフィールドで認証するようになっているので、下記部分でemailフィールドを使うように変更しています。

super({ usernameField: 'email' })

認証処理のvalidateではAuthServiceのvalidateUserでUserが取得できない場合はUnauthorizedExceptionの例外がでるようになっています。

GqlAuthGuardの作成

api/src/auth/guardsディレクトリを作成し、下記のようにLocalStrategy用のgql-auth.guard.tsを作成します。

api/src/auth/guards/gql-auth.guard.ts
import { ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";

export class GqlAuthGuard extends AuthGuard('local') {
    constructor() {
        super();
    }

    getRequest(context: ExecutionContext): any {
        const ctx = GqlExecutionContext.create(context);
        const request = ctx.getContext();
        request.body = ctx.getArgs().loginUserInput;
        return request;
    }
}

AuthGuardクラスはREST用なのでGraphQLでも使えるようにオーバーライドしています。
リクエストの形式もLoginUserInputにしています。

JwtStrategyの作成

メールアドレスとパスワードによる認証後に発行されたJWTトークンを使った認証のためのJwtStrategyを作成します。
api/src/auth/strategiesディレクトリにjwt.strategy.tsを作成し下記のようにします。

api/src/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from "passport-jwt";
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(private readonly usersService: UsersService) {
        super({
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
            ignoreExpiration: false,
            secretOrKey: process.env.JWT_SECRET,
        })
    }

    async validate(payload: { email: string, sub: string }): Promise<User | null> {
        return this.usersService.findUnique({where: {email: payload.email}});
    }
}

secretOrKeyには生成したprocess.env.JWT_SECRETを設定しています。
validateではJWTトークンからemailを取得し、それを使ってUserを取得しています。

JwtAuthGuardの作成

LocalStrategyと同様にJwtStrategyのAuthGuardクラスもREST用なのでGraphQLでも使えるようにオーバーライドします。
api/src/auth/guardsにjwt-auth.guard.tsを作成し下記のようにします。

api/src/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    getRequest(context: ExecutionContext) {
        const ctx = GqlExecutionContext.create(context);
        return ctx.getContext().req;
    }
}

AuthModuleの編集

下記のようにapi/src/auth/auth.module.tsを編集します。

api/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from 'src/users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from "@nestjs/jwt";
import { AuthResolver } from './auth.resolver';
import { LocalStrategy } from 'src/auth/strategies/local.strategy';
import { JwtStrategy } from 'src/auth/strategies/jwt.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '1h' }
    })
  ],
  providers: [AuthService, AuthResolver, LocalStrategy, JwtStrategy]
})
export class AuthModule {}

secretには生成したprocess.env.JWT_SECRET、有効期限には1時間を設定しています。

AuthResolverの編集

下記のようにapi/src/auth/auth.resolver.tsを編集します。

api/src/auth/auth.resolver.ts
import { UseGuards } from '@nestjs/common';
import { Resolver,  Mutation, Args, Context } from '@nestjs/graphql';
import { AuthService } from 'src/auth/auth.service';
import { LoginResponse } from 'src/auth/dto/login-response';
import { LoginUserInput } from 'src/auth/dto/login-user.input';
import { GqlAuthGuard } from 'src/auth/guards/gql-auth.guard';

@Resolver()
export class AuthResolver {
    constructor(private readonly authService: AuthService) {}

    @Mutation(() => LoginResponse)
    @UseGuards(GqlAuthGuard)
    async login(
        @Args('loginUserInput') loginUserInput: LoginUserInput,
        @Context() context
    ) {
        return this.authService.login(context.user)
    }
}

loginのデコレータにメールアドレスとパスワードでの認証の@UseGuards(GqlAuthGuard)をつけています。

UserResolverの編集

認証されたUserのみがuserを実行できるように下記を追加します。

api/src/users/users.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import * as bcrypt from 'bcrypt';
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model'
import { CreateOneUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/create-one-user.args';
import { UsersService } from 'src/users/users.service';
import { FindFirstUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/find-first-user.args';
+ import { UseGuards } from '@nestjs/common';
+ import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';

@Resolver(() => User)
export class UsersResolver {
    constructor(private readonly userService: UsersService) {}

    @Query(() => User)
+     @UseGuards(JwtAuthGuard)
    user(
        @Args() args: FindFirstUserArgs
    ) {
        return this.userService.findFirst(args)
    }

    @Mutation(() => User)
    async createUser(
        @Args() args: CreateOneUserArgs
    ) {
        args.data.password = await bcrypt.hash(args.data.password, 10);
        return this.userService.createUser(args);
    }
}

ログインの動作確認

ログインの動作確認をするため、下記コマンドでNestJSのサーバーを起動させます。

$ npm run start

サーバーが起動後http://localhost:3000/graphqlにアクセスします。
User登録の動作確認で作成したUserを使ってログインしてみます。

mutation {
  login(
    loginUserInput: {
      email: "test@example.com", 
      password: "password12345"
    }
  ) {
    user {
      name,
    	email
    },
    access_token
  }
}

下記のように表示されれば成功です。

{
  "data": {
    "login": {
      "user": {
        "name": "test",
        "email": "test@example.com"
      },
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTY0MjYwMzgxNywiZXhwIjoxNjQyNjA3NDE3fQ.qKZ2iJjkVVye46RpJbKu5_QBt90mHr7nz2rrKNk33rk"
    }
  }
}

JWT認証の動作確認

取得したJWTトークンを使用して認証できるか確認します。
下記を入力して実行します。

query {
  user(where: {email: {equals: "test@example.com"}}) {
    id
    email
    name
  }
}

JWTトークンを設定していないので下記のように認証エラーが表示されます。

{
  "errors": [
    {
      "message": "Unauthorized",
      "extensions": {
        "code": "UNAUTHENTICATED",
        "response": {
          "statusCode": 401,
          "message": "Unauthorized"
        }
      }
    }
  ],
  "data": null
}

GraphQL playgroundの左下にあるHTTP HEADERSをクリックして下記のように入力します。

{
  "Authorization": "Bearer ログインで取得したJWTトークン"
}

入力後、再度実行し、下記のように表示されれば成功です。

{
  "data": {
    "user": {
      "id": "1",
      "email": "test@example.com",
      "name": "test"
    }
  }
}

認証処理の流れのまとめ

メールアドレスとパスワードを使った認証の流れは

  1. メールアドレスとパスワードを入力してloginをリクエスト
  2. AuthResolverのloginのデコレータの@UseGuards(GqlAuthGuard)が実行
  3. @UseGuards(GqlAuthGuard)がLocalStrategyのvalidateを実行
  4. LocalStrategyのvalidateがAuthServiceのvalidateUserを実行
  5. validateUserでメールアドレスとパスワードをチェック
  6. 認証されたUserがContextのuserに渡される
  7. AuthResolverのloginに戻る
  8. Contextのuserに渡されたUserを使ってAuthServiceのloginを実行
  9. 渡されたUserの情報からJWTトークンを取得
  10. 取得したJWTトークンとともにUserを返却

となります。

また、JWTトークンでの認証の流れば

  1. メールアドレスとパスワードを使った認証で取得したJWTトークンを使ってuserをリクエスト
  2. UsersResolverのuserのデコレータの@UseGuards(JwtAuthGuard)が実行
  3. @UseGuards(JwtAuthGuard)がJwtStrategyのvalidateを実行
  4. JWTトークンに問題がなければJWTトークンに含まれる情報からUserを取得
  5. RequestのuserにUserを渡す
  6. UsersResolverのuser戻る

となります。

今回の使用した各種バージョン

[System Information]
OS Version     : Linux 5.10
NodeJS Version : v16.13.1
NPM Version    : 8.1.2 

[Nest CLI]
Nest CLI Version : 8.1.8 

[Nest Platform Information]
platform-express version : 8.2.4
schematics version       : 8.0.5
passport version         : 8.1.0
graphql version          : 9.1.2
testing version          : 8.2.4
common version           : 8.2.4
core version             : 8.2.4
jwt version              : 8.0.0
cli version              : 8.1.8

[Database]
MySQL : 5.7
Prisma Client : 3.8.0

まとめ

探してもやりたい構成を網羅した解説がなかったのでやってみました。
それなりに実用的にはなってると思います。
prisma-nestjs-graphqlがいろいろ自動生成してくれて便利。

続き

https://zenn.dev/mseto/articles/nest-graphql-jwt-refresh

Discussion