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 8: セキュリティ層の実装

Posted at

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

Hybrid License System Day 8: セキュリティ層の実装

API Gateway編 (3/5)


📖 はじめに

Day 8では、セキュリティ層の実装を学びます。CORS設定、Helmet.jsによるセキュリティヘッダー、レート制限、JWT検証ミドルウェアを実装し、安全なAPI Gatewayを構築しましょう。


🔒 CORS(Cross-Origin Resource Sharing)

CORSとは?

CORSは、異なるオリジン(ドメイン・プロトコル・ポート)からのリソースアクセスを制御するセキュリティ機構です。

┌─────────────────────────────────────────────────┐
│  Browser Security (Same-Origin Policy)          │
│                                                 │
│  https://example.com:443 → Same Origin         │
│  https://api.example.com:443 → Different Origin│
│  http://example.com:443 → Different Protocol   │
│  https://example.com:8080 → Different Port     │
└─────────────────────────────────────────────────┘

CORS設定の実装

// api-gateway/src/server.js
const cors = require('cors');

// 基本的なCORS設定
app.use(cors({
  origin: 'http://localhost:3000',  // 許可するオリジン
  credentials: true                 // Cookie送信を許可
}));

環境別CORS設定

// api-gateway/src/middleware/cors.js
const getCorsOptions = () => {
  const env = process.env.NODE_ENV || 'development';

  if (env === 'production') {
    // 本番環境: ホワイトリスト方式
    return {
      origin: [
        'https://app.example.com',
        'https://admin.example.com'
      ],
      credentials: true,
      methods: ['GET', 'POST', 'PUT', 'DELETE'],
      allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
      exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
      maxAge: 86400  // 24時間キャッシュ
    };
  }

  if (env === 'development') {
    // 開発環境: すべて許可
    return {
      origin: true,  // すべてのオリジンを許可
      credentials: true
    };
  }

  if (env === 'test') {
    // テスト環境: 特定オリジンのみ
    return {
      origin: ['http://localhost:3000', 'http://localhost:8080'],
      credentials: true
    };
  }
};

module.exports = getCorsOptions;

動的CORS検証

// api-gateway/src/middleware/dynamicCors.js
const dynamicCors = cors({
  origin: function (origin, callback) {
    // オリジンが送信されない場合(同一オリジン)は許可
    if (!origin) {
      return callback(null, true);
    }

    // ホワイトリストに含まれるか確認
    const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',');

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      // 許可されていないオリジン
      console.warn(`Blocked CORS request from: ${origin}`);
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
});

app.use(dynamicCors);

🛡️ Helmet.js セキュリティヘッダー

Helmet.jsとは?

Helmet.jsは、HTTPセキュリティヘッダーを自動設定するミドルウェアです。

// api-gateway/src/server.js
const helmet = require('helmet');

// 基本的なHelmet設定
app.use(helmet());

設定されるセキュリティヘッダー

// 詳細なHelmet設定
app.use(helmet({
  // Content Security Policy
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"]
    }
  },

  // DNS Prefetch Control
  dnsPrefetchControl: {
    allow: false
  },

  // Frame Guard (Clickjacking防止)
  frameguard: {
    action: 'deny'
  },

  // Hide Powered-By Header
  hidePoweredBy: true,

  // HSTS (HTTP Strict Transport Security)
  hsts: {
    maxAge: 31536000,      // 1年間
    includeSubDomains: true,
    preload: true
  },

  // IE No Open (IE8+のダウンロード対策)
  ieNoOpen: true,

  // No Sniff (MIMEタイプスニッフィング防止)
  noSniff: true,

  // Referrer Policy
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin'
  },

  // XSS Filter
  xssFilter: true
}));

レスポンスヘッダー例

HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'
X-DNS-Prefetch-Control: off
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Download-Options: noopen
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 1; mode=block

🚦 レート制限(Rate Limiting)

express-rate-limitの実装

// api-gateway/src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');

// IP単位のレート制限
const ipLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15分間
  max: 100,                  // 最大100リクエスト
  message: {
    error: 'Too many requests',
    message: 'You have exceeded the 100 requests in 15 minutes limit',
    retryAfter: '15 minutes'
  },
  standardHeaders: true,     // RateLimit-* ヘッダーを返す
  legacyHeaders: false,      // X-RateLimit-* ヘッダーを無効化
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many requests',
      message: 'Rate limit exceeded',
      retryAfter: req.rateLimit.resetTime
    });
  }
});

// ライセンスアクティベーション専用(より厳しい制限)
const activationLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,  // 1時間
  max: 10,                   // 最大10リクエスト
  message: 'Too many activation attempts',
  skipSuccessfulRequests: false,
  skipFailedRequests: false
});

module.exports = { ipLimiter, activationLimiter };

使用例

// api-gateway/src/routes/auth.js
const { ipLimiter, activationLimiter } = require('../middleware/rateLimiter');

// 全ルートにIP制限
router.use(ipLimiter);

// アクティベーションに厳しい制限
router.post('/license/activate', activationLimiter, async (req, res) => {
  // アクティベーション処理
});

ユーザー単位のレート制限

// JWT検証済みユーザー単位の制限
const userLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 500,  // 認証済みユーザーは多めに許可
  keyGenerator: (req) => {
    // JWTのuserIdをキーとして使用
    return req.user?.userId || req.ip;
  }
});

Redis ストアによるスケーリング

// 複数インスタンス間でレート制限を共有
const RedisStore = require('rate-limit-redis');
const redis = require('redis');

const redisClient = redis.createClient({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379
});

const limiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rate_limit:'
  }),
  windowMs: 15 * 60 * 1000,
  max: 100
});

🔑 JWT検証ミドルウェア

JWT検証の実装

// api-gateway/src/middleware/jwtVerify.js
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET;

function verifyJWT(req, res, next) {
  // Authorizationヘッダーを取得
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'No authorization header'
    });
  }

  // Bearer トークンを抽出
  const parts = authHeader.split(' ');
  if (parts.length !== 2 || parts[0] !== 'Bearer') {
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Invalid authorization format. Expected: Bearer <token>'
    });
  }

  const token = parts[1];

  try {
    // JWT検証
    const decoded = jwt.verify(token, JWT_SECRET);

    // リクエストオブジェクトにユーザー情報を追加
    req.user = {
      userId: decoded.userId,
      email: decoded.email,
      plan: decoded.plan,
      clientId: decoded.clientId
    };

    // トークンの有効期限チェック
    const now = Math.floor(Date.now() / 1000);
    if (decoded.exp && decoded.exp < now) {
      return res.status(401).json({
        error: 'Unauthorized',
        message: 'Token expired'
      });
    }

    next();
  } catch (error) {
    console.error('JWT verification failed:', error.message);

    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'Unauthorized',
        message: 'Token expired',
        expiredAt: error.expiredAt
      });
    }

    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        error: 'Unauthorized',
        message: 'Invalid token'
      });
    }

    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Token verification failed'
    });
  }
}

module.exports = verifyJWT;

オプショナルJWT検証

// JWT検証(トークンがある場合のみ)
function optionalJWT(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    // トークンがなくても続行
    return next();
  }

  const parts = authHeader.split(' ');
  if (parts.length !== 2 || parts[0] !== 'Bearer') {
    return next();
  }

  const token = parts[1];

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded;
  } catch (error) {
    // エラーでも続行(ログは記録)
    console.warn('Optional JWT verification failed:', error.message);
  }

  next();
}

JWT検証の使用例

// api-gateway/src/routes/auth.js
const verifyJWT = require('../middleware/jwtVerify');

// 公開エンドポイント(JWT不要)
router.post('/license/activate', async (req, res) => {
  // アクティベーション処理
});

// 保護されたエンドポイント(JWT必須)
router.get('/license/validate', verifyJWT, async (req, res) => {
  // req.user にユーザー情報が含まれる
  const { userId, email, plan, clientId } = req.user;

  // 検証処理
  res.json({
    valid: true,
    user: { userId, email, plan, clientId }
  });
});

🔐 統合セキュリティミドルウェア

セキュリティスタックの統合

// api-gateway/src/middleware/security.js
const helmet = require('helmet');
const cors = require('cors');
const { ipLimiter } = require('./rateLimiter');
const getCorsOptions = require('./cors');

function applySecurity(app) {
  // 1. Helmet.js セキュリティヘッダー
  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        scriptSrc: ["'self'"],
        imgSrc: ["'self'", "data:", "https:"]
      }
    },
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true
    }
  }));

  // 2. CORS設定
  app.use(cors(getCorsOptions()));

  // 3. レート制限
  app.use(ipLimiter);

  // 4. リクエストサイズ制限
  app.use(express.json({ limit: '10kb' }));
  app.use(express.urlencoded({ extended: true, limit: '10kb' }));

  // 5. セキュリティログ
  app.use((req, res, next) => {
    console.log({
      timestamp: new Date().toISOString(),
      method: req.method,
      path: req.path,
      ip: req.ip,
      userAgent: req.get('user-agent')
    });
    next();
  });
}

module.exports = applySecurity;

サーバー起動時の設定

// api-gateway/src/server.js
const express = require('express');
const applySecurity = require('./middleware/security');

const app = express();

// セキュリティミドルウェアを適用
applySecurity(app);

// ルーターを設定
const authRoutes = require('./routes/auth');
const adminRoutes = require('./routes/admin');

app.use('/api/v1/license', authRoutes);
app.use('/api/v1/admin', adminRoutes);

// エラーハンドリング
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({
    error: 'Internal server error'
  });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API Gateway running on port ${PORT}`);
});

🧪 セキュリティテスト

CORS テスト

// tests/security/cors.test.js
const request = require('supertest');
const app = require('../src/server');

describe('CORS Security', () => {
  it('should allow requests from allowed origin', async () => {
    const response = await request(app)
      .get('/api/v1/health')
      .set('Origin', 'https://app.example.com');

    expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
  });

  it('should block requests from disallowed origin', async () => {
    const response = await request(app)
      .get('/api/v1/health')
      .set('Origin', 'https://malicious.com');

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

レート制限テスト

describe('Rate Limiting', () => {
  it('should allow requests within limit', async () => {
    for (let i = 0; i < 100; i++) {
      const response = await request(app).get('/api/v1/health');
      expect(response.status).toBe(200);
    }
  });

  it('should block requests exceeding limit', async () => {
    // 100リクエスト送信
    for (let i = 0; i < 100; i++) {
      await request(app).get('/api/v1/health');
    }

    // 101番目はブロックされる
    const response = await request(app).get('/api/v1/health');
    expect(response.status).toBe(429);
    expect(response.body.error).toBe('Too many requests');
  });
});

JWT検証テスト

describe('JWT Authentication', () => {
  it('should reject requests without token', async () => {
    const response = await request(app).get('/api/v1/license/validate');
    expect(response.status).toBe(401);
  });

  it('should accept requests with valid token', async () => {
    const token = generateTestToken({ userId: 'test-user' });

    const response = await request(app)
      .get('/api/v1/license/validate')
      .set('Authorization', `Bearer ${token}`);

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

  it('should reject expired tokens', async () => {
    const expiredToken = generateTestToken({ userId: 'test-user' }, { expiresIn: '-1h' });

    const response = await request(app)
      .get('/api/v1/license/validate')
      .set('Authorization', `Bearer ${expiredToken}`);

    expect(response.status).toBe(401);
    expect(response.body.message).toBe('Token expired');
  });
});

🎯 次のステップ

Day 9では、ヘルスチェックと監視について学びます。ヘルスチェックエンドポイント、サービス監視、メトリクス収集の実装を理解しましょう。


🔗 関連リンク


次回予告: Day 9では、ヘルスチェックエンドポイントとPrometheusメトリクス収集の実装を詳しく解説します!


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?