🎄 科学と神々株式会社 アドベントカレンダー 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
// 長さ制限
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.