0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hybrid License System Day 12: ライセンスアクティベーション実装

Last updated at Posted at 2025-12-11

🎄 科学と神々株式会社 アドベントカレンダー 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.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?