NestJS、Prismaで認証機能を作成
前提
- こちらは自分の備忘録と認証機能の実装への理解のために記事を作成しております
- 作成時点の各種ライブラリのバージョンは以下になります
node 18.17.0 npm 9.8.0 NestJS 10.3.0 Prisma 5.15.0 PostgreSQL 3.8
実装手順
- 以下の手順で実装を進めていきます
- NestJSプロジェクトの作成
- DockerでのDB構築
- Prismaを利用しUserテーブルを作成
- UserServiceの実装
- サインアップ機能の実装
- ログイン・ログアウト機能の実装
- 実際の動作確認
1. NestJSプロジェクトの作成
NestJSプロジェクトの作成です。以下のコマンドを実行しNestJSプロジェクトを作成します。
nest new todo
2. Docker、PrismaでのDB構築
DB環境の構築です。今回はDockerを利用しDBを構築していきます。
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のシークレット等を利用して流出しないようにしてください。
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_DB
をDATABASE_URL
の部分に追記してください
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を作成します。
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
を追記します。
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を定義します。
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でユーザー取得とユーザ作成の処理を作成します
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
も追記します。
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 {}
サインアップ処理を実装します。すでに同じメールが登録されていた際のエラーハンドリングも実装します。
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;
}
}
}
サインアップのルーティングを実装します。
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に設定します。
DB_USER='db_user'
DB_PASSWORD='db_pass'
DB_DB='db_db'
↓↓追加↓↓
JWT_SECRET='secret1234'
〜〜略〜〜
Jwt生成で使用するJwtService
とシークレットキーをenvから使用するためにConfigService
も追記します。
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 {}
JwtService
とConfigService
をDIをし、ログイン処理とJwt生成の処理を実装していきます。
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での実装は不要です。
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