🎄 科学と神々株式会社 アドベントカレンダー 2025
Hybrid License System Day 13: JWT生成と検証
Auth Service編 (3/5)
📖 はじめに
Day 13では、JWT生成と検証を学びます。JWTペイロード設計、署名アルゴリズム(HS256)、有効期限管理、セキュアなトークン設計を実装しましょう。
🔑 JWTの基礎知識
JWTとは?
**JWT(JSON Web Token)**は、JSON形式の情報を安全に送信するためのトークン規格です。
JWT構造
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← Header (Base64URL)
eyJ1c2VySWQiOiJ1c2VyLTEyMyJ9. ← Payload (Base64URL)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature (HMAC-SHA256)
3つの構成要素
1. Header(ヘッダー)
{
"alg": "HS256", // 署名アルゴリズム
"typ": "JWT" // トークンタイプ
}
2. Payload(ペイロード)
{
"userId": "user-12345",
"email": "user@example.com",
"plan": "professional",
"clientId": "device-abc-123",
"iat": 1734556800, // 発行時刻 (Issued At)
"exp": 1766092800 // 有効期限 (Expiration Time)
}
3. Signature(署名)
HMAC-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
🛠️ JWT生成の実装
CryptoService クラス
// auth-service/src/cryptoService.js
const jwt = require('jsonwebtoken');
class CryptoService {
constructor() {
this.jwtSecret = process.env.JWT_SECRET;
if (!this.jwtSecret) {
throw new Error('JWT_SECRET environment variable is not set');
}
// シークレットキーの長さチェック(最低32文字推奨)
if (this.jwtSecret.length < 32) {
console.warn('WARNING: JWT_SECRET should be at least 32 characters for security');
}
}
/**
* JWT生成
* @param {Object} payload - { userId, email, plan, clientId }
* @param {Object} options - { expiresIn } (optional)
* @returns {string} - JWT token
*/
generateJWT(payload, options = {}) {
const { userId, email, plan, clientId } = payload;
// 必須フィールドチェック
if (!userId || !email || !plan || !clientId) {
throw new Error('Missing required JWT payload fields');
}
// JWTペイロード
const jwtPayload = {
userId,
email,
plan,
clientId,
iat: Math.floor(Date.now() / 1000), // 発行時刻
// 有効期限(デフォルト1年、オプションで変更可能)
exp: Math.floor(Date.now() / 1000) + (options.expiresIn || 365 * 24 * 60 * 60)
};
// JWT署名(HS256)
const token = jwt.sign(jwtPayload, this.jwtSecret, {
algorithm: 'HS256'
});
return token;
}
/**
* JWT検証
* @param {string} token - JWT token
* @returns {Object} - Decoded payload
*/
verifyJWT(token) {
if (!token) {
throw new Error('Token is required');
}
try {
// JWT検証と復号化
const decoded = jwt.verify(token, this.jwtSecret, {
algorithms: ['HS256'] // HS256のみ許可
});
return decoded;
} catch (error) {
// エラーハンドリング
if (error.name === 'TokenExpiredError') {
throw new Error('Token has expired');
}
if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token signature');
}
if (error.name === 'NotBeforeError') {
throw new Error('Token not active yet');
}
throw new Error('Token verification failed');
}
}
/**
* JWTデコード(検証なし)
* @param {string} token - JWT token
* @returns {Object} - Decoded payload (未検証)
*/
decodeJWT(token) {
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded) {
throw new Error('Invalid token format');
}
return {
header: decoded.header,
payload: decoded.payload,
signature: decoded.signature
};
} catch (error) {
throw new Error('Failed to decode token');
}
}
}
module.exports = CryptoService;
🔒 セキュアなJWTペイロード設計
推奨されるペイロード
const recommendedPayload = {
// 標準クレーム(RFC 7519)
iat: 1734556800, // Issued At(発行時刻)
exp: 1766092800, // Expiration Time(有効期限)
iss: 'auth-service', // Issuer(発行者)
sub: 'user-12345', // Subject(主体)
// カスタムクレーム
userId: 'user-12345',
email: 'user@example.com',
plan: 'professional',
clientId: 'device-abc-123',
// 権限・ロール
roles: ['user', 'license-holder']
};
避けるべきペイロード
const badPayload = {
// ❌ センシティブ情報を含めない
password: 'secret123', // パスワードは絶対NG
passwordHash: 'bcrypt$10$...', // ハッシュもNG
ssn: '123-45-6789', // 個人情報NG
creditCard: '4111-1111-1111-1111', // クレジットカード情報NG
// ❌ 過度に大きいペイロード
fullUserProfile: { /* 巨大なオブジェクト */ }, // トークンサイズが肥大化
logs: [ /* 大量のログ */ ] // 不要なデータ
};
⏰ 有効期限管理
プラン別有効期限設定
// auth-service/src/authService.js
class AuthService {
generateTokenForUser(user, clientId) {
let expiresIn;
// プラン別の有効期限設定
switch (user.plan) {
case 'free':
expiresIn = 30 * 24 * 60 * 60; // 30日
break;
case 'professional':
expiresIn = 365 * 24 * 60 * 60; // 1年
break;
case 'enterprise':
expiresIn = 3 * 365 * 24 * 60 * 60; // 3年
break;
default:
expiresIn = 30 * 24 * 60 * 60; // デフォルト30日
}
// JWT生成
const token = this.crypto.generateJWT(
{
userId: user.user_id,
email: user.email,
plan: user.plan,
clientId
},
{ expiresIn }
);
return {
token,
expiresIn,
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString()
};
}
}
トークンリフレッシュ
// auth-service/src/standalone.js
app.post('/refresh', async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
error: 'Bad Request',
message: 'Token is required'
});
}
// 既存トークンの検証(有効期限切れでもペイロードは取得)
const decoded = cryptoService.decodeJWT(token);
const { userId, clientId } = decoded.payload;
// ユーザー情報を再取得
const user = dbService.getUserById(userId);
if (!user) {
return res.status(404).json({
error: 'Not Found',
message: 'User not found'
});
}
// 新しいトークンを生成
const newToken = cryptoService.generateJWT({
userId: user.user_id,
email: user.email,
plan: user.plan,
clientId
});
res.json({
success: true,
token: newToken
});
} catch (error) {
console.error('Token refresh error:', error);
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to refresh token'
});
}
});
🧪 JWT検証テスト
有効なトークンのテスト
// auth-service/tests/jwt.test.js
const CryptoService = require('../src/cryptoService');
describe('JWT Generation and Verification', () => {
let cryptoService;
beforeAll(() => {
process.env.JWT_SECRET = 'test-secret-key-for-development-minimum-32-characters-long';
cryptoService = new CryptoService();
});
it('should generate a valid JWT token', () => {
const payload = {
userId: 'user-123',
email: 'test@example.com',
plan: 'professional',
clientId: 'client-abc'
};
const token = cryptoService.generateJWT(payload);
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3); // Header.Payload.Signature
});
it('should verify a valid JWT token', () => {
const payload = {
userId: 'user-123',
email: 'test@example.com',
plan: 'professional',
clientId: 'client-abc'
};
const token = cryptoService.generateJWT(payload);
const decoded = cryptoService.verifyJWT(token);
expect(decoded.userId).toBe(payload.userId);
expect(decoded.email).toBe(payload.email);
expect(decoded.plan).toBe(payload.plan);
expect(decoded.clientId).toBe(payload.clientId);
});
it('should reject expired tokens', async () => {
const payload = {
userId: 'user-123',
email: 'test@example.com',
plan: 'professional',
clientId: 'client-abc'
};
// 有効期限1秒のトークンを生成
const token = cryptoService.generateJWT(payload, { expiresIn: 1 });
// 2秒待機
await new Promise(resolve => setTimeout(resolve, 2000));
// 期限切れトークンを検証
expect(() => {
cryptoService.verifyJWT(token);
}).toThrow('Token has expired');
});
it('should reject tokens with invalid signature', () => {
const payload = {
userId: 'user-123',
email: 'test@example.com',
plan: 'professional',
clientId: 'client-abc'
};
const token = cryptoService.generateJWT(payload);
// 署名を改ざん
const parts = token.split('.');
const tamperedToken = `${parts[0]}.${parts[1]}.invalidsignature`;
expect(() => {
cryptoService.verifyJWT(tamperedToken);
}).toThrow('Invalid token signature');
});
it('should reject tokens with missing payload fields', () => {
const invalidPayload = {
userId: 'user-123'
// email, plan, clientId が欠けている
};
expect(() => {
cryptoService.generateJWT(invalidPayload);
}).toThrow('Missing required JWT payload fields');
});
it('should decode token without verification', () => {
const payload = {
userId: 'user-123',
email: 'test@example.com',
plan: 'professional',
clientId: 'client-abc'
};
const token = cryptoService.generateJWT(payload);
const decoded = cryptoService.decodeJWT(token);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.payload.userId).toBe(payload.userId);
});
});
🔐 セキュリティベストプラクティス
1. シークレットキーの管理
# ❌ 避けるべき例
JWT_SECRET="secret" # 短すぎる
JWT_SECRET="password123" # 推測可能
# ✅ 推奨例
JWT_SECRET="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6" # 32文字以上
# または
openssl rand -base64 32 # ランダム生成
2. アルゴリズムの固定
// HS256のみ許可(アルゴリズム混同攻撃を防ぐ)
jwt.verify(token, secret, {
algorithms: ['HS256'] // 明示的に指定
});
3. トークンの保管(クライアント側)
// ❌ ローカルストレージ(XSS脆弱性)
localStorage.setItem('token', jwt);
// ✅ HTTPOnly Cookie(推奨)
res.cookie('token', jwt, {
httpOnly: true, // JavaScriptからアクセス不可
secure: true, // HTTPS必須
sameSite: 'strict', // CSRF対策
maxAge: 365 * 24 * 60 * 60 * 1000 // 1年
});
🎯 次のステップ
Day 14では、セキュリティ強化策について学びます。BCrypt強化、レート制限、監査ログ、SQLインジェクション対策を実装しましょう。
🔗 関連リンク
次回予告: Day 14では、BCrypt強化とセキュリティ監査ログの実装を詳しく解説します!
Copyright © 2025 Gods & Golem, Inc. All rights reserved.