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 14: セキュリティ強化策

Last updated at Posted at 2025-12-13

🎄 科学と神々株式会社 アドベントカレンダー 2025

Hybrid License System Day 14: セキュリティ強化策

Auth Service編 (4/5)


📖 はじめに

Day 14では、セキュリティ強化策を学びます。パスワードハッシング、ログイン試行回数制限、サービス間認証、入力バリデーションなど、Auth Serviceのセキュリティを多層的に強化する方法を実装します。


🔐 セキュリティ強化の主要領域

1. パスワードセキュリティ

  • BCryptハッシング(cost factor 10)
  • ソルト自動生成
  • レインボーテーブル攻撃対策

2. 認証セキュリティ

  • ログイン試行回数制限
  • アカウントロックアウト
  • タイミング攻撃対策

3. 通信セキュリティ

  • サービス間認証(SERVICE_SECRET)
  • HTTPS強制
  • CORS設定

4. 入力検証

  • SQLインジェクション対策
  • XSS対策
  • 入力サニタイゼーション

🔑 パスワードハッシング実装

BCrypt Cost Factor設定

// auth-service/src/cryptoService.js
const bcrypt = require('bcrypt');

class CryptoService {
  constructor() {
    // BCrypt cost factor (推奨: 10-12)
    // cost 10 = 約0.1秒/ハッシュ
    // cost 12 = 約0.4秒/ハッシュ
    this.saltRounds = parseInt(process.env.BCRYPT_COST || '10', 10);
  }

  /**
   * パスワードハッシング
   * @param {string} password - 平文パスワード
   * @returns {Promise<string>} - ハッシュ化されたパスワード
   */
  async hashPassword(password) {
    // パスワード強度チェック
    this.validatePasswordStrength(password);

    // BCryptハッシング(ソルトは自動生成)
    const hash = await bcrypt.hash(password, this.saltRounds);
    return hash;
  }

  /**
   * パスワード検証
   * @param {string} password - 平文パスワード
   * @param {string} hash - ハッシュ
   * @returns {Promise<boolean>} - 一致するか
   */
  async verifyPassword(password, hash) {
    // タイミング攻撃対策のため、常に固定時間で応答
    const result = await bcrypt.compare(password, hash);
    return result;
  }

  /**
   * パスワード強度検証
   */
  validatePasswordStrength(password) {
    if (password.length < 8) {
      throw new Error('Password must be at least 8 characters');
    }

    // 複雑性要件
    const hasUpperCase = /[A-Z]/.test(password);
    const hasLowerCase = /[a-z]/.test(password);
    const hasNumber = /[0-9]/.test(password);
    const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);

    if (!hasUpperCase || !hasLowerCase || !hasNumber) {
      throw new Error(
        'Password must contain uppercase, lowercase, and numbers'
      );
    }

    // 弱いパスワードのブラックリスト
    const weakPasswords = [
      'password', 'Password123', '12345678', 'qwerty123',
      'admin123', 'letmein', 'welcome123'
    ];

    if (weakPasswords.includes(password)) {
      throw new Error('Password is too weak');
    }
  }
}

module.exports = CryptoService;

🚨 ログイン試行回数制限

レート制限ミドルウェア

// auth-service/src/middleware/rateLimiter.js
class LoginRateLimiter {
  constructor() {
    // メモリ内キャッシュ(本番環境ではRedis推奨)
    this.attempts = new Map();
    this.lockouts = new Map();

    // 設定
    this.maxAttempts = parseInt(process.env.MAX_LOGIN_ATTEMPTS || '5', 10);
    this.lockoutDuration = parseInt(process.env.LOCKOUT_DURATION || '900000', 10); // 15分
    this.attemptWindow = parseInt(process.env.ATTEMPT_WINDOW || '300000', 10); // 5分

    // 定期クリーンアップ
    setInterval(() => this.cleanup(), 60000); // 1分ごと
  }

  /**
   * ログイン試行をチェック
   * @param {string} identifier - ユーザー識別子(email or IP)
   * @returns {Object} - { allowed: boolean, remainingAttempts: number }
   */
  checkAttempt(identifier) {
    // ロックアウト中かチェック
    const lockoutUntil = this.lockouts.get(identifier);
    if (lockoutUntil && Date.now() < lockoutUntil) {
      const remainingTime = Math.ceil((lockoutUntil - Date.now()) / 1000 / 60);
      return {
        allowed: false,
        locked: true,
        remainingTime: `${remainingTime} minutes`
      };
    }

    // ロックアウト期限切れ
    if (lockoutUntil && Date.now() >= lockoutUntil) {
      this.lockouts.delete(identifier);
      this.attempts.delete(identifier);
    }

    // 試行回数取得
    const attemptData = this.attempts.get(identifier) || {
      count: 0,
      firstAttempt: Date.now()
    };

    // ウィンドウ外の試行はリセット
    if (Date.now() - attemptData.firstAttempt > this.attemptWindow) {
      attemptData.count = 0;
      attemptData.firstAttempt = Date.now();
    }

    const remainingAttempts = this.maxAttempts - attemptData.count;

    return {
      allowed: attemptData.count < this.maxAttempts,
      locked: false,
      remainingAttempts: Math.max(0, remainingAttempts)
    };
  }

  /**
   * 失敗した試行を記録
   */
  recordFailedAttempt(identifier) {
    const attemptData = this.attempts.get(identifier) || {
      count: 0,
      firstAttempt: Date.now()
    };

    attemptData.count++;
    this.attempts.set(identifier, attemptData);

    // 上限到達時はロックアウト
    if (attemptData.count >= this.maxAttempts) {
      const lockoutUntil = Date.now() + this.lockoutDuration;
      this.lockouts.set(identifier, lockoutUntil);

      console.warn(`Account locked: ${identifier} until ${new Date(lockoutUntil).toISOString()}`);
    }
  }

  /**
   * 成功した試行をリセット
   */
  recordSuccessfulAttempt(identifier) {
    this.attempts.delete(identifier);
    this.lockouts.delete(identifier);
  }

  /**
   * クリーンアップ(期限切れデータ削除)
   */
  cleanup() {
    const now = Date.now();

    // 期限切れロックアウト削除
    for (const [identifier, lockoutUntil] of this.lockouts.entries()) {
      if (now >= lockoutUntil) {
        this.lockouts.delete(identifier);
      }
    }

    // 古い試行データ削除
    for (const [identifier, data] of this.attempts.entries()) {
      if (now - data.firstAttempt > this.attemptWindow) {
        this.attempts.delete(identifier);
      }
    }
  }
}

module.exports = LoginRateLimiter;

AuthServiceへの統合

// auth-service/src/authService.js
const LoginRateLimiter = require('./middleware/rateLimiter');

class AuthService {
  constructor() {
    this.db = new DBService();
    this.crypto = new CryptoService();
    this.rateLimiter = new LoginRateLimiter();
  }

  async activate(email, password, clientId) {
    // 1. レート制限チェック
    const rateCheck = this.rateLimiter.checkAttempt(email);
    if (!rateCheck.allowed) {
      if (rateCheck.locked) {
        throw new Error(`Account locked. Try again in ${rateCheck.remainingTime}`);
      }
      throw new Error(`Too many attempts. ${rateCheck.remainingAttempts} remaining`);
    }

    try {
      // 2. 入力検証
      this.validateActivationInput(email, password, clientId);

      // 3. ユーザー認証
      const user = await this.authenticateUser(email, password);

      // 4. 成功した試行を記録
      this.rateLimiter.recordSuccessfulAttempt(email);

      // 5. ライセンス処理...
      // (既存のコード)
    } catch (error) {
      // 認証失敗時は試行回数をカウント
      if (error.message === 'Invalid credentials') {
        this.rateLimiter.recordFailedAttempt(email);
      }
      throw error;
    }
  }
}

🔒 サービス間認証

SERVICE_SECRET検証ミドルウェア

// auth-service/src/middleware/serviceAuth.js
class ServiceAuthMiddleware {
  constructor() {
    this.serviceSecret = process.env.SERVICE_SECRET;
    if (!this.serviceSecret) {
      throw new Error('SERVICE_SECRET is not configured');
    }
  }

  /**
   * サービス間認証ミドルウェア
   */
  authenticate() {
    return (req, res, next) => {
      const authHeader = req.headers['x-service-secret'];

      if (!authHeader) {
        return res.status(401).json({
          success: false,
          error: 'Unauthorized',
          message: 'Missing service authentication'
        });
      }

      // タイミング攻撃対策(固定時間比較)
      if (!this.constantTimeCompare(authHeader, this.serviceSecret)) {
        return res.status(403).json({
          success: false,
          error: 'Forbidden',
          message: 'Invalid service credentials'
        });
      }

      next();
    };
  }

  /**
   * 固定時間比較(タイミング攻撃対策)
   */
  constantTimeCompare(a, b) {
    if (a.length !== b.length) {
      return false;
    }

    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a.charCodeAt(i) ^ b.charCodeAt(i);
    }

    return result === 0;
  }
}

module.exports = ServiceAuthMiddleware;

Expressアプリケーションへの適用

// auth-service/src/standalone.js
const express = require('express');
const ServiceAuthMiddleware = require('./middleware/serviceAuth');

const app = express();
app.use(express.json());

const serviceAuth = new ServiceAuthMiddleware();

// サービス間認証が必要なエンドポイント
app.post('/activate', serviceAuth.authenticate(), async (req, res) => {
  // 処理...
});

app.post('/validate', serviceAuth.authenticate(), async (req, res) => {
  // 処理...
});

🛡️ 入力バリデーションとサニタイゼーション

包括的な入力検証

// auth-service/src/validators/inputValidator.js
class InputValidator {
  /**
   * メールアドレス検証
   */
  static validateEmail(email) {
    if (!email || typeof email !== 'string') {
      throw new Error('Email is required and must be a string');
    }

    // RFC 5322準拠の簡易版
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error('Invalid email format');
    }

    // 長さ制限
    if (email.length > 254) {
      throw new Error('Email is too long');
    }

    return email.toLowerCase().trim();
  }

  /**
   * クライアントID検証
   */
  static validateClientId(clientId) {
    if (!clientId || typeof clientId !== 'string') {
      throw new Error('Client ID is required and must be a string');
    }

    // 長さチェック
    if (clientId.length < 10 || clientId.length > 128) {
      throw new Error('Client ID must be between 10 and 128 characters');
    }

    // 許可文字(英数字、ハイフン、アンダースコア)
    const validPattern = /^[a-zA-Z0-9_-]+$/;
    if (!validPattern.test(clientId)) {
      throw new Error('Client ID contains invalid characters');
    }

    // SQLインジェクション対策(プリペアドステートメント併用)
    const dangerousPatterns = [
      /(\bOR\b.*=.*)/i,
      /(\bAND\b.*=.*)/i,
      /(;.*DROP\b)/i,
      /(--)/,
      /('/.*')/
    ];

    for (const pattern of dangerousPatterns) {
      if (pattern.test(clientId)) {
        throw new Error('Client ID contains suspicious patterns');
      }
    }

    return clientId.trim();
  }

  /**
   * 一般的な文字列サニタイゼーション
   */
  static sanitizeString(input, maxLength = 255) {
    if (typeof input !== 'string') {
      return '';
    }

    // XSS対策(HTMLエスケープ)
    const escaped = input
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#x27;')
      .replace(/\//g, '&#x2F;');

    // 長さ制限
    return escaped.substring(0, maxLength).trim();
  }

  /**
   * JSONペイロード検証
   */
  static validatePayload(payload, requiredFields) {
    if (!payload || typeof payload !== 'object') {
      throw new Error('Invalid payload');
    }

    for (const field of requiredFields) {
      if (!(field in payload)) {
        throw new Error(`Missing required field: ${field}`);
      }
    }

    return true;
  }
}

module.exports = InputValidator;

🔍 セキュリティヘッダー設定

Helmet.js統合

// auth-service/src/standalone.js
const helmet = require('helmet');

const app = express();

// セキュリティヘッダー設定
app.use(helmet({
  // Content Security Policy
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", 'data:', 'https:'],
    },
  },
  // HSTS (Strict-Transport-Security)
  hsts: {
    maxAge: 31536000, // 1年
    includeSubDomains: true,
    preload: true
  },
  // X-Frame-Options
  frameguard: {
    action: 'deny'
  },
  // X-Content-Type-Options
  noSniff: true,
  // Referrer-Policy
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin'
  }
}));

📊 監査ログ記録

セキュリティイベント記録

// auth-service/src/auditLogger.js
class AuditLogger {
  constructor(db) {
    this.db = db;
  }

  /**
   * セキュリティイベント記録
   */
  logSecurityEvent(eventType, details) {
    const logId = `log-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
    const timestamp = new Date().toISOString();

    this.db.prepare(`
      INSERT INTO audit_logs (log_id, user_id, action, details, timestamp, severity)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(
      logId,
      details.userId || null,
      eventType,
      JSON.stringify(details),
      timestamp,
      this.getSeverity(eventType)
    );
  }

  getSeverity(eventType) {
    const highSeverity = [
      'ACCOUNT_LOCKED',
      'UNAUTHORIZED_ACCESS',
      'INVALID_SERVICE_SECRET',
      'SQL_INJECTION_ATTEMPT'
    ];

    return highSeverity.includes(eventType) ? 'HIGH' : 'MEDIUM';
  }
}

module.exports = AuditLogger;

🧪 セキュリティテスト

テストケース

// auth-service/tests/security.test.js
const request = require('supertest');
const app = require('../src/standalone');

describe('Security Tests', () => {
  describe('Rate Limiting', () => {
    it('should block after 5 failed attempts', async () => {
      const email = 'test@example.com';

      // 5回失敗
      for (let i = 0; i < 5; i++) {
        await request(app)
          .post('/activate')
          .send({ email, password: 'wrong', clientId: 'test' });
      }

      // 6回目はブロック
      const response = await request(app)
        .post('/activate')
        .send({ email, password: 'correct', clientId: 'test' });

      expect(response.status).toBe(429);
      expect(response.body.message).toContain('locked');
    });
  });

  describe('Input Validation', () => {
    it('should reject SQL injection attempts', async () => {
      const response = await request(app)
        .post('/activate')
        .send({
          email: 'test@example.com',
          password: 'password',
          clientId: "'; DROP TABLE users; --"
        });

      expect(response.status).toBe(400);
      expect(response.body.message).toContain('suspicious patterns');
    });

    it('should reject XSS attempts', async () => {
      const response = await request(app)
        .post('/activate')
        .send({
          email: '<script>alert("xss")</script>@example.com',
          password: 'password',
          clientId: 'test'
        });

      expect(response.status).toBe(400);
    });
  });

  describe('Service Authentication', () => {
    it('should reject requests without SERVICE_SECRET', async () => {
      const response = await request(app)
        .post('/activate')
        .send({ email: 'test@example.com', password: 'pass', clientId: 'test' });

      expect(response.status).toBe(401);
    });

    it('should reject invalid SERVICE_SECRET', async () => {
      const response = await request(app)
        .post('/activate')
        .set('X-Service-Secret', 'invalid-secret')
        .send({ email: 'test@example.com', password: 'pass', clientId: 'test' });

      expect(response.status).toBe(403);
    });
  });
});

🎯 次のステップ

Day 15では、データベース操作とトランザクションを学びます。better-sqlite3の最適化、トランザクション管理、データ整合性の保証について詳しく解説します。


🔗 関連リンク


次回予告: Day 15では、SQLiteのパフォーマンス最適化とトランザクション設計を詳しく解説します!


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?