JWT認証の実装:セキュアなAPI認証システム
JWT(JSON Web Token)は、分散システムで認証情報を安全に伝達するための標準的な方法です。この記事では、JWTの仕組みを理解し、Node.jsとExpressを使って実際のAPI認証システムを構築します。
トークンベース認証の実装を通じて、セキュアな認証システムの設計と実装方法を学びます。
1. JWTとは
JWTは、JSONオブジェクトをエンコードしたトークンで、以下の3つの部分で構成されます。
- Header(ヘッダ): トークンのタイプと署名アルゴリズム
- Payload(ペイロード): クレーム(ユーザー情報など)
- Signature(署名): トークンの検証に使用
1.1 JWTの構造
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
各パートはBase64URLエンコードされています。
1.2 JWTのメリット
- ステートレス: サーバー側でセッションを保存する必要がない
- スケーラブル: 分散システムに適している
- クロスドメイン対応: CORS問題を回避
- モバイル対応: ネイティブアプリでも使用可能
1.3 環境の準備
# プロジェクトの初期化
mkdir jwt-auth-api
cd jwt-auth-api
npm init -y
# 必要なパッケージのインストール
npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
# TypeScriptを使用する場合
npm install --save-dev typescript @types/express @types/jsonwebtoken @types/bcryptjs
2. JWTの基本的な実装
2.1 トークンの生成
const jwt = require('jsonwebtoken');
require('dotenv').config();
// シークレットキー(環境変数から取得)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// トークンの生成
function generateToken(user) {
const payload = {
id: user.id,
email: user.email,
role: user.role
};
const token = jwt.sign(
payload,
JWT_SECRET,
{
expiresIn: '1h', // 有効期限: 1時間
issuer: 'your-app-name', // 発行者
audience: 'your-app-users' // 受信者
}
);
return token;
}
// リフレッシュトークンの生成
function generateRefreshToken(user) {
const payload = {
id: user.id,
type: 'refresh'
};
return jwt.sign(payload, JWT_SECRET, {
expiresIn: '7d' // リフレッシュトークンは7日間有効
});
}
2.2 トークンの検証
// トークンの検証
function verifyToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'your-app-name',
audience: 'your-app-users'
});
return decoded;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
} else if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token');
} else {
throw new Error('Token verification failed');
}
}
}
// トークンのデコード(検証なし)
function decodeToken(token) {
return jwt.decode(token);
}
3. Expressでの実装
3.1 基本的な認証ミドルウェア
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// 認証ミドルウェア
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
};
// ロールベースの認証ミドルウェア
const authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
3.2 ユーザー登録とログイン
// 簡易的なユーザーストレージ(実際にはデータベースを使用)
const users = [];
const refreshTokens = new Set();
// ユーザー登録
app.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
// バリデーション
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// 既存ユーザーのチェック
const existingUser = users.find(u => u.email === email);
if (existingUser) {
return res.status(400).json({ error: 'User already exists' });
}
// パスワードのハッシュ化
const hashedPassword = await bcrypt.hash(password, 10);
// ユーザーの作成
const user = {
id: users.length + 1,
email,
password: hashedPassword,
name,
role: 'user'
};
users.push(user);
// トークンの生成
const accessToken = generateToken(user);
const refreshToken = generateRefreshToken(user);
refreshTokens.add(refreshToken);
res.status(201).json({
message: 'User registered successfully',
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ログイン
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// バリデーション
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// ユーザーの検索
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// パスワードの検証
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// トークンの生成
const accessToken = generateToken(user);
const refreshToken = generateRefreshToken(user);
refreshTokens.add(refreshToken);
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
3.3 保護されたルート
// 保護されたエンドポイント
app.get('/profile', authenticateToken, (req, res) => {
res.json({
message: 'Profile data',
user: req.user
});
});
// 管理者専用エンドポイント
app.get('/admin', authenticateToken, authorize('admin'), (req, res) => {
res.json({
message: 'Admin data',
user: req.user
});
});
// ユーザー一覧(認証が必要)
app.get('/users', authenticateToken, (req, res) => {
const userList = users.map(u => ({
id: u.id,
email: u.email,
name: u.name
}));
res.json(userList);
});
3.4 リフレッシュトークン
// トークンのリフレッシュ
app.post('/refresh', (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
// リフレッシュトークンの検証
if (!refreshTokens.has(refreshToken)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
const decoded = jwt.verify(refreshToken, JWT_SECRET);
// ユーザーの検索
const user = users.find(u => u.id === decoded.id);
if (!user) {
return res.status(403).json({ error: 'User not found' });
}
// 新しいアクセストークンの生成
const newAccessToken = generateToken(user);
res.json({
accessToken: newAccessToken
});
} catch (error) {
res.status(403).json({ error: 'Invalid refresh token' });
}
});
// ログアウト
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
if (refreshToken) {
refreshTokens.delete(refreshToken);
}
res.json({ message: 'Logged out successfully' });
});
4. セキュリティのベストプラクティス
4.1 環境変数の管理
// .envファイル
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
NODE_ENV=production
// config.js
require('dotenv').config();
module.exports = {
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d'
},
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development'
};
4.2 パスワードポリシー
// パスワードの強度チェック
function validatePassword(password) {
const minLength = 8;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (password.length < minLength) {
return { valid: false, error: 'Password must be at least 8 characters long' };
}
if (!hasUpperCase) {
return { valid: false, error: 'Password must contain at least one uppercase letter' };
}
if (!hasLowerCase) {
return { valid: false, error: 'Password must contain at least one lowercase letter' };
}
if (!hasNumbers) {
return { valid: false, error: 'Password must contain at least one number' };
}
if (!hasSpecialChar) {
return { valid: false, error: 'Password must contain at least one special character' };
}
return { valid: true };
}
4.3 レート制限
const rateLimit = require('express-rate-limit');
// ログイン試行のレート制限
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分
max: 5, // 最大5回
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/login', loginLimiter, async (req, res) => {
// ログイン処理
});
4.4 CORS設定
const cors = require('cors');
const corsOptions = {
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
5. データベース統合
5.1 MongoDBとの統合
const mongoose = require('mongoose');
// ユーザースキーマ
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: true,
minlength: 8
},
name: {
type: String,
required: true
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
refreshTokens: [{
type: String
}]
}, {
timestamps: true
});
// パスワードのハッシュ化(保存前)
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// パスワード比較メソッド
userSchema.methods.comparePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
const User = mongoose.model('User', userSchema);
// ログインの実装
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValidPassword = await user.comparePassword(password);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = generateToken(user);
const refreshToken = generateRefreshToken(user);
// リフレッシュトークンを保存
user.refreshTokens.push(refreshToken);
await user.save();
res.json({
accessToken,
refreshToken,
user: {
id: user._id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
6. フロントエンドでの実装
6.1 トークンの保存と使用
// tokenService.js
class TokenService {
// トークンの保存
static saveTokens(accessToken, refreshToken) {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
}
// トークンの取得
static getAccessToken() {
return localStorage.getItem('accessToken');
}
static getRefreshToken() {
return localStorage.getItem('refreshToken');
}
// トークンの削除
static removeTokens() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
// APIリクエストのインターセプター
static async apiRequest(url, options = {}) {
const token = this.getAccessToken();
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
...options,
headers
});
// トークンの有効期限切れの場合
if (response.status === 401) {
const newToken = await this.refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
return fetch(url, { ...options, headers });
} else {
// リフレッシュに失敗した場合はログアウト
this.removeTokens();
window.location.href = '/login';
throw new Error('Authentication failed');
}
}
return response;
} catch (error) {
throw error;
}
}
// アクセストークンのリフレッシュ
static async refreshAccessToken() {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await fetch('/api/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
} else {
return null;
}
} catch (error) {
return null;
}
}
}
// 使用例
async function fetchUserProfile() {
try {
const response = await TokenService.apiRequest('/api/profile');
const user = await response.json();
console.log(user);
} catch (error) {
console.error('Failed to fetch profile:', error);
}
}
7. まとめと次のステップ
この記事を通じて、JWT認証システムの実装方法を学びました。
学んだこと
- JWTの構造: Header、Payload、Signature
- トークンの生成と検証: jsonwebtokenライブラリの使用
- 認証ミドルウェア: Expressでの実装
- リフレッシュトークン: アクセストークンの更新
- セキュリティ対策: パスワードハッシュ化、レート制限、CORS
- データベース統合: MongoDBとの統合
- フロントエンド実装: トークンの管理とAPIリクエスト
セキュリティのベストプラクティス
- 強力なシークレットキー: 環境変数で管理
- パスワードのハッシュ化: bcryptを使用
- トークンの有効期限: 適切な有効期限の設定
- HTTPSの使用: 本番環境では必須
- レート制限: ブルートフォース攻撃の防止
次のステップ
- OAuth2統合: Google、GitHubなどのOAuth認証
- 2要素認証(2FA): セキュリティの強化
- トークンブラックリスト: ログアウト時のトークン無効化
- 監査ログ: 認証イベントの記録
- パスワードリセット: メールベースのパスワードリセット
JWT認証は、モダンなWebアプリケーションで広く使用されている認証方式です。セキュリティを考慮しながら、実践的な認証システムを構築できるようになりましょう!
Happy Coding!
コメント
コメントを読み込み中...
