🐈

NestJSでGraphQLとPrismaとPassportの素振り

島袋恵2022/11/05に公開

はじめに

NestJSで、GraphQL、Prisma、Passportの素振りをしたまとめ記事です。

  • NestJSとPrismaの連携でDBにアクセスする
  • NestJSでGraphQLを利用してデータの取得更新する
  • NestJSとPassportの連携して認証、認可の処理実装する(Cookie / Session による認証)

はじめてNestJSを触ってみたときに認証やGraphQLなどを試したいと思っている人の参考になれば幸いです。

https://github.com/shimabukuromeg/sample-nestjs

試してみるぞ

以下の順番で試していきます。

  1. NestJSのプロジェクト作成
  2. Prisma
  3. GraphQL
  4. Passport

1. NestJSのプロジェクト作成

まずはNestJSからはじめます。プロジェクト作成 & 起動!!!

$ npm i -g @nestjs/cli
$ nest new sample-nestjs # 今回は yarn を選択しました
$ cd sample-nestjs
$ yarn run start

2. Prisma

続きまして、Prisma を使ってDBにアクセスできるようにしてみます。

まずは必要なパッケージを導入します。

$ yarn add -D prisma typescript ts-node @types/node

以下のコマンドを実行して、初期化します。この際、prisma/schema.prisma.env ファイルが自動で作成されます。

$ yarn prisma init

ローカル環境でpostgresを使えるようにしたいので docker-comose.yml を用意します。

docker-compose.yml

docker-compose.yml

version: "3"

volumes:
  db-data:

services:
  db:
    image: postgres:14
    container_name: sample-nestjs
    volumes:
      - db-data:/var/lib/postgresql/sample-nestjs/data
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password

起動

$ docker compose up

立ち上げたDBに接続できるようにするため.env の接続情報を修正します。

.env

.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#accessing-environment-variables-from-the-schema

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

DATABASE_URL="postgresql://postgres:password@localhost:5432/sample-nestjs?schema=public"

DBにテーブルを作成してみます。prisma/schema.prisma を編集してPostスキーマを追加してみます。

prisma/schema.prisma

prisma/schema.prisma

// 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"
  url      = env("DATABASE_URL")
}

model Post {
  id        String   @id @default(uuid())
  title     String
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("posts")
}

prisma/schema.prisma を編集したら、以下のコマンドを実行します。このコマンドは、マイグレーションファイルの作成 & 変更をDBに反映してくれます。

$ yarn prisma migrate dev --name init
実行時ログ
$ yarn prisma migrate dev --name init
yarn run v1.22.19
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "sample-nestjs", schema "public" at "localhost:5432"

PostgreSQL database sample-nestjs created at localhost:5432

Applying migration `20221031030935_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20221031030935_init/
    └─ migration.sql

Your database is now in sync with your schema.

Running generate... (Use --skip-generate to skip the generators)
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
warning " > ts-loader@9.4.1" has unmet peer dependency "webpack@^5.0.0".
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 2 new dependencies.
info Direct dependencies
└─ @prisma/client@4.5.0
info All dependencies
├─ @prisma/client@4.5.0
└─ @prisma/engines-version@4.5.0-43.0362da9eebca54d94c8ef5edd3b2e90af99ba452

✔ Generated Prisma Client (4.5.0 | library) to ./node_modules/@prisma/client in 104ms


✨  Done in 13.84s.

Prismaには便利なクライアントツールが用意されています。prisma studioを実行し起動してみます。

$ yarn prisma studio

ツールが起動するのでアクセスしてみるとテーブルできてることを確認できます🥳

以下のコマンドを実行すると、prismaクライアントの型情報などを自動生成してくれます。(node_modules/.prisma/client/index.d.ts が更新)

$ yarn prisma generate

続いて、seedのデータを作成、登録してみます。seederを流すためのコマンドを package.json に追記し、実行するスクリプトprisma/seed.ts を作成します。

// 追記
"prisma": {
  "seed": "ts-node prisma/seed.ts"
}
prisma/seed.ts

prisma/seed.ts

import { Post, Prisma, PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();

// モデル投入用のデータ定義
const postData: Post[] = [
  {
    id: "fa119cb6-9135-57f5-8a5a-54f28d566d0e",
    title: "タイトル1",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
  {
    id: "fa119cb6-9135-57f5-8a5a-54f28d566d0b",
    title: "タイトル2",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
  {
    id: "fa119cb6-9135-57f5-8a5a-54f28d566d0c",
    title: "タイトル3",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
];

const doPostSeed = async () => {
  const posts = [];
  for (const post of postData) {
    const createPosts = prisma.post.create({
      data: post,
    });
    posts.push(createPosts);
  }
  return await prisma.$transaction(posts);
};

const main = async () => {
  console.log(`Start seeding ...`);

  await doPostSeed();

  console.log(`Seeding finished.`);
};

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

登録したコマンドを実行

$ yarn prisma db seed

データが入った🥳

続いて、NestJSからPrismaのクライアントを呼んでみます。

調べてみたところ、NestJSは、Module単位でアプリケーションを組み立ていくのを強くお勧めしています。そのため、Prisma用のモジュールを作成したほうが良さそうだなと思ったりしつつも、今回はいったんサクッと試してみたかったので、Prismaのドキュメントに書かれてるお手軽そうだったコードを参考に実装してみます。

https://www.prisma.io/nestjs#nestjs-tabs

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

src/app.module.ts のProviderに PrismaService を登録します。

import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { PrismaService } from "src/prisma.service";

@Module({
  controllers: [AppController],
  providers: [AppService, PrismaService],
})
export class AppModule {}

src/app.service.tsPrismaServiceを使ってPost一覧を取得します。

import { Injectable } from "@nestjs/common";
import { PrismaService } from "./prisma.service";

@Injectable()
export class AppService {
  constructor(private readonly prismaService: PrismaService) {}

  async getPosts() {
    // post一覧取得
    return await this.prismaService.post.findMany();
  }
}

src/app.controller.ts で、getPosts が呼ばれるように修正します。

import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get("/posts")
  getPosts() {
    return this.appService.getPosts();
  }
}

curlで 動作確認してみるとデータ返ってくることが確認できます🥳

$ curl http://localhost:3000/posts
[{"id":"fa119cb6-9135-57f5-8a5a-54f28d566d0e","title":"タイトル1","createdAt":"2022-01-30T19:34:22.000Z","updatedAt":"2022-01-30T19:34:22.000Z"},{"id":"fa119cb6-9135-57f5-8a5a-54f28d566d0b","title":"タイトル2","createdAt":"2022-01-30T19:34:22.000Z","updatedAt":"2022-01-30T19:34:22.000Z"},{"id":"fa119cb6-9135-57f5-8a5a-54f28d566d0c","title":"タイトル3","createdAt":"2022-01-30T19:34:22.000Z","updatedAt":"2022-01-30T19:34:22.000Z"}]%

3. GraphQL

続いて、GraphQLを動かしてみます。GraphQLのバックエンドは、スキーマファーストとコードファーストの2つのパターンの開発方法がありますが、今回は、コードファーストで実装してみます。

必要なパッケージを導入します。

$ yarn add @nestjs/graphql @nestjs/apollo graphql apollo-server-express

GraphQLのモジュールを app.module.ts のimportsに追加します。

import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { PrismaService } from "src/prisma.service";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { GraphQLModule } from "@nestjs/graphql";
import * as path from "path";

@Module({
  controllers: [AppController],
  providers: [AppService, PrismaService],
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      debug: false,
      // いったん、playground は有効にしておく
      playground: true,
      // 自動生成されるスキーマのファイルを指定する
      autoSchemaFile: path.join(process.cwd(), "src/schema.gql"),
    }),
  ],
})
export class AppModule {}

実際にQueryとMutationを書いていきましょう。Postのテーブルとデータが既に存在するので、下記のQueryとMutationを作ってみます。

  • Post一覧のQuery
  • Post作成のMutation

ModuleとResolverの雛形を生成してくれる下記コマンドを実行します。

$ yarn nest g module posts
$ yarn nest g resolver posts

生成されたファイルの編集、いくつか新しく追加してQueryとMutationのコードを実装してみます。コードの詳細は以下の通りです。

src/posts/posts.module.ts の編集

src/posts/posts.module.ts

import { Module } from "@nestjs/common";
import { PostsResolver } from "./posts.resolver";
import { PrismaService } from "src/prisma.service";

@Module({
  providers: [PostsResolver, PrismaService],
})
export class PostsModule {}
src/posts/posts.resolver.ts の編集

src/posts/posts.resolver.ts

import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import { PostModel } from "./interfaces/post.model";
import { PrismaService } from "../prisma.service";
import { CreatePostInput } from "./interfaces/create-post.input";

@Resolver(() => PostModel)
export class PostsResolver {
  constructor(private readonly prismaService: PrismaService) {}

  @Query(() => [PostModel], { name: "posts", nullable: true })
  async getPosts() {
    return this.prismaService.post.findMany();
  }

  @Mutation(() => PostModel)
  async createPost(@Args("input") input: CreatePostInput) {
    return this.prismaService.post.create({
      data: {
        title: input.title,
      },
    });
  }
}
src/posts/interfaces/post.model.ts を作成

src/posts/interfaces/post.model.ts

import { Field, ObjectType } from "@nestjs/graphql";

@ObjectType()
export class PostModel {
  @Field((type) => String)
  id: string;

  @Field((type) => String)
  title: string;
}
src/posts/interfaces/create-post.input.ts を作成

src/posts/interfaces/create-post.input.tsPost新規作成する際のinputの型で使います。

import { Field, InputType } from "@nestjs/graphql";

@InputType()
export class CreatePostInput {
  @Field({ nullable: false })
  title: string;
}

上記の必要なファイルを実装後、サーバーを再起動(または開発モードで起動中)で src/schema.gql が自動で生成されます。

# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------

type PostModel {
  id: String!
  title: String!
}

type Query {
  posts: [PostModel!]
}

type Mutation {
  createPost(input: CreatePostInput!): PostModel!
}

input CreatePostInput {
  title: String!
}

以上で実装は完了です。プレイグラウンド(http://localhost:3000/graphql)にアクセスして、Query、Mutationを叩いてみましょう。

Queryを叩いてみます。

query {
  posts {
    id
    title
  }
}

取得できた🥳

続いて、mutationを叩いてみます。

# Write your query or mutation here
mutation createPost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
  }
}

# {
#  "input": {
#    "title": "タイトル100"
#  }
#}

登録できた 🥳

4. Passport

Prismaを使ってのDBへのアクセスも、GraphQL使ってQueryとMutationを叩くことができました。

最後にPassportとNestJSのguardの機能を使って認証と認可の処理を実装してみます。

公式ドキュメントの認証だとJWTを推してそうな感じでしたが、今回はSession、Cookieを使って実装してみます。

実装するにあたって、こちらの記事が大変参考になりました。記事内にも書かれていますが、公式のドキュメントはセッションの使用方法に関して不足気味なので、とても参考になりました。

https://dev.to/nestjs/setting-up-sessions-with-nestjs-passport-and-redis-210

下準備(データの準備、User情報を扱えるようにする)

認証の機能を実装していく前に、下準備として、Prisma、NestJSで、User情報を扱えるようにスキーマ変更や実装を追加していきます。

prisma/schema.prisma にUserのスキーマを追加します。

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  password  String
  isAdmin   Boolean  @default(false) @map("is_admin")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

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

$ yarn prisma migrate dev
実行時のログと結果
$ yarn prisma migrate dev
yarn run v1.22.19
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "sample-nestjs", schema "public" at "localhost:5432"

✔ Enter a name for the new migration: … add users
Applying migration `20221031141038_add_users`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20221031141038_add_users/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (4.5.0 | library) to ./node_modules/@prisma/client in 73ms


✨  Done in 11.01s.

prisma studioで確認したらテーブルが作成されてた

User情報もseedで作成されるようにしておきます。

prisma/seed.ts

prisma/seed.ts

import { Post, Prisma, PrismaClient, User } from "@prisma/client";
const prisma = new PrismaClient();

// モデル投入用のデータ定義
const postData: Post[] = [
  {
    id: "fa119cb6-9135-57f5-8a5a-54f28d566d0e",
    title: "気持ちを落ち着かせる呼吸法",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
  {
    id: "545d5237-15ee-169c-13a2-30f8748e3d6e",
    title: "高ぶる気持ちを存分に発揮したいです",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
  {
    id: "95daa18f-90d0-390c-fb96-0d152312936c",
    title: "ゆっくり落ち着く気持ちを大事にしたいです",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
];

const userData: User[] = [
  {
    id: "fa119cb6-9135-57f5-8a5a-54f28d566d0e",
    email: "admin@test.com",
    isAdmin: true,
    password: "$2b$12$s50omJrK/N3yCM6ynZYmNeen9WERDIVTncywePc75.Ul8.9PUk0LK",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
  {
    id: "fa119cb6-9135-57f5-8a5a-54f28d566d0f",
    email: "user01@test.com",
    isAdmin: false,
    password: "$2b$12$s50omJrK/N3yCM6ynZYmNeen9WERDIVTncywePc75.Ul8.9PUk0LK",
    createdAt: new Date("2022-01-31T04:34:22+09:00"),
    updatedAt: new Date("2022-01-31T04:34:22+09:00"),
  },
];

const doUserSeed = async () => {
  const users = [];
  for (const user of userData) {
    const createUsers = prisma.user.create({
      data: user,
    });
    users.push(createUsers);
  }
  return await prisma.$transaction(users);
};

const doPostSeed = async () => {
  const posts = [];
  for (const post of postData) {
    const createPosts = prisma.post.create({
      data: post,
    });
    posts.push(createPosts);
  }
  return await prisma.$transaction(posts);
};

const main = async () => {
  console.log(`Start seeding ...`);

  await doPostSeed();
  await doUserSeed();

  console.log(`Seeding finished.`);
};

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

以下のコマンドを実行してseedのデータを入れ直します。

$ yarn prisma migrate reset

登録できてた

Postの場合と同じ要領で、UserModuleとユーザー一覧を叩けるようにResolverを用意します。

$ yarn nest g module users
$ yarn nest g resolver users
src/users/interfaces/user.model.ts

src/users/interfaces/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 UserModel {
  @Field(() => ID, { nullable: false })
  id!: number;

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

  @HideField()
  password!: string;

  @HideField()
  createdAt!: Date;

  @HideField()
  updatedAt!: Date;
}
src/users/user.resolvers.ts `src/users/user.resolvers.ts`
import { Query, Resolver } from "@nestjs/graphql";
import { UserModel } from "./interfaces/user.model";
import { PrismaService } from "../prisma.service";

@Resolver(() => UserModel)
export class UsersResolver {
  constructor(private readonly prismaService: PrismaService) {}

  @Query(() => [UserModel], { name: "users", nullable: true })
  async getUsers() {
    return this.prismaService.user.findMany({
      where: {
        isAdmin: false,
      },
    });
  }

  @Query(() => [UserModel], { name: "allUsers", nullable: true })
  async getAllUsers() {
    return this.prismaService.user.findMany();
  }
}
src/users/users.module.ts

src/users/users.module.ts

import { Module } from "@nestjs/common";
import { PrismaService } from "src/prisma.service";
import { UsersResolver } from "./user.resolvers";

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

Queryが叩けることを確認できたら下準備終わりです。

query ExampleAllUserQuery {
  allUsers {
    email
    id
  }
}

叩けた

パッケージ導入

下準備ができたので、認証の処理を実装していきます。

必要なパッケージを導入

$ yarn add @nestjs/passport passport passport-local express-session redis@^3 connect-redis bcrypt
$ yarn add -D @types/passport-local @types/express-session @types/connect-redis @types/bcrypt @types/redis

セッションストレージ用にRedisをローカルで動かしたいので docker-compose.yml を以下のように修正します。

docker-compose.yml

docker-compose.yml

version: "3"

volumes:
  db-data:

services:
  db:
    image: postgres:14
    container_name: sample-nestjs
    volumes:
      - db-data:/var/lib/postgresql/sample-nestjs/data
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
  redis:
      image: redis:latest
      ports:
        - '6379:6379'
  rcli:
    image: redis:latest
    links:
      - redis
    command: redis-cli -h redis

セッション関連のセットアップ

  • express-session(expressでセッションを管理)
  • connect-redis(expressでセッション管理のstoreにredisをつかえるようにする)
  • redis(redisのクライアント)

redisのモジュールを作成します。

$ yarn nest g module redis
src/redis/redis.module.ts

src/redis/redis.module.ts

import { Module } from "@nestjs/common";
import * as Redis from "redis";

import { REDIS } from "./redis.constants";

@Module({
  providers: [
    {
      provide: REDIS,
      useValue: Redis.createClient({ port: 6379, host: "localhost" }),
    },
  ],
  exports: [REDIS],
})
export class RedisModule {}
src/redis/redis.constants.ts

src/redis/redis.constants.ts

export const REDIS = Symbol("AUTH:REDIS");
src/app.module.ts

src/app.module.ts

import {
  Inject,
  Logger,
  MiddlewareConsumer,
  Module,
  NestModule,
} from "@nestjs/common";
import * as RedisStore from "connect-redis";
import * as session from "express-session";
import * as passport from "passport";
import { RedisClient } from "redis";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { PostsModule } from "./posts/posts.module";
import { UsersModule } from "./users/users.module";
import * as path from "path";
import { RedisModule } from "./redis/redis.module";
import { REDIS } from "./redis/redis.constants";
import { PrismaService } from "./prisma.service";

@Module({
  controllers: [AppController],
  providers: [AppService, Logger, PrismaService],
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      debug: false,
      playground: true,
      autoSchemaFile: path.join(process.cwd(), "src/schema.gql"),
      sortSchema: true,
      // GraphQLの部分でcorsの設定なども合わせて変更してる
      cors: {
        origin: ["http://localhost:3000"],
        credentials: true,
        allowedHeaders: "Origin, X-Requested-With, Content-Type, Accept",
        methods: "*",
      },
    }),
    PostsModule,
    UsersModule,
    RedisModule,
  ],
})
export class AppModule implements NestModule {
  constructor(@Inject(REDIS) private readonly redis: RedisClient) {}
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        session({
          store: new (RedisStore(session))({
            client: this.redis,
            logErrors: true,
          }),
          saveUninitialized: false,
          secret: "secret",
          resave: false,
          cookie: {
            sameSite: "lax",
            httpOnly: false,
            maxAge: 60000,
            secure: false,
            domain: "localhost",
          },
        }),
        passport.initialize(),
        passport.session(),
      )
      .forRoutes("*");
  }
}

認証、認可の実装

モジュールの雛形を生成するコマンドを実行します。

$ yarn nest g module auth
$ yarn nest g resolver auth

Userの型とログイン、ユーザー登録で使うInputの型を定義します。

`src/modules/auth/models/user.interface.ts`

src/modules/auth/models/user.interface.ts

export interface User {
    id: string;
    email: string;
    password: string;
    isAdmin: boolean;
}
`src/modules/auth/models/register-user.input.ts`

src/modules/auth/models/register-user.input.ts

import { Field, InputType } from '@nestjs/graphql';

@InputType()
export class RegisterUserInput {
    @Field({ nullable: false })
    email: string;

    @Field({ nullable: false })
    password: string;

    @Field({ nullable: false })
    confirmationPassword: string;
}
`src/modules/auth/models/login-user.input.ts`

src/modules/auth/models/login-user.input.ts

import { Field, InputType } from '@nestjs/graphql';

@InputType()
export class LoginUserInput {
    @Field({ nullable: false })
    email: string;

    @Field({ nullable: false })
    password: string;
}

LocalStrategy を実装します。@nestjs/passportpassport の薄いラッパーで、passportのvalidateコールバックに渡す関数の定義などしてます。

`src/modules/auth/local.strategy.ts`

src/modules/auth/local.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
    constructor(private readonly authService: AuthService) {
        console.log("== call LocalStrategy constructor ==");
        super({
            usernameField: 'email',
        });
    }

    async validate(email: string, password: string) {
        console.log("== call LocalStrategy validate ==");

        const user = await this.authService.validateUser({ email, password });
        return user
    }
}

シリアライザーの実装。serializeUser の処理を通った後から req.session.passport.user にユーザー情報が入るようになります。

`src/modules/auth/serialization.provider.ts`

src/modules/auth/serialization.provider.ts

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

import { AuthService } from './auth.service';
import { User } from './models/user.interface';

@Injectable()
export class AuthSerializer extends PassportSerializer {
    constructor(private readonly authService: AuthService) {
        super();
    }
    serializeUser(user: User, done: (err: Error, user: { id: string; isAdmin: boolean }) => void) {
        console.log("== call serializeUser ==");

        done(null, { id: user.id, isAdmin: user.isAdmin });
    }

    async deserializeUser(payload: { id: string; isAdmin: boolean }, done: (err: Error, user: Omit<User, 'password'>) => void) {
        console.log("== call deserializeUser ==");

        const user = await this.authService.findById(payload.id);
        done(null, user);
    }
}

続いてガードの実装。ガードは、ユーザーのロールやパーミッションに応じて、リクエストに対して認可の処理を行えます。(権限がないユーザーに対して403を返すなど)

`src/guard/local.guard.ts`

src/guard/local.guard.ts

ログインのMutation実行する時に呼び出すやつ

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';

@Injectable()
export class LocalGuard extends AuthGuard('local') {
    async canActivate(context: ExecutionContext): Promise<boolean> {
        console.log("== call LocalGuard canActivate ==");

        const ctx = GqlExecutionContext.create(context);
        const gqlReq = ctx.getContext().req;
        const result = (await super.canActivate(context)) as boolean;

        console.log("== call canActivate gqlReq before ==", gqlReq.session)

        // logIn を実行する
        // 👇
        // シリアライズの処理が呼ばれる
        // 👇
        // user情報付与される
        // 例.
        // Session {
        //    ..
        //    passport: { user: { id: 'aaa', isAdmin: false } }
        // }
        await super.logIn(gqlReq);

        console.log("== call canActivate gqlReq after ==", gqlReq.session)

        return result;
    }

    getRequest(context: ExecutionContext) {
        console.log("== call LocalGuard getRequest ==");

        const ctx = GqlExecutionContext.create(context);
        const gqlReq = ctx.getContext().req;

        if (gqlReq) {
            const { input } = ctx.getArgs();
            gqlReq.body = input;

            return gqlReq;
        }
        return context.switchToHttp().getRequest();
    }
}
`src/guard/logged-in.guard.ts`

src/guard/logged-in.guard.ts

ログイン済みのユーザーのみが実行できるできるようにするガード

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';


@Injectable()
export class LoggedInGuard implements CanActivate {
    canActivate(context: ExecutionContext) {
        console.log("== call LoggedInGuard canActivate ==");

        const ctx = GqlExecutionContext.create(context);
        const gqlReq = ctx.getContext().req;

        if (gqlReq) {
            // ログイン済みのユーザーのみが実行できるできるようにする。ログインしてなかったらfalseになってアクセスできない。
            return gqlReq.isAuthenticated();
        }

        return context.switchToHttp().getRequest().isAuthenticated();
    }
}
`src/guard/admin.guard.ts`

src/guard/admin.guard.ts

アドミンロールのユーザーのみが実行できるできるようにするガード

import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

import { LoggedInGuard } from './logged-in.guard';

@Injectable()
export class AdminGuard extends LoggedInGuard {
    canActivate(context: ExecutionContext): boolean {
        console.log("== call AdminGuard canActivate ==");

        const ctx = GqlExecutionContext.create(context);
        const gqlReq = ctx.getContext().req;

        if (gqlReq) {
            // ログイン済みかつアドミンユーザーじゃないとアクセスできない。
            return gqlReq.isAuthenticated() && gqlReq.session.passport.user.isAdmin;
        }

        const req = context.switchToHttp().getRequest();
        return super.canActivate(context) && req.session.passport.user.isAdmin;
    }
}

認証、認可に必要な実装ができたので、最後にAuthモジュール、リゾルバー、サービスを実装します。

`src/modules/auth/auth.module.ts`

src/modules/auth/auth.module.ts

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { PrismaService } from 'src/prisma.service';
import { AuthResolver } from './auth.resolver';

import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { AuthSerializer } from './serialization.provider';

@Module({
    imports: [
        PassportModule.register({
            session: true,
        }),
    ],
    providers: [AuthResolver, AuthService, LocalStrategy, AuthSerializer, PrismaService],
})
export class AuthModule { }
`src/modules/auth/auth.resolver.ts`

src/modules/auth/auth.resolver.ts

import { UseGuards } from '@nestjs/common';
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { LocalGuard } from '../../guard/local.guard';
import { LoginUserInput } from './models/login-user.input';
import { RegisterUserInput } from './models/register-user.input';
import { UserModel } from '../users/interfaces/user.model';

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

    @UseGuards(LocalGuard)
    @Mutation(() => UserModel, { nullable: true })
    async login(
        @Args('input') input: LoginUserInput,
        @Context() context
    ) {
        console.log("== call AuthResolver login mutation ==");

        return context.req.user
    }

    @Mutation(() => UserModel, { nullable: true })
    async register(
        @Args('input') input: RegisterUserInput,
        @Context() context
    ) {
        console.log("== call AuthResolver login mutation ==");

        return this.authService.registerUser(input)
    }
}
`src/modules/auth/auth.service.ts`

src/modules/auth/auth.service.ts

import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { compare, hash } from 'bcrypt';

import { LoginUserInput } from './models/login-user.input';
import { RegisterUserInput } from './models/register-user.input';
import { User } from './models/user.interface';
import { PrismaService } from '../../prisma.service'

@Injectable()
export class AuthService {
    constructor(private readonly prismaService: PrismaService) { }

    async validateUser(user: LoginUserInput) {
        const foundUser = await this.prismaService.user.findUnique({
            where: {
                email: user.email
            }
        });

        if (!user || !(await compare(user.password, foundUser.password))) {
            throw new UnauthorizedException('Incorrect username or password');
        }
        const { password: _password, ...retUser } = foundUser;
        return retUser;
    }

    async registerUser(user: RegisterUserInput): Promise<Omit<User, 'password'>> {
        const existingUser = await this.prismaService.user.findUnique({
            where: {
                email: user.email
            }
        });
        if (existingUser) {
            throw new BadRequestException('User remail must be unique');
        }
        if (user.password !== user.confirmationPassword) {
            throw new BadRequestException('Password and Confirmation Password must match');
        }
        const { confirmationPassword: _, ...newUser } = user;

        const u = await this.prismaService.user.create({
            data: {
                email: newUser.email,
                password: await hash(newUser.password, 12),
                isAdmin: false
            }
        })

        return {
            id: u.id,
            email: u.email,
            isAdmin: u.isAdmin,
        };
    }

    async findById(id: string): Promise<Omit<User, 'password'>> {
        const user = await this.prismaService.user.findUnique({
            where: {
                id: id
            }
        })

        if (!user) {
            throw new BadRequestException(`No user found with id ${id}`);
        }
        return {
            id: user.id,
            email: user.email,
            isAdmin: user.isAdmin
        };
    }
}

ユーザー一覧を取得するQueryに認可の処理を入れてみる

src/users/user.resolvers.ts

src/users/user.resolvers.ts

import { Query, Resolver } from '@nestjs/graphql';
import { UserModel } from './interfaces/user.model';
import { PrismaService } from '../../prisma.service'
import { LoggedInGuard } from '../../guard/logged-in.guard';
import { AdminGuard } from '../../guard/admin.guard';
import { UseGuards } from '@nestjs/common';

@Resolver(() => UserModel)
export class UsersResolver {
    constructor(private readonly prismaService: PrismaService) { }

    // NOTE:ログイン済みのユーザーのみが実行できる
    @UseGuards(LoggedInGuard)
    @Query(() => [UserModel], { name: 'users', nullable: true })
    async getUsers() {
        return this.prismaService.user.findMany({
            where: {
                isAdmin: false
            }
        });
    }

    // NOTE: Adminロールを持ったユーザーのみが実行できる
    @UseGuards(AdminGuard)
    @Query(() => [UserModel], { name: 'allUsers', nullable: true })
    async getAllUsers() {
        return this.prismaService.user.findMany();
    }
}

以上で実装は終わりです。

ログインしてるかどうかに応じて、Queryが叩けるかどうかの動作確認してみましょう。

ログインしてない状態でQuery叩いてみる

query ExampleUserQuery {
  users {
    email
    id
  }
}

Query叩いた結果データ取得できず!(想定通り🥳)

ログインのmutationを叩いてログインする

# Write your query or mutation here
mutation Login($input: LoginUserInput!) {
  login(input: $input) {
    email
    id
  }
}

# {
#   "input": {
#     "email": "user01@test.com",
#     "password": "Passw0rd!"
#   }
# }

ログイン成功!Cookieも追加されてた🥳

ログインしている状態でQuery叩いてみる

query ExampleUserQuery {
  users {
    email
    id
  }
}

取得できた🥳

おわりに

  • NestJSでGraphQLとPrismaと認証認可の仕組みがわかってよかった。概要と雰囲気がわかった。細かいところはもう少しちゃんとみる
  • 認証に関して、NestJS以前に、passportの仕組みがわかってなかったけど、どんな感じで動いてるかわかってよかった。この記事がpassportとかの解説までしててわかりやすかった。
  • 次は、環境ごとの環境変数の切り替え、デプロイ周り、など試してみるぞ。

参考

ドキュメントのようやく & 日本語でわかりやすいいいまとめ

https://zenn.dev/morinokami/articles/nestjs-overview

nestjsを一通り動かしてみる際にいい本

https://zenn.dev/waddy/books/graphql-nestjs-nextjs-bootcamp/viewer/nestjs

redis最新だと怒られのでいったんv3で動かしてる

https://github.com/redis/node-redis/blob/master/docs/v3-to-v4.md

パスポートわかってなかったからチュートリアルやった

https://www.passportjs.org/tutorials/password/

GitHubで編集を提案
株式会社モニクル

モニクルは、金融サービステック企業です。 金融の専門知識がなくても、正しい意思決定ができる社会へ。 そのために必要なのは、人によるサービスとテクノロジーの掛け合わせ。 既存のフィンテックとは異なる挑戦に取り組みます。

Discussion

ログインするとコメントできます