はじめに
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
コメント