🐈

NestJS、Prismaで認証機能を作成

2024/07/03に公開

前提

  • こちらは自分の備忘録と認証機能の実装への理解のために記事を作成しております
  • 作成時点の各種ライブラリのバージョンは以下になります
    node 18.17.0
    npm 9.8.0
    NestJS 10.3.0
    Prisma 5.15.0
    PostgreSQL 3.8
    

実装手順

  • 以下の手順で実装を進めていきます
  1. NestJSプロジェクトの作成
  2. DockerでのDB構築
  3. Prismaを利用しUserテーブルを作成
  4. UserServiceの実装
  5. サインアップ機能の実装
  6. ログイン・ログアウト機能の実装
  7. 実際の動作確認

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

NestJSプロジェクトの作成です。以下のコマンドを実行しNestJSプロジェクトを作成します。

nest new todo

2. Docker、PrismaでのDB構築

DB環境の構築です。今回はDockerを利用しDBを構築していきます。

docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:15-alpine
    container_name: todo_backend
    ports:
      - 5433:5432
    volumes:
      - ./docker/postgres/init.d:/docker-entrypoint-initdb.d
      - ./docker/postgres/pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: '--encoding=UTF-8'
      POSTGRES_DB: ${DB_DB}
    hostname: postgres
    restart: always
    user: root

DB関連の情報を公開しないためにenvに設定し、.gitignoreで除外するかGithub Actionsのシークレット等を利用して流出しないようにしてください。

.env
DB_USER='db_user'
DB_PASSWORD='db_pass'
DB_DB='db_db'

上記のファイルが作成できたらコンテナを立ち上げ、起動することを確認します。

docker-compose up -d

3. Prismaを利用しUserテーブルを作成

まずはprismaをインストールします。

npm i prisma

インストール後、initコマンドでprismaを初期化します。

npx prisma init

初期化をすることで.envにDATABASE_URLが追加されます。
すでに設定していた、DB_USER DB_PASSWORD DB_DBDATABASE_URLの部分に追記してください

.env
DB_USER='db_user'
DB_PASSWORD='db_pass'
DB_DB='db_db'

〜〜略〜〜

DATABASE_URL="postgresql://db_user:db_pass@localhost:5433/db_db?schema=public"

schema.prismaも作成されるため、Userテーブルの定義を記載します。

model User {
  id Int @id @default(autoincrement())
  name String @db.VarChar(255)
  email String @unique @db.VarChar(255)
  password String @db.VarChar(255)
  createdAt DateTime @default(now()) @db.Timestamp(0)
  updatedAt DateTime @updatedAt @db.Timestamp(0)
}

migrationを実施しUserテーブルを作成します。
create_user_tableの部分は任意のため、その時の操作に合わせたわかりやすいmigration名をつけてください

npx prisma migrate dev --name create_user_table

以下コマンドでPrisma Studioを起動し、Userテーブルができていることが確認します。

npx prisma studio

4. UserServiceの実装

使用するライブラリをインストールします。

npm i @nestjs/common @prisma/client bcrypt class-validator

PrismaClientのインスタンス化とデータベースへの接続を行うPrismaServiceを作成します。

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

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

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

user用のmodule、serviceを自動生成します。

nest g module user
nest g service user

user.service.tsでPrismaServiceを利用するため、作成したuser.module.tsのprovidersにPrismaServiceを追記します。

user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { PrismaService } from 'src/prisma/prisma.service';

@Module({
  providers: [UserService, PrismaService],
  controllers: [UserController],
})
export class UserModule {}

user配下にdtoフォルダーを作成し、ユーザー取得とユーザー作成時のdtoを定義します。

authDto.ts
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MaxLength,
  MinLength,
} from 'class-validator';

export class AuthDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(255)
  name: string;

  @IsString()
  @IsNotEmpty()
  @IsEmail()
  @MaxLength(255)
  email: string;

  @MinLength(8)
  password: string;
}

user.service.tsでユーザー取得とユーザ作成の処理を作成します

user.service.ts
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { AuthDto } from './dto/authDto';

@Injectable()
export class UserService {
  // PrismaServiceを使用するためにDI(依存性の注入)をする
  constructor(private readonly prisma: PrismaService) {}

  // ユーザー取得
  async getUser(data: AuthDto): Promise<User> {
    return await this.prisma.user.findUnique({
      where: { email: data.email },
    });
  }

  // ユーザー作成
  async createUser(data: AuthDto): Promise<User> {
    const { name, email, password } = data;
    const hashPassword = await bcrypt.hash(password, 12);
    return await this.prisma.user.create({
      data: { name, email, password: hashPassword },
    });
  }
}

5. サインアップ機能の実装

auth用のmodule、service、controllerを作成します。authはルーティングも必要になるためcontrollerも必要になります。

nest g module auth
nest g service auth
nest g controller auth

userの時と同様にauth.module.tsにもprovidersにPrismaServiceを追記します。また先ほど作成したユーザー関連の機能も必要なためUserServiceも追記します。

auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaService } from 'src/prisma/prisma.service';
import { UserService } from 'src/user/user.service';

@Module({
  imports: [JwtModule.register({})],
  providers: [
    AuthService,
    PrismaService,
    UserService,
  ],
  controllers: [AuthController],
})
export class AuthModule {}

サインアップ処理を実装します。すでに同じメールが登録されていた際のエラーハンドリングも実装します。

auth.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { Msg } from './types/auth.type';
import { UserService } from 'src/user/user.service';
import { AuthDto } from 'src/user/dto/authDto';

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly userService: UserService,
  ) {}

  // サインアップ処理
  async signUp(authDto: AuthDto): Promise<Msg> {
    try {
      await this.userService.createUser(authDto);
      return {
        message: 'サインアップが完了しました',
      };
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ForbiddenException('このメールはすでに登録されています');
        }
      }
      throw error;
    }
  }
}

サインアップのルーティングを実装します。

auth.controller.ts
import {
  Controller,
  Post,
  Body,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from 'src/user/dto/authDto';
import { Msg } from './types/auth.type';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('signup')
  signUp(@Body() authDto: AuthDto): Promise<Msg> {
    return this.authService.signUp(authDto);
  }
}

ルーティングが完了したらPostmanでサインアップをしてみます。以下のレスポンスが帰ってきたらユーザー作成が完了です。npx prisma studioコマンドでUserテーブルに保存されているかも確認しましょう。

6. ログイン・ログアウト機能の実装

認証関連のライブラリを導入します。

npm i @nestjs/jwt @nestjs/config express

今回はJwtを生成するため、生成の際に使う任意のシークレットキーを.envに設定します。

.env
DB_USER='db_user'
DB_PASSWORD='db_pass'
DB_DB='db_db'
↓↓追加↓↓
JWT_SECRET='secret1234'

〜〜略〜〜

Jwt生成で使用するJwtServiceとシークレットキーをenvから使用するためにConfigServiceも追記します。

auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaService } from 'src/prisma/prisma.service';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UserService } from 'src/user/user.service';

@Module({
  imports: [JwtModule.register({})],
  providers: [
    AuthService,
    PrismaService,
    JwtService,
    ConfigService,
    UserService,
  ],
  controllers: [AuthController],
})
export class AuthModule {}

JwtServiceConfigServiceをDIをし、ログイン処理とJwt生成の処理を実装していきます。

auth.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { Msg, Jwt } from './types/auth.type';
import { UserService } from 'src/user/user.service';
import { AuthDto } from 'src/user/dto/authDto';

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
    private readonly userService: UserService,
  ) {}

  // サインアップ処理
  async signUp(authDto: AuthDto): Promise<Msg> {
    try {
      await this.userService.createUser(authDto);
      return {
        message: 'サインアップが完了しました',
      };
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ForbiddenException('このメールはすでに登録されています');
        }
      }
      throw error;
    }
  }

  // ログイン処理
  async login(authDto: AuthDto): Promise<Jwt> {
    const user = await this.userService.getUser(authDto);
    if (!user)
      throw new ForbiddenException(
        'ユーザー情報が取得できませんでした。メールアドレスが正しいか確認してください',
      );
    console.log(user);
    console.log(authDto);

    const isValid = await bcrypt.compare(authDto.password, user.password);
    console.log(isValid);
    if (!isValid)
      throw new ForbiddenException(
        'パスワードが正しくないです。有効なパスワードを入力してください',
      );
    return this.generateJwt(user.id, user.email);
  }

  // Jwtの生成処理
  async generateJwt(userId: number, email: string): Promise<Jwt> {
    const payload = {
      sub: userId,
      email,
    };
    const secret = this.config.get('JWT_SECRET');
    const token = await this.jwt.signAsync(payload, {
      expiresIn: '5m',
      secret: secret,
    });

    return {
      accessToken: token,
    };
  }
}

最後にログインとログアウトのルーティングを設定します。ログアウトについてはクッキーから認証情報を削除するだけなのでauth.service.tsでの実装は不要です。

auth.controller.ts
import {
  Controller,
  Post,
  Body,
  HttpCode,
  HttpStatus,
  Res,
  Req,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { AuthDto } from 'src/user/dto/authDto';
import { Msg } from './types/auth.type';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('signup')
  signUp(@Body() authDto: AuthDto): Promise<Msg> {
    return this.authService.signUp(authDto);
  }

  @HttpCode(HttpStatus.OK)
  @Post('login')
  async login(
    @Body() authDto: AuthDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<Msg> {
    const jwt = await this.authService.login(authDto);
    res.cookie('access_token', jwt.accessToken, {
      httpOnly: true,
      // trueの場合https化された通信のみを許可するようにする
      secure: false,
      sameSite: 'none',
      path: '/',
    });
    return {
      message: 'ログインが完了しました',
    };
  }

  @HttpCode(HttpStatus.OK)
  @Post('logout')
  logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Msg {
    res.cookie('access_token', '', {
      httpOnly: true,
      // trueの場合https化された通信のみを許可するようにする
      secure: false,
      sameSite: 'none',
      path: '/',
    });
    return {
      message: 'ログアウトしました',
    };
  }
}

まずはログインの動作確認をしていきます。以下のレスポンスが返却されたら成功です。

クッキーに認証情報が保存されていることも確認します。

次にログアウトの確認です。以下のレスポンスが返却されたら成功です。

先ほど保存されていた認証情報がクッキーから削除されていればログアウト成功です。

Discussion