
React演習問題(JSX・コンポーネント・Props・State)
初級問題(9問) JSXの書き方 <div class="container"> <h1 id="title&q […]
前回までで、ReactフロントエンドとExpressバックエンドの連携が完成しました。しかし、現在のアプリケーションには重大なセキュリティ上の問題があります:誰でもAPIにアクセスでき、すべてのユーザーのデータが見えてしまうということです。
今回は、JWT(JSON Web Token)認証を実装して、ユーザーごとにデータを分離し、セキュアなログイン機能を追加します。これにより、各ユーザーが自分のTODOデータのみを操作できるようになります。
JWTはJSONオブジェクトをエンコードしたトークンで、以下の3つの部分から構成されます:
[図解: JWT認証フロー]
1. ユーザーがログイン情報を送信
2. サーバーが認証しJWTを発行
3. クライアントがJWTを保存(ローカルストレージなど)
4. クライアントがAPIリクエスト時にJWTをヘッダーに付加
5. サーバーがJWTを検証して権限を確認
npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs
.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"
前回定義したUserモデルを活用し、パスワードハッシュ化のためのメソッドを追加します。
// prisma/schema.prisma(既存のUserモデルにメソッドを追加するための準備)
// 注: Prismaスキーマではメソッドを直接定義できないため、別ファイルで実装します
// 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
};
// 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
};
// 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
};
// 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;
// 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でフィルタリングするように修正
// ...
// 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}`);
});
// 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;
}
};
// 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;
// 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}
);
};
// 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認証システムの要点:
これらの実装により、本格的なユーザー認証システムが完成し、アプリケーションのセキュリティと信頼性が大幅に向上しました。