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 13: JWT生成と検証

Last updated at Posted at 2025-12-12

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

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?