🔐

NestJSで簡単なユーザ認証付きのREST APIを実装する

2023/10/03に公開

はじめに

今回の記事では、NestJSで簡単なユーザ認証付きのREST APIを開発する手順を解説する。使う技術は以下の通りだ。

  • SQLite
  • Prisma
  • @passport/jwt

対象とする読者

  • これから実務でNestJSを使いたい人
  • NestJSで認証機能を実装してみたい人
  • 今NestJSを勉強している人
  • タイトルでなんとなく気になった人

前提知識

今回の記事では、読者にコピペで認証機能付きのREST APIを実装する手順を理解してもらうことを目的に書かれている。詳細は以下の記事を確認してほしい。あくまで認証付きのREST APIを作る方法や手順を理解してもらうために解説していることはご了承願いたい。

本記事で取り扱うNestJSは、専門用語や独自の概念、アーキテクチャが数多く登場する。本記事ではそれらに関することは説明しないので、詳細は以下の記事を確認してほしい。

https://zenn.dev/nameless_sn/articles/nestjs-fundamental

JWT認証に関する記事は以下の記事を参照にしてほしい。

https://qiita.com/knaot0/items/8427918564400968bd2b

Prisma

Prismaは、データベースの設計を効率化させるために開発されたORMである。Node.jsとTypeScriptに対応している。最大の特徴は、SQLを書かずにデータベースを設計できる点にある。

@passport/jwt

@passport/jwtは、Node.jsで開発されたWebアプリの認証をサポートするためのパッケージだ。認証を司るJavaScriptライブラリであるPassportで、JWT認証を実装するためのものである。この認証に用いるJWT(JSON Web Token)を一言で述べると、何かしらの情報を持つ短い文字列を意味する。

これを用いた認証の流れは以下のようになる。

  1. ユーザがWebサイトにログインする
  2. サーバが正しいユーザ名とパスワードを確認する
  3. 2.で入力された情報が正しい場合、サーバはJWTというトークンをユーザに送信する
  4. ユーザが次にサイトを訪れるとき、このJWTを使う
  5. サーバはそのJWTを確認して正しいかどうかを見極める

@passport/jwtは、上述の3.~5.までの流れを簡単に実装するためのライブラリだ。

bcrypt

パスワード専用のハッシュ値を生成してくれるnpmである。パスワードをデータベースに平文―いわゆる、そのままの状態で保存してはならない。JavaScript(TypeScript)では主にこれを使ってパスワードをハッシュ化する。

APIの基礎的な部分を実装する

(1) プロジェクトのセットアップ

まず、以下のコマンドを入力して、新規でNestJSのプロジェクトを作成する。

npm i -g @nestjs/cli
nest new project-name
cd project-name

(2) 必要なライブラリのインストール

次に、以下のコマンドを入力して必要なライブラリをインストールする。

npm i @nestjs/jwt @nestjs/passport passport passport-jwt prisma @prisma/client sqlite3

(3) Prismaのセットアップ

最初に以下のコマンドを入力する。

npx prisma init

そのあと、/prisma/schema.prismaに以下の内容を追加する。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id       Int    @id @default(autoincrement())
  email    String @unique
  password String
}

以下のコマンドでデータベースを初期化する。

$ npx prisma migrate dev --name init

(4) 認証のセットアップ

/src/auth/jwt.strategy.ts
// (1) 必要なライブラリやパッケージをインポート
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';

// (2) JwtStrategyクラスの新規作成
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  // (3) コンストラクタ。認証のオプションをここで設定する
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'SECRET_KEY', // 実際には環境変数や外部の秘密管理ツールから取得
    });
  }

  // (4) validateメソッド:JWTトークンの署名が正しく検証されたあとに呼び出される
  async validate(payload: any) {
    if (!payload?.email) {
      throw new UnauthorizedException();
    }
    return { email: payload.email };
  }
}

(5) Service、Controllerの実装

ユーザを作成・認証するためのサービス(Service)を作ろう。パスワードのハッシュ化やJWTの作成もここで行う。

/src/auth/auth.service.ts
// (1) 必要なライブラリをインポートする
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service'; 
import { JwtService } from '@nestjs/jwt';

// (2) AuthServiceクラス
@Injectable()
export class AuthService {
  // (3) コンストラクタの設定
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}

  // (4) validateUserメソッド:与えられたメールアドレスやパスワードを使ってユーザを検証。
  async validateUser(email: string, pass: string): Promise<any> {
    const user = await this.prisma.user.findUnique({ where: { email } });
    // ここではパスワードの比較を簡単にしているものの、実際にはハッシュ化して比較するべき。
    if (user && user.password === pass) {
      return user;
    }
    return null;
  }

  // (5) loginメソッド:与えられたユーザオブジェクトからJWTトークンを生成する。
  async login(user: any) {
    const payload = { email: user.email, sub: user.id };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

上述の/src/auth/auth.service.tsでは、NestJSの認証を司るServiceを実装する。

ここで、このファイルで以下のコードに該当するファイルが存在しないので、新規で以下のファイルを作成する。

import { PrismaService } from '../prisma.service'; 

新規で作成するファイルのソースコードは以下の通り。prisma.service.tsは、NestJSでPrismaを使う際に鍵となる部分である。ここでPrismaServiceを新規作成することで、NestJS内のアプリケーション内でPrisma経由でSQLiteデータベースに簡単にアクセスできるようになる。

prisma.service.ts
// (1) 必要なライブラリをインポートする
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

// (2) PrismaServiceクラスの実装
@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {

  async onModuleInit() {
    await this.$connect();
  }

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

次に/src/auth/auth.controller.tsを実装する。このファイルはユーザのログインを処理する。

/src/auth/auth.controller.ts
// (1) 必要なパッケージをインストールする
import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local-auth.guard'; // LocalAuthGuardは別途作成

// (2) AuthControllerクラスの定義
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  // (3) loginエンドポイントの定義
  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

上述のAuthControllerクラスは、ユーザのログインリクエストを処理し、正しい資格情報が提供されたときにJWTを返す役割を果たす。

import { LocalAuthGuard } from './local-auth.guard';

これに該当するファイルが存在しないので、別途書く。

local-auth.guard.ts
// (1) 必要なパッケージをインストール
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

// LocalAuthGuardクラスの作成
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const result = (await super.canActivate(context)) as boolean;
    const request = context.switchToHttp().getRequest();

    if (result && request.user) {
      return true;
    }

    throw new UnauthorizedException('Invalid credentials');
  }
}

以下のLocalAuthGuardのコードの部分に焦点を当てて解説する。この部分は、Passportのlocalを使ってユーザの資格情報を検証するためのガード(Guard)である。

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const result = (await super.canActivate(context)) as boolean;
    const request = context.switchToHttp().getRequest();

    // ユーザが正しく検証された場合、request.userにユーザ情報がセットされる
    if (result && request.user) {
      return true;
    }

    // 
    throw new UnauthorizedException('Invalid credentials');
  }
}

ハッシュ化

ハッシュ化のためには、bcryptライブラリを使用するのが一般的だ。以下に、bcryptを使用してパスワードをハッシュ化し、ユーザの認証を行う方法を示す。

(1) bcryptのインストール

$ npm install bcrypt

(2) AuthServiceを変更する

/src/auth/auth.service.tsに以下の変更を加える。

import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}

  async hashPassword(password: string): Promise<string> {
    const salt = await bcrypt.genSalt();
    return bcrypt.hash(password, salt);
  }

  async validateUser(email: string, pass: string): Promise<any> {
    const user = await this.prisma.user.findUnique({ where: { email } });
    if (user && await bcrypt.compare(pass, user.password)) {
      // ハッシュ化されたパスワードと比較
      return user;
    }
    return null;
  }

  // ... 他の部分はそのまま
}

(3) ハッシュ化を実装する

もしユーザーを新規に作成する機能がある場合、パスワードをハッシュ化してからデータベースに保存する必要がある。

async createUser(email: string, password: string) {
  const hashedPassword = await this.hashPassword(password);
  return this.prisma.user.create({
    data: {
      email: email,
      password: hashedPassword,
    },
  });
}

ダミーデータを使って認証を確認する

(1) ユーザの作成

/src/user/user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post('create')
  async create(@Body('email') email: string, @Body('password') password: string) {
    return this.userService.createUser(email, password);
  }
}
/src/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import * as bcrypt from 'bcrypt';

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

  async createUser(email: string, password: string) {
    const hashedPassword = await bcrypt.hash(password, 10); // 10 is the saltRounds
    return this.prisma.user.create({
      data: {
        email,
        password: hashedPassword,
      },
    });
  }
}

(2) ユーザの作成

以下のようなPOSTリクエストを作成。

curl -X POST http://localhost:3000/user/create \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password123"}'

(3) ユーザの認証(ログイン)

作ったユーザのデータを使ってログインを試す。

curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password123"}'

上述のコマンドが成功すると、レスポンスとしてJWTトークンが返る。

最終ディレクトリ

📂 your-nestjs-project
│
├── 📂 src
│   │
│   ├── 📂 auth
│   │   ├── auth.controller.ts  (エンドポイントを定義するコントローラ)
│   │   ├── auth.service.ts     (認証に関連するサービス)
│   │   ├── jwt.strategy.ts     (JWT認証ストラテジ)
│   │   └── local-auth.guard.ts (Local認証ガード)
│   │
│   ├── 📂 user
        │
        ├── 📂 dto
        │   └── create-user.dto.ts  (ユーザー作成時のデータ転送オブジェクト)
        │
        ├── 📂 entities
        │   └── user.entity.ts      (ユーザーエンティティ、Prismaを使っている場合は不要)
        │
        ├── 📜 user.controller.ts   (ユーザーに関連するAPIエンドポイントを定義するコントローラ)
        ├── 📜 user.service.ts      (ユーザーに関連するビジネスロジックを処理するサービス)
        └── ...                     (他のユーザー関連のモジュール、インターフェース、ユーティリティなど)
│   │
│   ├── 📂 prisma
│   │   ├── prisma.service.ts   (Prismaサービス)
│   │   └── schema.prisma      (Prismaのデータベーススキーマ)
│   │
│   └── 📜 main.ts              (アプリケーションのエントリポイント)
│
├── 📂 node_modules             (NPMパッケージ)
│
├── 📜 package.json             (NPM設定とスクリプト)
└── 📜 tsconfig.json            (TypeScriptの設定)

参考サイト一覧

https://docs.nestjs.com/security/authentication

https://docs.nestjs.com/security/encryption-and-hashing

GitHubで編集を提案

Discussion