【Node.js実戦習得】第16回:パスワードハッシュ化(bcrypt / Argon2)とセッション認証の仕組み

社員ブログ

1. なぜプレーンテキスト保存は絶対NGなのか?ハッシュ化と暗号化の根本的違い

酒本先輩!

今回からいよいよ認証編ですね!

これまでは誰でもカート操作ができる仕様でしたが、ついにユーザー認証を実装するんですね!

ユーザーのパスワードって、データベースにそのまま保存しちゃいけないというのは知っています!

でも、暗号化して保存するのとハッシュ化するのって、何が違うんですか?

とっても重要で基礎的なポイントね!

 

結論から言うと、パスワードの保存に『暗号化(復号可能な変換)』を使っては絶対ダメで、必ず『一方向ハッシュ化(復号不可能な変換)』を使わなければならないわ。

  • 暗号化(可逆): 鍵(Secret Key)を使ってデータを復号可能。万が一サーバーや鍵が漏洩した場合、元のパスワードがそのまま暴露されてしまう。
  • ハッシュ化(不可逆): 元の文字列から一方向のハッシュ値を生成。元に戻す方法は存在しない。認証時は『入力されたパスワードを再度ハッシュ化して比較』する。
【パスワード検証の仕組み(不可逆処理)】

1. ユーザー入力: "mySuperSecret123"
│
▼ (ハッシュ関数)
2. 計算されたハッシュ値: $2b$10$e8pX...
│
▼ (一致確認)
3. DBに保存されたハッシュ値: $2b$10$e8pX...

さらに、単なるハッシュ化(SHA-256など)だと、レインボーテーブル(事前計算されたハッシュ値の辞書)や GPU を使った超高速な総当たり攻撃に耐えられないの。

そこで必要になるのが 『ソルト(Salt:ランダムな塩)』 の付与と 『ストレッチング(計算負荷を高める繰り返し処理)』 よ!

2. 実務のデファクトアルゴリズム比較:bcrypt vs Argon2

Node.js の認証開発でよく使われる安全なアルゴリズムが bcrypt と Argon2 よ。

それぞれの特徴を比較してみましょう。

  • bcrypt
    • 特徴・歴史:長年使われており、圧倒的な実績とライブラリの安定性がある
    • 防御対象:GPU による並列総当たり攻撃
    • パラメータ調整:cost factor(計算回数)のみ
    • 実務での採用度:最も一般的で導入が容易
    • 注意点:入力の先頭 72 バイトまでしかハッシュに使われない(それ以降の文字は無視される)
  • Argon2(Argon2id)
    • 特徴・歴史:Password Hashing Competition(PHC)の優勝アルゴリズム。現在最も推奨される最新規格
    • 防御対象:GPU / ASIC(専用ハードウェア)やサイドチャネル攻撃
    • パラメータ調整:memoryCost(メモリ使用量)、timeCost(時間)、parallelism(並列度)
    • 実務での採用度:セキュリティ要件が高い新規プロダクトで積極採用

講義での選択:

今回は環境依存のエラーが少なく、Node.js エコシステムで最も広く使われている bcrypt を使用して実装を学んでいきます。
bcrypt の 72 バイト制限は、後ほど Zod のバリデーションで入力の上限として弾きます。

3. ステートフル認証の王道:「セッション認証」の仕組みと Cookie のセキュリティ属性

パスワードを安全に保存できたら、次はログイン状態をどう保持するかね。ウェブアプリの認証には大きく分けて 『セッション認証』 と 『JWT認証(第17回で解説)』 があるわ。

 

今回はサーバー側で状態を保持する ステートフルな『セッション認証』 を学ぶわよ。

【セッション認証の処理フロー】

1. クライアント - [ ID / Password ] -> サーバー(パスワード検証)
2. サーバー - [ セッションID発行 ] -> メモリ / Redis に保存(sid -> userId)
3. サーバー - [ Set-Cookie: sid=xxx ] -> クライアント(レスポンスヘッダー)
4. クライアント - [ Cookie: sid=xxx ] -> サーバー(2回目以降の全リクエスト)

【超重要】Cookie に付与すべき必須セキュリティ属性

Cookie にセッション ID を乗せてやり取りする際、以下の属性を設定しないと脆弱性(XSS や CSRF など)の温床になります。

  • HttpOnly: JavaScript(document.cookie)からのアクセスを禁止し、XSS 攻撃によるセッションハイジャックを防ぐ。
  • Secure: HTTPS 通信時のみ Cookie を送信する(ブラウザは localhost に限り HTTP でも許可する)。
  • SameSite=Lax(または Strict): 外部サイトからのクロスサイトリクエスト時に Cookie 送信を制限し、CSRF 攻撃を防ぐ。

注意:

express-session の標準のセッション保存先(MemoryStore)はサーバーのメモリ上にあるため、サーバーを再起動するとログイン状態が消え、複数台構成にも対応できません。
今回の動作確認用としては十分ですが、本番では Redis などの外部ストアを指定します。

4. 実践コード:パスワードハッシュ化ユーティリティとユーザー登録/ログイン実装

それでは必要なパッケージをインストールして実装に入りましょう!

今回も第12〜15回と同じ Controller → Service → Repository のレイヤー構成を守るわよ。

Controller から直接 Prisma を呼ぶのはナシ!

1. パッケージのインストール

npm install bcrypt express-session
npm install -D @types/bcrypt @types/express-session

補足:

npm 11.19 以降では、パッケージのインストールスクリプトが初期状態でブロックされ、npm warn install-scripts と表示されます。
bcrypt 6 はビルド済みのバイナリを同梱しているので、スクリプトを実行しなくても動きます(npm install-scripts deny bcrypt で警告を止められます)。
Prisma と tsx(esbuild)の分は、第13回の時点で許可していなければ npm install-scripts approve prisma @prisma/engines esbuild を実行してください。

2. パスワードハッシュ化ユーティリティ(src/utils/password.util.ts)

// src/utils/password.util.ts
import bcrypt from 'bcrypt';

// ストレッチングのコストファクター(標準値: 10〜12)
const SALT_ROUNDS = 10;

/**
 * 平文パスワードをソルト付きでハッシュ化する
 */
export const hashPassword = async (password: string): Promise<string> => {
  return await bcrypt.hash(password, SALT_ROUNDS);
};

/**
 * 平文パスワードとハッシュ値を比較・検証する
 */
export const comparePassword = async (
  plainText: string,
  hashedText: string
): Promise<boolean> => {
  return await bcrypt.compare(plainText, hashedText);
};

3. スキーマ定義への User モデル追加(prisma/schema.prisma)

第13回で作った schema.prisma の末尾に、以下の User モデルを追記します。

// prisma/schema.prisma(末尾に追記)

// ユーザーモデル
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique @db.VarChar(255)
  password  String   @db.VarChar(255) // ハッシュ化されたパスワードを格納
  name      String   @db.VarChar(100)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

スキーマを追加したら、マイグレーションを実行して Prisma Client を再生成します。

npx prisma migrate dev --name add_user_model
npx prisma generate

あれ、migrate dev のあとに generate も要るんですか?

Prisma 6 までは migrate dev が自動で Client を作り直してくれたけど、Prisma 7 からは migrate dev は DB を更新するだけになったの。

generate を忘れると、生成済みの Client に User がないままだから、prisma.user で型エラーになるわよ。

@unique を付けたので、第15回で話していた P2002 がここで出てくるんですね!

そう。

メールアドレスの重複チェックを『先に検索して、無ければ作成』と書くと、同時に2人が登録した場合にすり抜けてしまうの。

DB の一意制約に任せて、エラー(P2002)を ConflictError に変換するのが確実よ。

第15回の toAppError がそのまま使えるわ。

4. エラークラスの追加と型定義

認証失敗用の UnauthorizedError(401)を app.error.ts に追加します。

// src/errors/app.error.ts

// ベースとなるアプリケーション例外
export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly code: string
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 不正なリクエスト (400)
export class BadRequestError extends AppError {
  constructor(message = 'リクエストの内容が正しくありません') {
    super(message, 400, 'BAD_REQUEST');
  }
}

// リソース未存在エラー (404)
export class NotFoundError extends AppError {
  constructor(message = '指定されたリソースが見つかりません') {
    super(message, 404, 'NOT_FOUND');
  }
}

// 一意制約・衝突エラー (409)
export class ConflictError extends AppError {
  constructor(message = 'リソースが既に存在するか衝突しています') {
    super(message, 409, 'CONFLICT');
  }
}

// 認証エラー (401)
export class UnauthorizedError extends AppError {
  constructor(message = '認証が必要です') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

パスワードのハッシュ値を API やサービス層に漏らさないよう、ユーザーの型を2つに分けます。

// src/types/user.type.ts

// API やサービス層で扱うユーザー(パスワードを含まない)
export interface User {
  id: number;
  email: string;
  name: string;
}

// ログイン検証でだけ使う、ハッシュ化済みパスワード付きのユーザー
export interface UserCredentials extends User {
  passwordHash: string;
}

5. Repository 層(インターフェースと Prisma 実装)

// src/repositories/interfaces/user.repository.interface.ts
import { User, UserCredentials } from '../../types/user.type.js';

export interface IUserRepository {
  findById(id: number): Promise<User | undefined>;
  findCredentialsByEmail(email: string): Promise<UserCredentials | undefined>;
  /** メールアドレスが重複している場合は ConflictError を投げる */
  create(data: { email: string; name: string; passwordHash: string }): Promise<User>;
}
// src/repositories/prisma/prisma-user.repository.ts
import { PrismaClient } from '../../generated/prisma/client/client.js';
import { IUserRepository } from '../interfaces/user.repository.interface.js';
import { User, UserCredentials } from '../../types/user.type.js';
import { toAppError } from './prisma-error.util.js';

export class PrismaUserRepository implements IUserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  private mapToDomain(raw: { id: number; email: string; name: string }): User {
    return { id: raw.id, email: raw.email, name: raw.name };
  }

  async findById(id: number): Promise<User | undefined> {
    const user = await this.prisma.user.findUnique({ where: { id } });
    return user ? this.mapToDomain(user) : undefined;
  }

  async findCredentialsByEmail(email: string): Promise<UserCredentials | undefined> {
    const user = await this.prisma.user.findUnique({ where: { email } });
    if (!user) return undefined;
    return { ...this.mapToDomain(user), passwordHash: user.password };
  }

  async create(data: { email: string; name: string; passwordHash: string }): Promise<User> {
    try {
      const created = await this.prisma.user.create({
        data: { email: data.email, name: data.name, password: data.passwordHash },
      });
      return this.mapToDomain(created);
    } catch (error) {
      // P2002(email の一意制約違反)→ ConflictError(409)
      throw toAppError(error, { conflict: 'このメールアドレスは既に登録されています' });
    }
  }
}

6. 入力バリデーション(src/schemas/auth.schema.ts)

第11回の Zod スキーマと validateRequest をここでも使います。bcrypt の 72 バイト制限もここで弾きます。

// src/schemas/auth.schema.ts
import { z } from 'zod';

const emailSchema = z.email('メールアドレスの形式が正しくありません。').trim().toLowerCase();

export const registerSchema = z.object({
  email: emailSchema,
  // bcrypt は先頭 72 バイトまでしか使わないため、上限を設ける
  password: z
    .string()
    .min(8, 'パスワードは8文字以上で指定してください。')
    .refine((v) => Buffer.byteLength(v, 'utf8') <= 72, 'パスワードは72バイト以内で指定してください。'),
  name: z.string().trim().min(1, '名前は必須です。').max(100),
});

export const loginSchema = z.object({
  email: emailSchema,
  password: z.string().min(1, 'パスワードは必須です。'),
});

7. Service 層(src/services/auth.service.ts)

ユーザーが存在しない場合とパスワードが違う場合で同じメッセージを返しているのは、攻撃者に「そのメールアドレスは登録されているか」を悟らせないためです。

// src/services/auth.service.ts
import { IUserRepository } from '../repositories/interfaces/user.repository.interface.js';
import { User } from '../types/user.type.js';
import { hashPassword, comparePassword } from '../utils/password.util.js';
import { UnauthorizedError } from '../errors/app.error.js';

export class AuthService {
  constructor(private readonly userRepository: IUserRepository) {}

  register = async (input: { email: string; password: string; name: string }): Promise<User> => {
    const passwordHash = await hashPassword(input.password);
    // 重複チェックは DB の一意制約に任せる(Repository が P2002 を ConflictError に変換)
    return await this.userRepository.create({
      email: input.email,
      name: input.name,
      passwordHash,
    });
  };

  login = async (email: string, password: string): Promise<User> => {
    const credentials = await this.userRepository.findCredentialsByEmail(email);
    // ユーザー不在とパスワード不一致を同じメッセージにして、アカウントの有無を悟らせない
    const message = 'メールアドレスまたはパスワードが正しくありません';

    if (!credentials) {
      throw new UnauthorizedError(message);
    }
    const isValid = await comparePassword(password, credentials.passwordHash);
    if (!isValid) {
      throw new UnauthorizedError(message);
    }

    return { id: credentials.id, email: credentials.email, name: credentials.name };
  };

  getProfile = async (userId: number): Promise<User> => {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new UnauthorizedError('ユーザーが存在しません');
    }
    return user;
  };
}

8. Controller 層(src/controllers/auth.controller.ts)

ログイン成功時に req.session.regenerate() でセッション ID を作り直しています。
ログイン前に攻撃者が用意したセッション ID をそのまま使い続けさせる セッション固定攻撃 への対策です。

req.session.userId を使うために、express-session の型も拡張します(src/types/express-session.d.ts)。

// src/types/express-session.d.ts
import 'express-session';

// express-session の型拡張(セッションデータに userId を持たせる)
declare module 'express-session' {
interface SessionData {
userId: number;
}
}
// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { AuthService } from '../services/auth.service.js';

export class AuthController {
  constructor(private readonly authService: AuthService) {}

  // ユーザー新規登録
  register = async (req: Request, res: Response): Promise<void> => {
    const { email, password, name } = req.body;
    const user = await this.authService.register({ email, password, name });
    res.status(201).json(user);
  };

  // ログイン処理(セッション生成)
  login = async (req: Request, res: Response): Promise<void> => {
    const { email, password } = req.body;
    const user = await this.authService.login(email, password);

    // セッション固定攻撃対策:ログイン成功時にセッション ID を必ず作り直す
    await new Promise<void>((resolve, reject) => {
      req.session.regenerate((err) => (err ? reject(err) : resolve()));
    });

    // セッションにユーザー ID を記録(セッション ID が Cookie に設定される)
    req.session.userId = user.id;

    res.status(200).json({
      message: 'ログインに成功しました',
      user,
    });
  };

  // ログイン中のユーザー情報(Cookie が送られているかの確認用)
  me = async (req: Request, res: Response): Promise<void> => {
    const userId = req.session.userId;
    if (userId === undefined) {
      res.status(401).json({ error: 'UNAUTHORIZED', message: 'ログインしていません' });
      return;
    }
    const user = await this.authService.getProfile(userId);
    res.json(user);
  };

  // ログアウト(サーバー側のセッションを破棄)
  logout = async (req: Request, res: Response): Promise<void> => {
    await new Promise<void>((resolve, reject) => {
      req.session.destroy((err) => (err ? reject(err) : resolve()));
    });
    res.clearCookie('connect.sid');
    res.status(204).send();
  };
}

9. ルーティングと DI(src/routes/auth.route.ts)

第14回と同じく、ルーティング層で prisma → Repository → Service → Controller の順に組み立てます。

// src/routes/auth.route.ts
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { PrismaUserRepository } from '../repositories/prisma/prisma-user.repository.js';
import { AuthService } from '../services/auth.service.js';
import { AuthController } from '../controllers/auth.controller.js';
import { validateRequest } from '../middlewares/validate.middleware.js';
import { registerSchema, loginSchema } from '../schemas/auth.schema.js';

// 依存関係の組み立て(DI)
const userRepository = new PrismaUserRepository(prisma);
const authService = new AuthService(userRepository);
const authController = new AuthController(authService);

const router = Router();

router.post('/register', validateRequest({ body: registerSchema }), authController.register);
router.post('/login', validateRequest({ body: loginSchema }), authController.login);
router.get('/me', authController.me);
router.post('/logout', authController.logout);

export default router;

10. Session ミドルウェアの設定と組み込み

セッションの設定は src/config/session.ts にまとめます。
シークレットが未設定のまま起動しないよう、起動時にチェックします。

// src/config/session.ts
import session from 'express-session';

const secret = process.env.SESSION_SECRET;
if (!secret) {
  throw new Error('SESSION_SECRET が .env に設定されていません。');
}

// ※ デフォルトの MemoryStore は開発用。本番では Redis などの外部ストアを指定する
export const sessionMiddleware = session({
  secret,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true, // XSS対策 (JSからのCookie読み取り不可)
    secure: process.env.NODE_ENV === 'production', // 本番は HTTPS 時のみ送信
    sameSite: 'lax', // CSRF対策
    maxAge: 1000 * 60 * 60 * 24, // 有効期限: 1日
  },
});

.env にもシークレットを追加します(ランダムな長い文字列にしてください)。

# .env(追記)
SESSION_SECRET="change-me-to-a-long-random-string"

src/app.ts に Session ミドルウェアと認証ルートを組み込みます。
session はルーティングより前に登録します。
また、session.ts は import された時点で SESSION_SECRET を読むため、import ‘dotenv/config’; を app.ts の一番最初に書いて、先に .env を読み込ませています。

// src/app.ts
import 'dotenv/config';
import express, { Application, Request, Response } from 'express';
import { sessionMiddleware } from './config/session.js';
import authRouter from './routes/auth.route.js';
import cartRouter from './routes/cart.route.js';
import { errorHandler } from './middlewares/error.middleware.js';

const app: Application = express();
const PORT = 3000;

app.use(express.json());

// セッションミドルウェア(ルーティングより前に登録する)
app.use(sessionMiddleware);

app.use('/api/auth', authRouter);
app.use('/api/cart', cartRouter);

app.get('/health', (req: Request, res: Response) => {
  res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});

app.use((req: Request, res: Response) => {
  res.status(404).json({
    error: 'NOT_FOUND',
    message: '要求されたエンドポイントが存在しません。',
  });
});

app.use(errorHandler);

if (process.env.NODE_ENV !== 'test') {
  app.listen(PORT, () => {
    console.log(`[Server] App running on http://localhost:${PORT}`);
  });
}

export default app;

5. curl による動作確認:登録・ログイン・Cookie の検証

npm run dev

1. ユーザー登録(POST リクエスト)

curl -i -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "kumaki@example.com", "password": "MySecretPassword123", "name": "熊木"}'

実行結果:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{
  "id": 1,
  "email": "kumaki@example.com",
  "name": "熊木"
}

同じメールアドレスで再登録すると、P2002 が ConflictError に変換されて 409 Conflict が返ります。

HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8

{
  "error": "CONFLICT",
  "message": "このメールアドレスは既に登録されています"
}

不正な入力は Zod が 400 で弾きます。

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{
  "error": "VALIDATION_ERROR",
  "message": "リクエストデータに不備があります。",
  "details": [
    {
      "field": "email",
      "message": "メールアドレスの形式が正しくありません。",
      "rule": "invalid_format"
    },
    {
      "field": "password",
      "message": "パスワードは8文字以上で指定してください。",
      "rule": "too_small"
    },
    {
      "field": "name",
      "message": "名前は必須です。",
      "rule": "too_small"
    }
  ]
}

2. ログイン実行とレスポンスヘッダーの Set-Cookie 確認

Cookie をファイルに保存するため、-c オプションを付けます。

curl -i -c cookies.txt -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "kumaki@example.com", "password": "MySecretPassword123"}'

実行結果(レスポンスヘッダーの抜粋):

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Set-Cookie: connect.sid=s%3AqWQQQOmFxC...; Path=/; Expires=Tue, 13 Oct 2026 05:51:43 GMT; HttpOnly; SameSite=Lax

{
  "message": "ログインに成功しました",
  "user": {
    "id": 1,
    "email": "kumaki@example.com",
    "name": "熊木"
  }
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Set-Cookie: connect.sid=s%3AqWQQQOmFxC...; Path=/; Expires=Tue, 13 Oct 2026 05:51:43 GMT; HttpOnly; SameSite=Lax

{
  "message": "ログインに成功しました",
  "user": {
    "id": 1,
    "email": "kumaki@example.com",
    "name": "熊木"
  }
}

レスポンスの Set-Cookie に HttpOnly や SameSite=Lax がしっかり付与されていますね!

3. Cookie を送ってログイン状態を確認する

保存した Cookie を -b オプションで送ると、サーバー側のセッションからユーザーが特定されます。

curl -i -b cookies.txt http://localhost:3000/api/auth/me
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
  "id": 1,
  "email": "kumaki@example.com",
  "name": "熊木"
}

Cookie なし(未ログイン)で呼ぶと 401 です。

curl -i http://localhost:3000/api/auth/me
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8

{
  "error": "UNAUTHORIZED",
  "message": "ログインしていません"
}

4. ログアウトとパスワードのハッシュ値の確認

curl -i -b cookies.txt -c cookies.txt -X POST http://localhost:3000/api/auth/logout
HTTP/1.1 204 No Content
Set-Cookie: connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT

最後に DB の中身を覗いてみます(-x は1件ずつ縦に表示するオプションです)。

docker exec -it dev-postgres psql -U postgres -d my_layered_db -x -c 'SELECT id, email, name, password FROM "User";'
-[ RECORD 1 ]----------------------------------------------------------
id       | 1
email    | kumaki@example.com
name     | 熊木
password | $2b$10$RbmimTFlqUPl0V8wzoNua.9UcbsMIurCXB32/hJpcZDAvKMClcjMm

DB 内のパスワードが $2b$10$… という暗号みたいなハッシュ値になっていて、元のパスワードが一切見えなくなっています!

$2b$10$ の 10 が先ほどの SALT_ROUNDS(コスト)よ。

ソルトもこの文字列の中に含まれているから、別の列に保存する必要はないの。

本日のまとめ

  • ハッシュ化の絶対ルール:
    パスワードは可逆な暗号化ではなく不可逆なハッシュ化(bcrypt / Argon2)で保存する。ソルトとストレッチングにより総当たり・辞書攻撃を防ぐ。
    bcrypt は先頭 72 バイトまでしか使われない点に注意する。
  • セッション認証とステート保持:
    ログイン成功時にサーバー側でセッションを生成し、クライアントにはセッション ID のみを Cookie 経由で受け渡す。
    ログイン時に regenerate() でセッション ID を作り直してセッション固定攻撃を防ぐ。
  • Cookie のセキュリティ防御線:
    HttpOnly(XSS 対策)、Secure(通信盗聴対策)、SameSite(CSRF 対策)を適切に設定する。
  • レイヤー構成と P2002:
    認証も Controller → Service → Repository で分離し、メールアドレスの重複は DB の一意制約(P2002)を ConflictError に変換して扱う。

次回:第17回「ステートレスな認証の主役!JWT(JSON Web Token)によるセキュアな認可システム」へ続く

タイトルとURLをコピーしました