PR

Django + React MVPに認証機能を追加する完全ガイド

はじめに

MVPから本格的なプロダクトへと発展させる上で、認証機能の実装は最も重要なステップの一つです。認証機能により、ユーザーの識別、権限管理、セキュリティ強化が可能になります。この記事では、前回構築したDjango + React + MySQLのMVPに、JWTベースの認証システムを追加する方法を解説します。

1. 認証方式の選定

1.1. 主な認証方式の比較

セッションベース認証:

  • サーバー側でセッション情報を保存
  • クライアント側にはセッションIDを保存するクッキーを発行
  • 従来の認証方式で安定している
  • 分散環境では管理が複雑になる

トークンベース認証(JWT):

  • ステートレス:サーバー側での情報保存不要
  • トークンはクライアント側に保存
  • スケーラビリティが高い
  • 有効期限の設定が可能

OAuth/OpenID Connect:

  • サードパーティ認証
  • Google、Facebook、GitHubなどのアカウントを利用
  • 実装が複雑だが、ユーザー体験が向上

1.2. 今回の選定:JWT認証

MVPから本格的な製品へのステップアップとして、JWTベースの認証システムを採用します。これにより、以下のメリットが得られます:

  • フロントエンドとバックエンドの分離が容易
  • マイクロサービスアーキテクチャとの親和性
  • 拡張性が高い
  • モバイルアプリ連携も容易

2. バックエンド(Django)での認証実装

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

まず、requirements.txtに以下のパッケージを追加します:

djangorestframework-simplejwt>=5.2.0

2.2. settings.pyの設定

backend/core/settings.pyに以下の設定を追加します:

# 追加のインポート
from datetime import timedelta

# REST Framework設定を更新
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

# JWT設定
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': True,
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUTH_HEADER_TYPES': ('Bearer',),
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
}

2.3. URLの設定

backend/core/urls.pyにJWT認証用のエンドポイントを追加します:

from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)
from api.views import TaskViewSet

router = DefaultRouter()
router.register(r'tasks', TaskViewSet)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include(router.urls)),
    # JWT認証エンドポイント
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]

2.4. ユーザー登録API

カスタムユーザー登録APIを作成します。backend/api/serializers.pyに以下を追加:

from django.contrib.auth.models import User
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = '__all__'

class UserSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True)

    class Meta:
        model = User
        fields = ('id', 'username', 'email', 'password', 'first_name', 'last_name')
        extra_kwargs = {'password': {'write_only': True}}

    def create(self, validated_data):
        user = User.objects.create_user(
            username=validated_data['username'],
            email=validated_data.get('email', ''),
            password=validated_data['password'],
            first_name=validated_data.get('first_name', ''),
            last_name=validated_data.get('last_name', ''),
        )
        return user

backend/api/views.pyに以下を追加:

from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from django.contrib.auth.models import User
from .models import Task
from .serializers import TaskSerializer, UserSerializer

class TaskViewSet(viewsets.ModelViewSet):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer
    permission_classes = [IsAuthenticated]  # 認証が必要
    
    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)
    
    def get_queryset(self):
        # ユーザーに関連付けられたタスクのみを返す
        return Task.objects.filter(owner=self.request.user)

class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    
    def get_permissions(self):
        # 登録時は認証不要、その他は認証が必要
        if self.action == 'create':
            permission_classes = [AllowAny]
        else:
            permission_classes = [IsAuthenticated]
        return [permission() for permission in permission_classes]
    
    @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
    def me(self, request):
        serializer = self.get_serializer(request.user)
        return Response(serializer.data)

URLの設定を更新:

# backend/core/urls.py
router.register(r'users', UserViewSet)

2.5. タスクモデルの更新

タスクモデルにユーザーとの関連付けを追加します。backend/api/models.pyを更新:

from django.db import models
from django.contrib.auth.models import User

class Task(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    completed = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
    
    def __str__(self):
        return self.title

データベースのマイグレーションを実行:

docker-compose exec backend python manage.py makemigrations
docker-compose exec backend python manage.py migrate

3. フロントエンド(React)での認証実装

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

cd frontend
npm install axios jwt-decode react-router-dom

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

認証状態を管理するためのコンテキストを作成します。frontend/src/contexts/AuthContext.jsを作成:

import React, { createContext, useState, useEffect } from 'react';
import axios from 'axios';
import jwtDecode from 'jwt-decode';

const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);
  const [accessToken, setAccessToken] = useState(localStorage.getItem('accessToken') || null);
  const [refreshToken, setRefreshToken] = useState(localStorage.getItem('refreshToken') || null);
  const [loading, setLoading] = useState(true);

  // APIインスタンスを作成
  const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000/api';
  
  const authAxios = axios.create({
    baseURL: API_URL,
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
  });

  // トークンの有効期限チェック
  const isTokenExpired = (token) => {
    if (!token) return true;
    try {
      const decoded = jwtDecode(token);
      return decoded.exp * 1000 < new Date().getTime();
    } catch (error) {
      return true;
    }
  };

  // トークンリフレッシュ
  const refreshAccessToken = async () => {
    try {
      const response = await axios.post(`${API_URL}/token/refresh/`, {
        refresh: refreshToken,
      });
      
      const { access } = response.data;
      setAccessToken(access);
      localStorage.setItem('accessToken', access);
      
      return access;
    } catch (error) {
      logout();
      return null;
    }
  };

  // ログイン
  const login = async (username, password) => {
    try {
      const response = await axios.post(`${API_URL}/token/`, {
        username,
        password,
      });
      
      const { access, refresh } = response.data;
      
      setAccessToken(access);
      setRefreshToken(refresh);
      
      localStorage.setItem('accessToken', access);
      localStorage.setItem('refreshToken', refresh);
      
      // ユーザー情報を取得
      await getUserInfo(access);
      
      return true;
    } catch (error) {
      return false;
    }
  };

  // ユーザー情報の取得
  const getUserInfo = async (token) => {
    try {
      const response = await axios.get(`${API_URL}/users/me/`, {
        headers: {
          Authorization: `Bearer ${token || accessToken}`,
        },
      });
      
      setCurrentUser(response.data);
      return response.data;
    } catch (error) {
      return null;
    }
  };

  // ログアウト
  const logout = () => {
    setCurrentUser(null);
    setAccessToken(null);
    setRefreshToken(null);
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
  };

  // 登録
  const register = async (userData) => {
    try {
      await axios.post(`${API_URL}/users/`, userData);
      return true;
    } catch (error) {
      return false;
    }
  };

  // トークンとユーザー情報の初期化
  useEffect(() => {
    const initAuth = async () => {
      if (accessToken) {
        if (isTokenExpired(accessToken)) {
          if (refreshToken && !isTokenExpired(refreshToken)) {
            const newAccessToken = await refreshAccessToken();
            if (newAccessToken) {
              await getUserInfo(newAccessToken);
            }
          } else {
            logout();
          }
        } else {
          await getUserInfo();
        }
      }
      setLoading(false);
    };

    initAuth();
  }, []);

  // リクエストインターセプター(トークンの自動更新)
  authAxios.interceptors.request.use(
    async (config) => {
      if (accessToken && isTokenExpired(accessToken)) {
        const newToken = await refreshAccessToken();
        if (newToken) {
          config.headers.Authorization = `Bearer ${newToken}`;
        }
      }
      return config;
    },
    (error) => Promise.reject(error)
  );

  return (
    <AuthContext.Provider
      value={{
        currentUser,
        accessToken,
        refreshToken,
        loading,
        login,
        logout,
        register,
        getUserInfo,
        authAxios,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export default AuthContext;

3.3. ルーティングの設定

frontend/src/App.jsを更新し、認証ルートを設定します:

import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import Login from './components/Login';
import Register from './components/Register';
import TaskList from './components/TaskList';
import PrivateRoute from './components/PrivateRoute';
import './App.css';

function App() {
  return (
    <AuthProvider>
      <Router>
        <div className="App">
          <header className="App-header">
            <h1>Django + React タスク管理アプリ</h1>
          </header>
          <main>
            <Routes>
              <Route path="/login" element={<Login />} />
              <Route path="/register" element={<Register />} />
              <Route
                path="/tasks"
                element={
                  <PrivateRoute>
                    <TaskList />
                  </PrivateRoute>
                }
              />
              <Route path="/" element={<Navigate to="/tasks" replace />} />
            </Routes>
          </main>
        </div>
      </Router>
    </AuthProvider>
  );
}

export default App;

3.4. PrivateRouteコンポーネント

// frontend/src/components/PrivateRoute.js
import React, { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import AuthContext from '../contexts/AuthContext';

const PrivateRoute = ({ children }) => {
  const { currentUser, loading } = useContext(AuthContext);

  if (loading) {
    return <div>Loading...</div>;
  }

  if (!currentUser) {
    return <Navigate to="/login" replace />;
  }

  return children;
};

export default PrivateRoute;

3.5. ログインコンポーネント

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

const Login = () => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login, currentUser } = useContext(AuthContext);
  const navigate = useNavigate();

  // ユーザーがすでにログインしている場合はタスク一覧にリダイレクト
  React.useEffect(() => {
    if (currentUser) {
      navigate('/tasks');
    }
  }, [currentUser, navigate]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    
    if (!username || !password) {
      setError('ユーザー名とパスワードを入力してください');
      return;
    }
    
    const success = await login(username, password);
    
    if (success) {
      navigate('/tasks');
    } else {
      setError('ログインに失敗しました。ユーザー名またはパスワードが間違っています。');
    }
  };

  return (
    <div className="login-container">
      <h2>ログイン</h2>
      {error && <div className="error-message">{error}</div>}
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>ユーザー名:</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
          />
        </div>
        <div className="form-group">
          <label>パスワード:</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>
        <button type="submit" className="login-button">ログイン</button>
      </form>
      <p>
        アカウントをお持ちでない場合は、<Link to="/register">登録</Link>してください。
      </p>
    </div>
  );
};

export default Login;

3.6. 登録コンポーネント

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

const Register = () => {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    firstName: '',
    lastName: '',
  });
  const [error, setError] = useState('');
  const { register, login } = useContext(AuthContext);
  const navigate = useNavigate();

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

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    
    // バリデーション
    if (formData.password !== formData.confirmPassword) {
      setError('パスワードが一致しません');
      return;
    }
    
    if (formData.password.length < 8) {
      setError('パスワードは8文字以上にしてください');
      return;
    }
    
    // 登録処理
    const userData = {
      username: formData.username,
      email: formData.email,
      password: formData.password,
      first_name: formData.firstName,
      last_name: formData.lastName,
    };
    
    const success = await register(userData);
    
    if (success) {
      // 登録成功後、自動ログイン
      const loginSuccess = await login(formData.username, formData.password);
      if (loginSuccess) {
        navigate('/tasks');
      } else {
        navigate('/login');
      }
    } else {
      setError('登録に失敗しました。ユーザー名またはメールアドレスがすでに使用されている可能性があります。');
    }
  };

  return (
    <div className="register-container">
      <h2>アカウント登録</h2>
      {error && <div className="error-message">{error}</div>}
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>ユーザー名:</label>
          <input
            type="text"
            name="username"
            value={formData.username}
            onChange={handleChange}
            required
          />
        </div>
        <div className="form-group">
          <label>メールアドレス:</label>
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            required
          />
        </div>
        <div className="form-group">
          <label>名:</label>
          <input
            type="text"
            name="firstName"
            value={formData.firstName}
            onChange={handleChange}
          />
        </div>
        <div className="form-group">
          <label>姓:</label>
          <input
            type="text"
            name="lastName"
            value={formData.lastName}
            onChange={handleChange}
          />
        </div>
        <div className="form-group">
          <label>パスワード:</label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            required
          />
        </div>
        <div className="form-group">
          <label>パスワード(確認):</label>
          <input
            type="password"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleChange}
            required
          />
        </div>
        <button type="submit" className="register-button">登録</button>
      </form>
      <p>
        すでにアカウントをお持ちの場合は、<Link to="/login">ログイン</Link>してください。
      </p>
    </div>
  );
};

export default Register;

3.7. タスクリストコンポーネントの更新

認証に対応したタスクリストコンポーネントを更新します:

// frontend/src/components/TaskList.js
import React, { useState, useEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import AuthContext from '../contexts/AuthContext';

function TaskList() {
  const [tasks, setTasks] = useState([]);
  const [newTask, setNewTask] = useState({ title: '', description: '' });
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const { authAxios, logout, currentUser } = useContext(AuthContext);
  const navigate = useNavigate();

  // タスク一覧を取得
  useEffect(() => {
    const fetchTasks = async () => {
      try {
        setLoading(true);
        const response = await authAxios.get('/tasks/');
        setTasks(response.data);
        setError(null);
      } catch (err) {
        console.error('Error fetching tasks:', err);
        if (err.response && err.response.status === 401) {
          logout();
          navigate('/login');
        } else {
          setError('タスクの取得に失敗しました');
        }
      } finally {
        setLoading(false);
      }
    };

    fetchTasks();
  }, [authAxios, logout, navigate]);

  // 新しいタスクを追加
  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      const response = await authAxios.post('/tasks/', newTask);
      setTasks([...tasks, response.data]);
      setNewTask({ title: '', description: '' });
    } catch (err) {
      console.error('Error adding task:', err);
      if (err.response && err.response.status === 401) {
        logout();
        navigate('/login');
      } else {
        setError('タスクの追加に失敗しました');
      }
    }
  };

  // タスク完了状態の切り替え
  const toggleComplete = async (id, completed) => {
    try {
      const response = await authAxios.patch(`/tasks/${id}/`, {
        completed: !completed
      });
      setTasks(tasks.map(task => 
        task.id === id ? response.data : task
      ));
    } catch (err) {
      console.error('Error updating task:', err);
      if (err.response && err.response.status === 401) {
        logout();
        navigate('/login');
      } else {
        setError('タスクの更新に失敗しました');
      }
    }
  };

  // タスクの削除
  const deleteTask = async (id) => {
    try {
      await authAxios.delete(`/tasks/${id}/`);
      setTasks(tasks.filter(task => task.id !== id));
    } catch (err) {
      console.error('Error deleting task:', err);
      if (err.response && err.response.status === 401) {
        logout();
        navigate('/login');
      } else {
        setError('タスクの削除に失敗しました');
      }
    }
  };

  // ログアウト処理
  const handleLogout = () => {
    logout();
    navigate('/login');
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div className="task-list">
      <div className="user-info">
        <p>こんにちは、{currentUser?.username}さん</p>
        <button onClick={handleLogout} className="logout-button">ログアウト</button>
      </div>
      
      <h2>タスク一覧</h2>
      {error && <div className="error-message">{error}</div>}
      
      <form onSubmit={handleSubmit} className="task-form">
        <div className="form-group">
          <label>タスク名:</label>
          <input
            type="text"
            value={newTask.title}
            onChange={(e) => setNewTask({...newTask, title: e.target.value})}
            required
          />
        </div>
        <div className="form-group">
          <label>詳細:</label>
          <textarea
            value={newTask.description}
            onChange={(e) => setNewTask({...newTask, description: e.target.value})}
          />
        </div>
        <button type="submit" className="add-task-button">追加</button>
      </form>

      <ul className="task-items">
        {tasks.length === 0 ? (
          <li className="no-tasks">タスクがありません</li>
        ) : (
          tasks.map(task => (
            <li key={task.id} className={`task-item ${task.completed ? 'completed' : ''}`}>
              <div className="task-header">
                <input
                  type="checkbox"
                  checked={task.completed}
                  onChange={() => toggleComplete(task.id, task.completed)}
                />
                <span className="task-title">{task.title}</span>
                <button onClick={() => deleteTask(task.id)} className="delete-button">削除</button>
              </div>
              {task.description && <p className="task-description">{task.description}</p>}
            </li>
          ))
        )}
      </ul>
    </div>
  );
}

export default TaskList;

3.8. スタイルの追加

frontend/src/App.cssにスタイルを追加します:

/* 共通スタイル */
.form-group {
  margin-bottom: 15px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

.form-group input,
.form-group textarea {
  width: 100%;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.error-message {
  color: #d32f2f;
  background-color: #ffebee;
  padding: 10px;
  border-radius: 4px;
  margin-bottom: 15px;
}

/* ログイン・登録フォーム */
.login-container,
.register-container {
  max-width: 400px;
  margin: 0 auto;
  padding: 20px;
  background-color: #fff;
  border-radius: 5px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.login-button,
.register-button {
  width: 100%;
  padding: 10px;
  background-color: #4caf50;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

.login-button:hover,
.register-button:hover {
  background-color: #45a049;
}

/* タスクリスト */
.task-list {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.user-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.logout-button {
  padding: 5px 10px;
  background-color: #f44336;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task-form {
  margin-bottom: 20px;
  padding: 15px;
  background-color: #f5f5f5;
  border-radius: 5px;
}

.add-task-button {
  padding: 8px 16px;
  background-color: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task-items {
  list-style: none;
  padding: 0;
}

.task-item {
  margin-bottom: 10px;
  padding: 15px;
  background-color: #fff;
  border-radius: 5px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.task-item.completed .task-title {
  text-decoration: line-through

コメント

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