NestJSとPrismaでバックエンド構築
この章では、NestJSとPrismaを使用して、React SPAのためのバックエンドAPIを構築します。
この章で学ぶこと
- NestJSのモジュール構造と依存性注入
- Prismaによる型安全なデータベース操作
- JWT認証とリフレッシュトークンの実装
- RESTful APIの設計とバリデーション
この章ではNestJSの新規プロジェクトを作成します。03章のReactプロジェクトとは別のディレクトリで作業します。完成後、27章で学んだフロントエンドの認証と連携できます。
NestJSはAngularに似た構造を持つため、最初は概念(モジュール、デコレーター、依存性注入など)に戸惑うかもしれません。しかし、この構造こそがスケーラブルで保守しやすいバックエンドを実現する鍵です。まずはコードを写経しながら、各要素がどのように連携しているかを掴んでください。
NestJSとは
NestJSは、TypeScriptで構築されたNode.jsフレームワークです。Angularにインスパイアされたモジュール構造を持ち、エンタープライズレベルのアプリケーション開発に適しています。
特徴
| 特徴 | 説明 |
|---|---|
| TypeScript | 完全なTypeScriptサポート |
| モジュール構造 | 機能ごとに分離された構造 |
| 依存性注入 | テスタブルで疎結合な設計 |
| デコレーター | 宣言的なコード記述 |
| 豊富なエコシステム | 認証、バリデーション、ORMなど |
Prismaとは
PrismaはモダンなNode.js/TypeScript向けORMです。型安全なデータベースアクセスを提供します。
特徴
| 特徴 | 説明 |
|---|---|
| 型安全 | スキーマから型を自動生成 |
| 直感的なAPI | シンプルで分かりやすいクエリ |
| マイグレーション | スキーマ変更を安全に管理 |
| Prisma Studio | GUIでデータを確認・編集 |
なぜPrismaを選ぶのか
従来のORMでは、SQLとTypeScriptの型が分離していたり、複雑な設定が必要だったりしました。Prismaはschema.prismaファイル1つでデータベース構造を定義し、そこからTypeScript型とクエリAPIを自動生成します。これにより、スキーマ変更→マイグレーション→型更新の流れが一貫し、「データベースの変更がコードに反映されていない」というバグを防げます。
プロジェクトセットアップ
本書では NestJS 11 (Express 5 ベース) と Prisma 7 (Rust エンジン廃止) を使用しています。Prisma 6 以前は接続方式・生成クライアントの import 元が大きく異なるため、古い情報源と手順を混ぜないよう注意してください。
NestJSプロジェクトの作成
# NestJS CLI(グローバルインストールしたくない場合は npx で代替可能)
npm install -g @nestjs/cli@11
# or
npx -y @nestjs/cli@11 new project-api
# プロジェクト作成(CLIをインストールした場合)
nest new project-api
cd project-api
# 必要なパッケージをインストール
npm install @prisma/client @prisma/adapter-pg dotenv @nestjs/config @nestjs/jwt @nestjs/passport
npm install passport passport-jwt bcrypt cookie-parser class-validator class-transformer @nestjs/mapped-types
npm install -D prisma @types/passport-jwt @types/bcrypt @types/cookie-parser
@nestjs/cli をグローバルインストールしたくない場合は、npx -y @nestjs/cli@11 new project-api で代替できます。CI 環境や複数の NestJS バージョンを並行して使う場合は、こちらの方が副作用が少なくおすすめです。
Prismaの初期化
npx prisma init
prisma init は prisma/schema.prisma・.env に加えて prisma.config.ts を生成します。Prisma 7 の CLI は .env を自動では読み込まないため、prisma.config.ts 冒頭の import 'dotenv/config' がその役割を担います。
// prisma.config.ts
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: env('DATABASE_URL')
}
})
プロジェクト構成
project-api/
├── prisma/
│ ├── schema.prisma # データベーススキーマ
│ └── migrations/ # マイグレーションファイル
├── prisma.config.ts # Prisma CLI 設定(.env 読み込みと接続 URL)
├── src/
│ ├── auth/ # 認証モジュール
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── jwt.strategy.ts
│ │ └── dto/
│ ├── users/ # ユーザーモジュール
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── dto/
│ ├── projects/ # プロジェクトモジュール
│ │ ├── projects.module.ts
│ │ ├── projects.controller.ts
│ │ ├── projects.service.ts
│ │ └── dto/
│ ├── prisma/ # Prismaモジュール
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts
│ ├── app.module.ts
│ └── main.ts
├── .env
└── package.json
データベーススキーマ
Prismaスキーマ定義
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
// 接続 URL は prisma.config.ts(CLI 用)と driver adapter(ランタイム用)で管理する
}
model User {
id String @id @default(uuid())
email String @unique
name String
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// リレーション
projects Project[]
boards Board[]
cards Card[]
refreshTokens RefreshToken[]
@@map("users")
}
model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("refresh_tokens")
}
model Project {
id String @id @default(uuid())
name String
description String?
ownerId String @map("owner_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// リレーション
boards Board[]
@@map("projects")
}
model Board {
id String @id @default(uuid())
name String
position Int @default(0)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdById String @map("created_by_id")
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// リレーション
cards Card[]
@@map("boards")
}
model Card {
id String @id @default(uuid())
title String
description String?
position Int @default(0)
boardId String @map("board_id")
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
createdById String @map("created_by_id")
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("cards")
}
マイグレーション実行
# マイグレーション作成と適用(開発環境)
npx prisma migrate dev --name init
# Prisma Clientの生成
npx prisma generate
本番環境でのマイグレーション運用
開発と本番では使うコマンドが異なります。用途を混同しないように注意してください。
| コマンド | 用途 | 安全性 |
|---|---|---|
prisma migrate dev | 開発環境。スキーマ変更 → マイグレーション生成 → DB 反映 → prisma generate までを自動実行 | 破壊的変更を検出するとデータリセットを提案する |
prisma migrate deploy | 本番環境。既存のマイグレーション SQL を順に適用するだけ | DB をリセットしない(安全) |
prisma migrate reset | 開発時の最終手段。DB を初期化して全マイグレーションを再実行 | データがすべて消える |
prisma migrate resolve | 適用に失敗したマイグレーションの状態を手動で解決する | 高度な運用向け |
# 本番環境(CI / CD パイプラインで実行)
npx prisma migrate deploy
本番環境では絶対に prisma migrate dev を使わないでください。 データベース差分を検出すると「リセットしますか?」と提案し、誤って同意すると本番データが失われます。本番デプロイでは必ず prisma migrate deploy を使い、事前に検証済みのマイグレーション SQL のみを適用する運用にします。
ロールバックについて: Prisma には自動ロールバックコマンドがありません。ロールバックが必要な場合は、以下のいずれかの方法で対応します。
- 打ち消しマイグレーションを追加する(推奨): スキーマを元に戻すマイグレーションを新規作成し
prisma migrate deployで適用 - 失敗状態を手動で解消:
prisma migrate resolve --rolled-back <migration-name>で DB 上のマイグレーション履歴を修正し、失敗原因を取り除いたマイグレーションを適用
いずれの場合も、本番適用前にステージング環境で検証し、DB バックアップを取得しておくことが重要です。
Prismaサービス
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../generated/prisma/client'
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
// Prisma 7 では driver adapter 経由で DB に接続する
// (ConfigModule の .env 読み込み後に評価されるよう、adapter は constructor 内で生成する)
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
})
super({ adapter })
}
async onModuleInit() {
await this.$connect()
}
async onModuleDestroy() {
await this.$disconnect()
}
}
// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}
認証モジュール
DTOの定義
// src/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator'
export class RegisterDto {
@IsString()
@MinLength(2)
@MaxLength(50)
name: string
@IsEmail()
email: string
@IsString()
@MinLength(8)
password: string
}
// src/auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator'
export class LoginDto {
@IsEmail()
email: string
@IsString()
password: string
}
認証サービス
// src/auth/auth.service.ts
import {
Injectable,
UnauthorizedException,
ConflictException
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { ConfigService } from '@nestjs/config'
import * as bcrypt from 'bcrypt'
import { PrismaService } from '../prisma/prisma.service'
import { RegisterDto } from './dto/register.dto'
import { LoginDto } from './dto/login.dto'
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService
) {}
async register(dto: RegisterDto) {
// メールアドレスの重複チェック
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email }
})
if (existingUser) {
throw new ConflictException('このメールアドレスは既に登録されています')
}
// パスワードのハッシュ化
const passwordHash = await bcrypt.hash(dto.password, 10)
// ユーザー作成
const user = await this.prisma.user.create({
data: {
name: dto.name,
email: dto.email,
passwordHash
},
select: {
id: true,
email: true,
name: true
}
})
// トークン生成
const tokens = await this.generateTokens(user.id)
return {
user,
...tokens
}
}
async login(dto: LoginDto) {
// ユーザー検索
const user = await this.prisma.user.findUnique({
where: { email: dto.email }
})
if (!user) {
throw new UnauthorizedException('認証情報が正しくありません')
}
// パスワード検証
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash)
if (!isPasswordValid) {
throw new UnauthorizedException('認証情報が正しくありません')
}
// トークン生成
const tokens = await this.generateTokens(user.id)
return {
user: {
id: user.id,
email: user.email,
name: user.name
},
...tokens
}
}
async refresh(refreshToken: string) {
// リフレッシュトークンの検証
const storedToken = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true }
})
if (!storedToken || storedToken.expiresAt < new Date()) {
throw new UnauthorizedException('リフレッシュトークンが無効です')
}
// 古いトークンを削除
await this.prisma.refreshToken.delete({
where: { id: storedToken.id }
})
// 新しいトークンを生成
const tokens = await this.generateTokens(storedToken.userId)
return {
user: {
id: storedToken.user.id,
email: storedToken.user.email,
name: storedToken.user.name
},
...tokens
}
}
async logout(refreshToken: string) {
await this.prisma.refreshToken.deleteMany({
where: { token: refreshToken }
})
}
async getMe(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true
}
})
return { user }
}
private async generateTokens(userId: string) {
// アクセストークン(短い有効期限)
const accessToken = this.jwtService.sign(
{ sub: userId },
{
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: '15m'
}
)
// リフレッシュトークン(長い有効期限)
const refreshToken = this.jwtService.sign(
{ sub: userId },
{
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: '7d'
}
)
// リフレッシュトークンをDBに保存
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId,
expiresAt
}
})
return { accessToken, refreshToken }
}
}
上記は学習用に平文で保存しています。本番では SHA-256 等の高速ハッシュで crypto.createHash('sha256').update(token).digest('hex') のように不可逆化して保存してください(bcrypt は先頭 72 バイトしか見ないため、長い JWT のハッシュには不適切です)。DB が漏洩した場合、保存済みトークンがそのまま悪用される(access token より長寿命なため影響が大きい)リスクを防げます。SHA-256 ハッシュは決定的なので、検証時はハッシュ値で findUnique 検索できます。
認証コントローラー
// src/auth/auth.controller.ts
import {
Controller,
Post,
Get,
Body,
Res,
Req,
UseGuards,
HttpCode,
HttpStatus,
UnauthorizedException
} from '@nestjs/common'
import { Response, Request } from 'express'
import { AuthService } from './auth.service'
import { RegisterDto } from './dto/register.dto'
import { LoginDto } from './dto/login.dto'
import { JwtAuthGuard } from './jwt-auth.guard'
import { CurrentUser } from './current-user.decorator'
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
async register(
@Body() dto: RegisterDto,
@Res({ passthrough: true }) res: Response
) {
const { user, accessToken, refreshToken } = await this.authService.register(dto)
this.setRefreshTokenCookie(res, refreshToken)
return { user, accessToken }
}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response
) {
const { user, accessToken, refreshToken } = await this.authService.login(dto)
this.setRefreshTokenCookie(res, refreshToken)
return { user, accessToken }
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const refreshToken = req.cookies['refreshToken']
if (!refreshToken) {
throw new UnauthorizedException('リフレッシュトークンがありません')
}
const { user, accessToken, refreshToken: newRefreshToken } =
await this.authService.refresh(refreshToken)
this.setRefreshTokenCookie(res, newRefreshToken)
return { user, accessToken }
}
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const refreshToken = req.cookies['refreshToken']
if (refreshToken) {
await this.authService.logout(refreshToken)
}
res.clearCookie('refreshToken')
return { message: 'ログアウトしました' }
}
@Get('me')
@UseGuards(JwtAuthGuard)
async getMe(@CurrentUser() userId: string) {
return this.authService.getMe(userId)
}
private setRefreshTokenCookie(res: Response, token: string) {
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7日
})
}
}
JWTストラテジーとガード
// src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private prisma: PrismaService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_ACCESS_SECRET')
})
}
async validate(payload: { sub: string }) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub }
})
if (!user) {
throw new UnauthorizedException()
}
return payload.sub // リクエストにuserIdとして設定される
}
}
// src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// src/auth/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest()
return request.user // JwtStrategyのvalidateで返した値
}
)
認証モジュールの定義
ここまでで作成したコントローラー・サービス・ストラテジーを 1 つのモジュールにまとめます。
// src/auth/auth.module.ts
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { JwtStrategy } from './jwt.strategy'
@Module({
imports: [PassportModule, JwtModule.register({})],
controllers: [AuthController],
providers: [AuthService, JwtStrategy]
})
export class AuthModule {}
generateTokens では署名時に secret を都度指定しているため、JwtModule.register({}) は空オプションでの登録で十分です。
プロジェクトモジュール
DTOの定義
// src/projects/dto/create-project.dto.ts
import { IsString, IsOptional, MaxLength } from 'class-validator'
export class CreateProjectDto {
@IsString()
@MaxLength(100)
name: string
@IsString()
@IsOptional()
@MaxLength(500)
description?: string
}
// src/projects/dto/update-project.dto.ts
import { PartialType } from '@nestjs/mapped-types'
import { CreateProjectDto } from './create-project.dto'
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}
プロジェクトサービス
// src/projects/projects.service.ts
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { CreateProjectDto } from './dto/create-project.dto'
import { UpdateProjectDto } from './dto/update-project.dto'
@Injectable()
export class ProjectsService {
constructor(private prisma: PrismaService) {}
async create(userId: string, dto: CreateProjectDto) {
return this.prisma.project.create({
data: {
...dto,
ownerId: userId
},
include: {
owner: {
select: { id: true, name: true, email: true }
},
boards: {
orderBy: { position: 'asc' }
}
}
})
}
async findAll(userId: string) {
return this.prisma.project.findMany({
where: { ownerId: userId },
include: {
owner: {
select: { id: true, name: true, email: true }
},
_count: {
select: { boards: true }
}
},
orderBy: { createdAt: 'desc' }
})
}
async findOne(userId: string, id: string) {
const project = await this.prisma.project.findUnique({
where: { id },
include: {
owner: {
select: { id: true, name: true, email: true }
},
boards: {
orderBy: { position: 'asc' },
include: {
cards: {
orderBy: { position: 'asc' }
}
}
}
}
})
if (!project) {
throw new NotFoundException('プロジェクトが見つかりません')
}
if (project.ownerId !== userId) {
throw new ForbiddenException('このプロジェクトにアクセスする権限がありません')
}
return project
}
async update(userId: string, id: string, dto: UpdateProjectDto) {
const project = await this.findOne(userId, id)
return this.prisma.project.update({
where: { id: project.id },
data: dto,
include: {
owner: {
select: { id: true, name: true, email: true }
},
boards: {
orderBy: { position: 'asc' }
}
}
})
}
async remove(userId: string, id: string) {
const project = await this.findOne(userId, id)
await this.prisma.project.delete({
where: { id: project.id }
})
return { message: 'プロジェクトを削除しました' }
}
}
プロジェクトコントローラー
// src/projects/projects.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards
} from '@nestjs/common'
import { ProjectsService } from './projects.service'
import { CreateProjectDto } from './dto/create-project.dto'
import { UpdateProjectDto } from './dto/update-project.dto'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../auth/current-user.decorator'
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@Post()
create(@CurrentUser() userId: string, @Body() dto: CreateProjectDto) {
return this.projectsService.create(userId, dto)
}
@Get()
findAll(@CurrentUser() userId: string) {
return this.projectsService.findAll(userId)
}
@Get(':id')
findOne(@CurrentUser() userId: string, @Param('id') id: string) {
return this.projectsService.findOne(userId, id)
}
@Patch(':id')
update(
@CurrentUser() userId: string,
@Param('id') id: string,
@Body() dto: UpdateProjectDto
) {
return this.projectsService.update(userId, id, dto)
}
@Delete(':id')
remove(@CurrentUser() userId: string, @Param('id') id: string) {
return this.projectsService.remove(userId, id)
}
}
アプリケーションモジュール
// src/app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { PrismaModule } from './prisma/prisma.module'
import { AuthModule } from './auth/auth.module'
import { ProjectsModule } from './projects/projects.module'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true
}),
PrismaModule,
AuthModule,
ProjectsModule
]
})
export class AppModule {}
本文で実装したのは上記 3 モジュールです。UsersModule は本章末の「試してみよう」で、Boards / Cards モジュールは 29章のフロントエンドと組み合わせる発展課題として、Projects モジュールと同じパターンで自作してください(nest g module boards などで雛形を生成できます)。作成したら imports に追加します。
メインファイル
// src/main.ts
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import cookieParser from 'cookie-parser'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
// グローバルプレフィックス
app.setGlobalPrefix('api')
// CORS設定
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true
})
// Cookie パーサー
app.use(cookieParser())
// バリデーションパイプ
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true
})
)
await app.listen(3000)
console.log('Server running on http://localhost:3000')
}
bootstrap()
環境変数
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/project_db"
JWT_ACCESS_SECRET="your-access-secret-key"
JWT_REFRESH_SECRET="your-refresh-secret-key"
FRONTEND_URL="http://localhost:5173"
本番環境では、JWT_ACCESS_SECRETとJWT_REFRESH_SECRETに十分に長いランダムな文字列を使用してください。openssl rand -base64 32などで生成できます。また、.envファイルはGitにコミットしないでください(.gitignoreに追加)。
APIエンドポイント一覧
| Method | Endpoint | 説明 | 認証 |
|---|---|---|---|
| POST | /api/auth/register | ユーザー登録 | 不要 |
| POST | /api/auth/login | ログイン | 不要 |
| POST | /api/auth/refresh | トークン更新 | Cookie |
| POST | /api/auth/logout | ログアウト | Cookie |
| GET | /api/auth/me | 現在のユーザー | 必要 |
| GET | /api/projects | プロジェクト一覧 | 必要 |
| POST | /api/projects | プロジェクト作成 | 必要 |
| GET | /api/projects/:id | プロジェクト詳細 | 必要 |
| PATCH | /api/projects/:id | プロジェクト更新 | 必要 |
| DELETE | /api/projects/:id | プロジェクト削除 | 必要 |
試してみよう
以下の課題に挑戦して、NestJSとPrismaの理解を深めましょう。
-
ユーザープロフィール更新APIを追加する:
PATCH /api/users/meエンドポイントを作成し、ログイン中のユーザーが自分の名前を更新できるようにしてください。DTOでバリデーションも実装しましょう。 -
パスワード変更機能を実装する: 現在のパスワードと新しいパスワードを受け取り、パスワードを変更するエンドポイントを作成してください。現在のパスワードの検証を忘れずに。
-
リフレッシュトークンの無効化を実装する: ログアウト時に該当ユーザーのすべてのリフレッシュトークンを削除する機能を追加してください。これにより他のデバイスからもログアウトされます。
ヒント
UsersControllerに@Patch('me')エンドポイントを追加し、@CurrentUser()でユーザーIDを取得します。UpdateUserDtoを作成してバリデーションを行います。bcrypt.compareで現在のパスワードを検証し、bcrypt.hashで新しいパスワードをハッシュ化します。不一致の場合はUnauthorizedExceptionを投げます。this.prisma.refreshToken.deleteMany({ where: { userId } })で該当ユーザーのトークンをすべて削除できます。
回答と解説
課題1: ユーザープロフィール更新APIを追加する
// src/users/dto/update-user.dto.ts
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'
export class UpdateUserDto {
@IsString()
@IsOptional()
@MinLength(2)
@MaxLength(50)
name?: string
}
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateUserDto } from './dto/update-user.dto'
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async updateProfile(userId: string, dto: UpdateUserDto) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
})
if (!user) {
throw new NotFoundException('ユーザーが見つかりません')
}
const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: dto,
select: {
id: true,
email: true,
name: true
}
})
return updatedUser
}
}
// src/users/users.controller.ts
import { Controller, Patch, Body, UseGuards } from '@nestjs/common'
import { UsersService } from './users.service'
import { UpdateUserDto } from './dto/update-user.dto'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../auth/current-user.decorator'
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Patch('me')
updateProfile(@CurrentUser() userId: string, @Body() dto: UpdateUserDto) {
return this.usersService.updateProfile(userId, dto)
}
}
@CurrentUser()デコレーターでJWTから取得したユーザーIDを受け取り、自分自身のプロフィールのみ更新できるようにしています。DTOでバリデーションを行い、不正な入力を防いでいます。
課題2: パスワード変更機能を実装する
// src/users/dto/change-password.dto.ts
import { IsString, MinLength } from 'class-validator'
export class ChangePasswordDto {
@IsString()
currentPassword: string
@IsString()
@MinLength(8)
newPassword: string
}
// src/users/users.service.ts(メソッドを追加)
import { UnauthorizedException } from '@nestjs/common'
import * as bcrypt from 'bcrypt'
async changePassword(userId: string, dto: ChangePasswordDto) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
})
if (!user) {
throw new NotFoundException('ユーザーが見つかりません')
}
// 現在のパスワードを検証
const isPasswordValid = await bcrypt.compare(
dto.currentPassword,
user.passwordHash
)
if (!isPasswordValid) {
throw new UnauthorizedException('現在のパスワードが正しくありません')
}
// 新しいパスワードをハッシュ化して保存
const newPasswordHash = await bcrypt.hash(dto.newPassword, 10)
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: newPasswordHash }
})
return { message: 'パスワードを変更しました' }
}
// src/users/users.controller.ts(エンドポイントを追加)
@Patch('me/password')
changePassword(
@CurrentUser() userId: string,
@Body() dto: ChangePasswordDto
) {
return this.usersService.changePassword(userId, dto)
}
パスワード変更時は必ず現在のパスワードを確認します。これにより、セッションが乗っ取られた場合でも、攻撃者がパスワードを変更できなくなります。新しいパスワードはbcrypt.hashでハッシュ化してから保存します。
課題3: リフレッシュトークンの無効化を実装する
// src/auth/auth.service.ts(メソッドを追加)
async logoutAll(userId: string) {
// 該当ユーザーのすべてのリフレッシュトークンを削除
await this.prisma.refreshToken.deleteMany({
where: { userId }
})
return { message: 'すべてのデバイスからログアウトしました' }
}
// src/auth/auth.controller.ts(エンドポイントを追加)
@Post('logout-all')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async logoutAll(@CurrentUser() userId: string) {
return this.authService.logoutAll(userId)
}
// フロントエンドでの使用例
const handleLogoutAll = async () => {
await api.post('/auth/logout-all')
clearAuth() // ローカルの認証状態もクリア
navigate('/login')
}
deleteManyを使うことで、該当ユーザーのすべてのリフレッシュトークンを一括削除できます。これにより、他のデバイスやブラウザでログインしているセッションもすべて無効化されます。セキュリティ上の理由(パスワード漏洩の疑いなど)でユーザーがすべてのセッションを終了したい場合に役立ちます。
まとめ
- NestJSはモジュール構造でスケーラブルなバックエンドを構築
- PrismaでTypeScript型安全なデータベースアクセス
- JWT + HttpOnly Cookieで安全な認証
- デコレーターで宣言的なコード記述
- バリデーションパイプで入力検証
次の章では、ReactフロントエンドとNestJSバックエンドを統合し、フルスタックアプリケーションを完成させます。