JWT認証の実装(ログイン機能)

2025-08-29

はじめに

前回までで、ReactフロントエンドとExpressバックエンドの連携が完成しました。しかし、現在のアプリケーションには重大なセキュリティ上の問題があります:誰でもAPIにアクセスでき、すべてのユーザーのデータが見えてしまうということです。

今回は、JWT(JSON Web Token)認証を実装して、ユーザーごとにデータを分離し、セキュアなログイン機能を追加します。これにより、各ユーザーが自分のTODOデータのみを操作できるようになります。

JWT認証の基本概念

JWTとは

JWTはJSONオブジェクトをエンコードしたトークンで、以下の3つの部分から構成されます:

  1. ヘッダー: トークンタイプと署名アルゴリズム
  2. ペイロード: ユーザー情報や有効期限などのクレーム
  3. 署名: トークンの改ざんを防ぐための検証部分

認証フローの全体像

[図解: JWT認証フロー]
1. ユーザーがログイン情報を送信
2. サーバーが認証しJWTを発行
3. クライアントがJWTを保存(ローカルストレージなど)
4. クライアントがAPIリクエスト時にJWTをヘッダーに付加
5. サーバーがJWTを検証して権限を確認

バックエンドの認証機能実装

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

npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs

2. 環境変数の設定

.envファイルにJWTの秘密鍵を追加:

# .env
DATABASE_URL="mysql://app_user:password@localhost:3306/todo_app"
JWT_SECRET="your-super-secret-jwt-key-at-least-32-characters-long"
JWT_EXPIRES_IN="7d"

3. ユーザーモデルの拡張

前回定義したUserモデルを活用し、パスワードハッシュ化のためのメソッドを追加します。

// prisma/schema.prisma(既存のUserモデルにメソッドを追加するための準備)
// 注: Prismaスキーマではメソッドを直接定義できないため、別ファイルで実装します

4. パスワードハッシュ化ユーティリティ

// src/utils/password.js
const bcrypt = require('bcryptjs');

const SALT_ROUNDS = 12;

// パスワードのハッシュ化
const hashPassword = async (password) => {
  return await bcrypt.hash(password, SALT_ROUNDS);
};

// パスワードの検証
const verifyPassword = async (password, hashedPassword) => {
  return await bcrypt.compare(password, hashedPassword);
};

module.exports = {
  hashPassword,
  verifyPassword
};

5. JWTユーティリティ

// src/utils/jwt.js
const jwt = require('jsonwebtoken');

const generateToken = (userId) => {
  return jwt.sign(
    { userId },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN }
  );
};

const verifyToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (error) {
    throw new Error('無効または期限切れのトークンです');
  }
};

const extractTokenFromHeader = (authHeader) => {
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return null;
  }
  return authHeader.substring(7); // "Bearer "の後ろのトークン部分を取得
};

module.exports = {
  generateToken,
  verifyToken,
  extractTokenFromHeader
};

6. ミドルウェアの実装

// src/middleware/auth.js
const { verifyToken, extractTokenFromHeader } = require('../utils/jwt');
const prisma = require('../lib/prisma');

const authenticateUser = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    const token = extractTokenFromHeader(authHeader);

    if (!token) {
      return res.status(401).json({ error: '認証トークンが必要です' });
    }

    const decoded = verifyToken(token);
    const user = await prisma.user.findUnique({
      where: { id: decoded.userId },
      select: { id: true, email: true, name: true } // パスワードは返さない
    });

    if (!user) {
      return res.status(401).json({ error: 'ユーザーが見つかりません' });
    }

    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({ error: error.message });
  }
};

module.exports = {
  authenticateUser
};

7. 認証ルートの実装

// src/routes/auth.js
const express = require('express');
const { hashPassword, verifyPassword } = require('../utils/password');
const { generateToken } = require('../utils/jwt');
const prisma = require('../lib/prisma');

const router = express.Router();

// ユーザー登録
router.post('/register', async (req, res) => {
  try {
    const { email, password, name } = req.body;

    // バリデーション
    if (!email || !password) {
      return res.status(400).json({ error: 'メールアドレスとパスワードは必須です' });
    }

    if (password.length < 6) {
      return res.status(400).json({ error: 'パスワードは6文字以上必要です' });
    }

    // 既存ユーザーのチェック
    const existingUser = await prisma.user.findUnique({
      where: { email }
    });

    if (existingUser) {
      return res.status(400).json({ error: 'このメールアドレスは既に使用されています' });
    }

    // パスワードのハッシュ化
    const hashedPassword = await hashPassword(password);

    // ユーザー作成
    const user = await prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        name: name || null
      },
      select: {
        id: true,
        email: true,
        name: true,
        createdAt: true
      }
    });

    // JWTトークンの発行
    const token = generateToken(user.id);

    res.status(201).json({
      message: 'ユーザー登録が成功しました',
      user,
      token
    });
  } catch (error) {
    console.error('ユーザー登録エラー:', error);
    res.status(500).json({ error: 'ユーザー登録に失敗しました' });
  }
});

// ログイン
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // バリデーション
    if (!email || !password) {
      return res.status(400).json({ error: 'メールアドレスとパスワードは必須です' });
    }

    // ユーザーの検索
    const user = await prisma.user.findUnique({
      where: { email }
    });

    if (!user) {
      return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません' });
    }

    // パスワードの検証
    const isValidPassword = await verifyPassword(password, user.password);

    if (!isValidPassword) {
      return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません' });
    }

    // JWTトークンの発行
    const token = generateToken(user.id);

    // パスワード情報を除外してユーザー情報を返す
    const userWithoutPassword = {
      id: user.id,
      email: user.email,
      name: user.name,
      createdAt: user.createdAt
    };

    res.json({
      message: 'ログイン成功',
      user: userWithoutPassword,
      token
    });
  } catch (error) {
    console.error('ログインエラー:', error);
    res.status(500).json({ error: 'ログインに失敗しました' });
  }
});

// ユーザー情報の取得(認証が必要)
router.get('/me', async (req, res) => {
  try {
    // このルートは認証ミドルウェアで保護されることを想定
    res.json({ user: req.user });
  } catch (error) {
    console.error('ユーザー情報取得エラー:', error);
    res.status(500).json({ error: 'ユーザー情報の取得に失敗しました' });
  }
});

module.exports = router;

8. TODOルートの認証対応改造

// src/routes/todos.js(抜粋:変更部分のみ)
const express = require('express');
const { authenticateUser } = require('../middleware/auth');
const prisma = require('../lib/prisma');

const router = express.Router();

// すべてのルートを認証で保護
router.use(authenticateUser);

// GET /todos - ユーザーのTODOのみを取得
router.get('/', async (req, res) => {
  try {
    const todos = await prisma.todo.findMany({
      where: { userId: req.user.id },
      orderBy: { createdAt: 'desc' }
    });
    res.json(todos);
  } catch (error) {
    console.error('データ取得エラー:', error);
    res.status(500).json({ error: 'データの取得に失敗しました' });
  }
});

// POST /todos - ユーザーに関連付けてTODOを作成
router.post('/', async (req, res) => {
  try {
    const { title, completed = false } = req.body;

    if (!title || title.trim() === '') {
      return res.status(400).json({ error: 'タイトルは必須です' });
    }

    const todo = await prisma.todo.create({
      data: {
        title: title.trim(),
        completed,
        userId: req.user.id // ユーザーIDを関連付け
      }
    });

    res.status(201).json(todo);
  } catch (error) {
    console.error('データ作成エラー:', error);
    res.status(500).json({ error: 'TODOの作成に失敗しました' });
  }
});

// 他のCRUD操作も同様にuserIdでフィルタリングするように修正
// ...

9. メインサーバーファイルの更新

// server.js
const express = require('express');
require('dotenv').config();

const authRoutes = require('./src/routes/auth');
const todoRoutes = require('./src/routes/todos');

const app = express();
const port = process.env.PORT || 3000;

app.use(express.json());

// ルートの設定
app.use('/api/auth', authRoutes);
app.use('/api/todos', todoRoutes); // 認証が必要なルート

// 健康状態チェック用エンドポイント(認証不要)
app.get('/api/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

// 404ハンドリング
app.use('/api/*', (req, res) => {
  res.status(404).json({ error: 'エンドポイントが見つかりません' });
});

// エラーハンドリングミドルウェア
app.use((error, req, res, next) => {
  console.error('予期せぬエラー:', error);
  res.status(500).json({ error: 'サーバー内部エラーが発生しました' });
});

app.listen(port, () => {
  console.log(`サーバーが起動しました: http://localhost:${port}`);
});

フロントエンドの認証機能実装

1. 認証サービス層の追加

// src/api/authService.js
import apiClient from './client';

export const register = async (userData) => {
  try {
    const response = await apiClient.post('/auth/register', userData);
    return response.data;
  } catch (error) {
    console.error('ユーザー登録エラー:', error);
    throw error;
  }
};

export const login = async (credentials) => {
  try {
    const response = await apiClient.post('/auth/login', credentials);
    return response.data;
  } catch (error) {
    console.error('ログインエラー:', error);
    throw error;
  }
};

export const getCurrentUser = async () => {
  try {
    const response = await apiClient.get('/auth/me');
    return response.data;
  } catch (error) {
    console.error('ユーザー情報取得エラー:', error);
    throw error;
  }
};

2. トークン管理のためのAPIクライアント更新

// src/api/client.js(更新)
import axios from 'axios';

const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000';

const apiClient = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// リクエストインターセプター - トークンを自動付加
apiClient.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('authToken');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// レスポンスインターセプター - トークン期限切れ処理
apiClient.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    if (error.response?.status === 401) {
      // トークン期限切れや無効なトークンの場合
      localStorage.removeItem('authToken');
      localStorage.removeItem('user');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default apiClient;

3. 認証コンテキストの作成

// src/contexts/AuthContext.js
import React, { createContext, useState, useContext, useEffect } from 'react';
import { login, register, getCurrentUser } from '../api/authService';

const AuthContext = createContext();

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // アプリ起動時にローカルストレージからユーザー情報を復元
    const token = localStorage.getItem('authToken');
    const savedUser = localStorage.getItem('user');

    if (token && savedUser) {
      setUser(JSON.parse(savedUser));
      // トークンの有効性を確認
      validateToken();
    }
    setLoading(false);
  }, []);

  const validateToken = async () => {
    try {
      await getCurrentUser();
    } catch (error) {
      logout();
    }
  };

  const loginUser = async (credentials) => {
    try {
      setError(null);
      const response = await login(credentials);
      const { user: userData, token } = response;

      localStorage.setItem('authToken', token);
      localStorage.setItem('user', JSON.stringify(userData));
      setUser(userData);

      return response;
    } catch (error) {
      setError(error.response?.data?.error || 'ログインに失敗しました');
      throw error;
    }
  };

  const registerUser = async (userData) => {
    try {
      setError(null);
      const response = await register(userData);
      const { user: newUser, token } = response;

      localStorage.setItem('authToken', token);
      localStorage.setItem('user', JSON.stringify(newUser));
      setUser(newUser);

      return response;
    } catch (error) {
      setError(error.response?.data?.error || '登録に失敗しました');
      throw error;
    }
  };

  const logout = () => {
    localStorage.removeItem('authToken');
    localStorage.removeItem('user');
    setUser(null);
    setError(null);
  };

  const value = {
    user,
    loading,
    error,
    loginUser,
    registerUser,
    logout,
    isAuthenticated: !!user
  };

  return (
    
      {children}
    
  );
};

4. ログイン/登録コンポーネント

// src/components/Login.js
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate, Link } from 'react-router-dom';

const Login = () => {
  const [credentials, setCredentials] = useState({
    email: '',
    password: ''
  });
  const [isLoading, setIsLoading] = useState(false);

  const { loginUser, error } = useAuth();
  const navigate = useNavigate();

  const handleChange = (e) => {
    setCredentials({
      ...credentials,
      [e.target.name]: e.target.value
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsLoading(true);

    try {
      await loginUser(credentials);
      navigate('/todos');
    } catch (error) {
      // エラーはコンテキストで処理される
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="auth-container">
      <div className="auth-form">
        <h2>ログイン</h2>

        {error && <div className="error-message">{error}</div>}

        <form onSubmit={handleSubmit}>
          <div className="form-group">
            <label htmlFor="email">メールアドレス</label>
            <input
              type="email"
              id="email"
              name="email"
              value={credentials.email}
              onChange={handleChange}
              required
            />
          </div>

          <div className="form-group">
            <label htmlFor="password">パスワード</label>
            <input
              type="password"
              id="password"
              name="password"
              value={credentials.password}
              onChange={handleChange}
              required
            />
          </div>

          <button 
            type="submit" 
            disabled={isLoading}
            className="auth-button"
          >
            {isLoading ? '処理中...' : 'ログイン'}
          </button>
        </form>

        <p className="auth-link">
          アカウントをお持ちでない方は <Link to="/register">こちら</Link>
        </p>
      </div>
    </div>
  );
};

export default Login;

次のステップ

JWT認証の実装が完了し、ユーザーごとにデータを分離できるようになりました。次回は、CORS設定を適切に構成して、フロントエンドとバックエンドの安全な接続を確保します。これにより、クロスオリジンリクエストの問題を解決し、本番環境でのデプロイ準備を整えます。

まとめ

今回実装したJWT認証システムの要点:

  1. 安全なパスワード管理: bcryptを使用したパスワードのハッシュ化
  2. トークンベース認証: JWTを使用したステートレスな認証
  3. ルート保護: ミドルウェアによるAPIエンドポイントの保護
  4. フロントエンド連携: トークンの自動付加と期限切れ処理
  5. ユーザーコンテキスト: React Contextを使用した認証状態の管理

これらの実装により、本格的なユーザー認証システムが完成し、アプリケーションのセキュリティと信頼性が大幅に向上しました。