🎄 科学と神々株式会社 アドベントカレンダー 2025
Hybrid License System Day 12: ライセンスアクティベーション実装
Auth Service編 (2/5)
📖 はじめに
Day 12では、ライセンスアクティベーション実装を学びます。ユーザー認証、ライセンス発行、データベーストランザクション、エラーハンドリングを実装しましょう。
🔐 アクティベーションフロー
全体フロー
1. クライアント → API Gateway
POST /api/v1/license/activate
{ email, password, client_id }
2. API Gateway → Auth Service
POST /api/v1/license/activate
{ email, password, client_id }
+ X-Service-Secret header
3. Auth Service
├─ ユーザー認証(BCrypt)
├─ ライセンス存在確認
├─ 新規ライセンス作成(トランザクション)
├─ JWT生成(HS256)
└─ レスポンス返却
4. Auth Service → API Gateway
{ token, license, user }
5. API Gateway → クライアント
{ token, license, user }
🏗️ Auth Service実装(統合版)
standalone.js - 統合実装
// auth-service/src/standalone.js
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const Database = require('better-sqlite3');
const app = express();
app.use(express.json());
// ===== データベース初期化 =====
const db = new Database(process.env.DB_PATH || './data/licenses.db');
// WALモード(並行アクセス性能向上)
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('cache_size = 10000');
// テーブル作成
db.exec(`
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
plan TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS licenses (
license_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
CREATE INDEX IF NOT EXISTS idx_licenses_client_id ON licenses(client_id);
CREATE INDEX IF NOT EXISTS idx_licenses_user_id ON licenses(user_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
`);
// ===== ヘルパー関数 =====
/**
* 入力検証
*/
function validateActivationInput(email, password, clientId) {
if (!email || !password || !clientId) {
throw new Error('Missing required fields: email, password, client_id');
}
// メールアドレス形式チェック
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error('Invalid email format');
}
// パスワード長チェック
if (password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
// クライアントID形式チェック
if (clientId.length < 10) {
throw new Error('Client ID must be at least 10 characters');
}
}
/**
* ユーザー認証
*/
async function authenticateUser(email, password) {
// ユーザー情報取得
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
throw new Error('Invalid credentials');
}
// パスワード検証(BCrypt)
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
throw new Error('Invalid credentials');
}
return user;
}
/**
* ライセンス作成(トランザクション)
*/
function createLicense(userId, clientId, plan) {
const transaction = db.transaction((userId, clientId, plan) => {
// 1. ユーザー存在確認
const user = db.prepare('SELECT * FROM users WHERE user_id = ?').get(userId);
if (!user) {
throw new Error('User not found');
}
// 2. 既存ライセンスチェック
const existingLicense = db.prepare(
'SELECT * FROM licenses WHERE client_id = ?'
).get(clientId);
if (existingLicense) {
throw new Error('License already exists for this client');
}
// 3. ライセンスID生成
const licenseId = `lic-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const now = new Date().toISOString();
// 有効期限(プラン別)
let expiresAt;
switch (plan) {
case 'free':
expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30日
break;
case 'professional':
expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); // 1年
break;
case 'enterprise':
expiresAt = new Date(Date.now() + 3 * 365 * 24 * 60 * 60 * 1000).toISOString(); // 3年
break;
default:
expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
}
// 4. ライセンス作成
db.prepare(`
INSERT INTO licenses (license_id, user_id, client_id, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(licenseId, userId, clientId, 'active', now, expiresAt);
// 5. 作成したライセンスを返す
return db.prepare('SELECT * FROM licenses WHERE license_id = ?').get(licenseId);
});
// トランザクション実行
return transaction(userId, clientId, plan);
}
/**
* JWT生成
*/
function generateJWT(payload) {
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error('JWT_SECRET is not set');
}
const { userId, email, plan, clientId } = payload;
// JWTペイロード
const jwtPayload = {
userId,
email,
plan,
clientId,
iat: Math.floor(Date.now() / 1000), // 発行時刻
exp: Math.floor(Date.now() / 1000) + (365 * 24 * 60 * 60) // 有効期限(1年)
};
// JWT署名(HS256)
const token = jwt.sign(jwtPayload, jwtSecret, {
algorithm: 'HS256'
});
return token;
}
/**
* ライセンスアクティベーション
*/
async function activateLicense(email, password, clientId) {
// 1. 入力検証
validateActivationInput(email, password, clientId);
// 2. ユーザー認証
const user = await authenticateUser(email, password);
// 3. 既存ライセンス確認
const existingLicense = db.prepare(
'SELECT * FROM licenses WHERE client_id = ?'
).get(clientId);
if (existingLicense) {
// 既に同じclient_idでライセンスが存在する場合
if (existingLicense.user_id === user.user_id) {
// 同じユーザーの再アクティベーション
const token = generateJWT({
userId: user.user_id,
email: user.email,
plan: user.plan,
clientId
});
return {
token,
license: existingLicense,
user: {
userId: user.user_id,
email: user.email,
plan: user.plan
}
};
} else {
// 別のユーザーが既に使用中
throw new Error('Client ID already in use by another user');
}
}
// 4. 新規ライセンス作成(トランザクション)
const license = createLicense(user.user_id, clientId, user.plan);
// 5. JWT生成
const token = generateJWT({
userId: user.user_id,
email: user.email,
plan: user.plan,
clientId: license.client_id
});
// 6. レスポンス返却
return {
token,
license: {
licenseId: license.license_id,
userId: license.user_id,
clientId: license.client_id,
status: license.status,
createdAt: license.created_at,
expiresAt: license.expires_at
},
user: {
userId: user.user_id,
email: user.email,
plan: user.plan
}
};
}
// ===== エンドポイント定義 =====
/**
* ヘルスチェック
*/
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', service: 'auth-service' });
});
/**
* アクティベーションエンドポイント
*/
app.post('/api/v1/license/activate', async (req, res) => {
try {
const { email, password, client_id } = req.body;
// アクティベーション実行
const result = await activateLicense(email, password, client_id);
res.status(200).json({
success: true,
data: result
});
} catch (error) {
console.error('Activation error:', error.message);
// エラーレスポンス
if (error.message === 'Invalid credentials') {
return res.status(401).json({
success: false,
error: 'Unauthorized',
message: error.message
});
}
if (error.message.includes('Client ID already in use')) {
return res.status(409).json({
success: false,
error: 'Conflict',
message: error.message
});
}
if (error.message.includes('Missing required fields') ||
error.message.includes('Invalid email') ||
error.message.includes('Password must be')) {
return res.status(400).json({
success: false,
error: 'Bad Request',
message: error.message
});
}
// その他のエラー
res.status(500).json({
success: false,
error: 'Internal Server Error',
message: 'An error occurred during activation'
});
}
});
/**
* ライセンス検証エンドポイント
*/
app.get('/api/v1/license/validate', (req, res) => {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
error: 'No token provided'
});
}
const jwtSecret = process.env.JWT_SECRET;
const decoded = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] });
res.status(200).json({
success: true,
data: decoded
});
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: 'Token expired'
});
}
res.status(401).json({
success: false,
error: 'Invalid token'
});
}
});
// ===== サーバー起動 =====
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Auth Service running on port ${PORT}`);
});
module.exports = app;
🧪 テストケース
アクティベーションテスト
// auth-service/tests/activation.test.js
const request = require('supertest');
const app = require('../src/standalone');
describe('License Activation', () => {
it('should activate a new license successfully', async () => {
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com',
password: 'password123',
client_id: 'test-client-001'
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('token');
expect(response.body.data).toHaveProperty('license');
expect(response.body.data.license.status).toBe('active');
});
it('should reject activation with invalid credentials', async () => {
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com',
password: 'wrongpassword',
client_id: 'test-client-002'
});
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Unauthorized');
});
it('should reject activation with duplicate client_id from different user', async () => {
// 1回目のアクティベーション
await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com',
password: 'password123',
client_id: 'duplicate-client'
});
// 2回目(別ユーザーで同じclient_id)
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test2@example.com',
password: 'password456',
client_id: 'duplicate-client'
});
expect(response.status).toBe(409);
expect(response.body.error).toBe('Conflict');
});
it('should allow re-activation with same user and client_id', async () => {
// 1回目
await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com',
password: 'password123',
client_id: 'reactivate-client'
});
// 2回目(同じユーザー、同じclient_id)
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com',
password: 'password123',
client_id: 'reactivate-client'
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('should reject activation with missing fields', async () => {
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com'
// password と client_id が欠けている
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Bad Request');
});
it('should reject activation with invalid email format', async () => {
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'invalid-email',
password: 'password123',
client_id: 'test-client-003'
});
expect(response.status).toBe(400);
expect(response.body.message).toContain('Invalid email format');
});
});
describe('License Validation', () => {
let validToken;
beforeAll(async () => {
// テスト用トークン取得
const response = await request(app)
.post('/api/v1/license/activate')
.send({
email: 'test@example.com',
password: 'password123',
client_id: 'validation-test-client'
});
validToken = response.body.data.token;
});
it('should validate a valid token', async () => {
const response = await request(app)
.get('/api/v1/license/validate')
.set('Authorization', `Bearer ${validToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('userId');
expect(response.body.data).toHaveProperty('email');
});
it('should reject validation without token', async () => {
const response = await request(app)
.get('/api/v1/license/validate');
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
it('should reject validation with invalid token', async () => {
const response = await request(app)
.get('/api/v1/license/validate')
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid token');
});
});
📊 パフォーマンス考慮
インデックス最適化
-- ライセンス検索の高速化
CREATE INDEX idx_licenses_client_id ON licenses(client_id);
CREATE INDEX idx_licenses_user_id ON licenses(user_id);
CREATE INDEX idx_licenses_status ON licenses(status);
-- ユーザー検索の高速化
CREATE INDEX idx_users_email ON users(email);
データベース最適化設定
// better-sqlite3の最適化設定
const Database = require('better-sqlite3');
const db = new Database('licenses.db', {
verbose: console.log,
fileMustExist: false
});
// WALモード(並行アクセス性能向上)
db.pragma('journal_mode = WAL');
// 同期モード(パフォーマンス優先)
db.pragma('synchronous = NORMAL');
// キャッシュサイズ(10MB)
db.pragma('cache_size = 10000');
// メモリマップドI/O(高速化)
db.pragma('mmap_size = 30000000000');
🔒 セキュリティ考慮
BCryptパスワードハッシュ化
const bcrypt = require('bcryptjs');
// パスワードハッシュ化(コストファクター12)
const hashedPassword = await bcrypt.hash(plainPassword, 12);
// パスワード検証
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
JWT秘密鍵管理
# .env ファイル(本番環境では環境変数を使用)
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
SERVICE_SECRET=your-service-secret-key-change-this
レート制限(Day 14で詳細解説)
// 簡易的なレート制限例
const loginAttempts = new Map();
function checkRateLimit(email) {
const attempts = loginAttempts.get(email) || 0;
if (attempts >= 5) {
throw new Error('Too many login attempts. Please try again later.');
}
loginAttempts.set(email, attempts + 1);
}
🎯 次のステップ
Day 13では、JWT生成と検証について詳しく学びます。JWTペイロード設計、有効期限管理、リフレッシュトークンの実装を理解しましょう。
🔗 関連リンク
次回予告: Day 13では、JWT生成アルゴリズムとセキュアなトークン設計を詳しく解説します!
Copyright © 2025 Gods & Golem, Inc. All rights reserved.