T-CREATOR

TypeScript でセキュアな認証・認可を実装するベストプラクティス

TypeScript でセキュアな認証・認可を実装するベストプラクティス

現代の Web アプリケーションにおいて、セキュアな認証・認可システムは必須の要素となっています。TypeScript の型安全性を活用することで、多くのセキュリティリスクを開発段階で防ぐことができます。

この記事では、TypeScript を使用した認証・認可システムの実装において、実際に発生しやすいエラーとその解決策、そしてセキュリティを最優先にした実装方法について詳しく解説します。

認証・認可の基本概念

認証(Authentication)と認可(Authorization)の違い

認証と認可は混同されがちですが、明確に異なる概念です。

認証(Authentication) は「あなたは誰ですか?」という質問に答えるプロセスです。ユーザーが本人であることを証明する仕組みです。

認可(Authorization) は「あなたは何をしてもよいですか?」という質問に答えるプロセスです。認証されたユーザーが特定のリソースにアクセスする権限があるかを判断します。

セキュリティの重要性とリスク

認証・認可システムの脆弱性は、以下のような深刻な問題を引き起こす可能性があります:

  • データ漏洩: 不正アクセスによる機密情報の流出
  • 権限昇格: 一般ユーザーが管理者権限を取得
  • セッションハイジャック: 他人のセッションを乗っ取り
  • ブルートフォース攻撃: パスワードの総当たり攻撃

TypeScript の型安全性がもたらす利点

TypeScript の型システムを活用することで、以下のようなセキュリティ上の利点を得られます:

typescript// 型安全なユーザー情報の定義
interface User {
  id: string;
  email: string;
  role: UserRole;
  permissions: Permission[];
}

// 権限の型定義
type UserRole = 'admin' | 'user' | 'guest';
type Permission = 'read' | 'write' | 'delete';

// 型チェックにより不正な値の代入を防ぐ
const user: User = {
  id: '123',
  email: 'user@example.com',
  role: 'admin', // 型チェックにより不正な値はエラー
  permissions: ['read', 'write'],
};

認証システムの実装

JWT(JSON Web Token)の実装

JWT は認証情報を安全に伝達するための標準的な方法です。以下のように実装します:

typescriptimport jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';

// JWT設定の型定義
interface JWTPayload {
  userId: string;
  email: string;
  role: string;
  iat: number;
  exp: number;
}

// JWTトークンの生成
const generateToken = (user: User): string => {
  const payload: JWTPayload = {
    userId: user.id,
    email: user.email,
    role: user.role,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24時間
  };

  return jwt.sign(payload, process.env.JWT_SECRET!, {
    algorithm: 'HS256',
  });
};

// JWTトークンの検証
const verifyToken = (token: string): JWTPayload | null => {
  try {
    return jwt.verify(
      token,
      process.env.JWT_SECRET!
    ) as JWTPayload;
  } catch (error) {
    console.error('JWT verification failed:', error);
    return null;
  }
};

パスワードハッシュ化とソルト

パスワードは平文で保存してはいけません。bcrypt を使用して安全にハッシュ化します:

typescript// パスワードのハッシュ化
const hashPassword = async (
  password: string
): Promise<string> => {
  const saltRounds = 12; // セキュリティ強度
  return await bcrypt.hash(password, saltRounds);
};

// パスワードの検証
const verifyPassword = async (
  password: string,
  hash: string
): Promise<boolean> => {
  return await bcrypt.compare(password, hash);
};

// ログイン処理の実装
const login = async (
  email: string,
  password: string
): Promise<LoginResult> => {
  try {
    const user = await findUserByEmail(email);
    if (!user) {
      throw new Error('User not found');
    }

    const isValidPassword = await verifyPassword(
      password,
      user.passwordHash
    );
    if (!isValidPassword) {
      throw new Error('Invalid password');
    }

    const token = generateToken(user);
    return { success: true, token, user };
  } catch (error) {
    return { success: false, error: error.message };
  }
};

セッション管理の実装

セッション管理では、セッション固定攻撃やセッションハイジャックを防ぐことが重要です:

typescript// セッション管理の型定義
interface Session {
  id: string;
  userId: string;
  token: string;
  createdAt: Date;
  expiresAt: Date;
  userAgent: string;
  ipAddress: string;
}

// セッションの作成
const createSession = async (
  user: User,
  userAgent: string,
  ipAddress: string
): Promise<Session> => {
  const sessionId = crypto.randomUUID();
  const token = generateToken(user);
  const expiresAt = new Date(
    Date.now() + 24 * 60 * 60 * 1000
  ); // 24時間

  const session: Session = {
    id: sessionId,
    userId: user.id,
    token,
    createdAt: new Date(),
    expiresAt,
    userAgent,
    ipAddress,
  };

  await saveSession(session);
  return session;
};

// セッションの検証
const validateSession = async (
  token: string,
  userAgent: string,
  ipAddress: string
): Promise<boolean> => {
  const session = await findSessionByToken(token);
  if (!session) return false;

  // セッションの有効期限チェック
  if (new Date() > session.expiresAt) {
    await deleteSession(session.id);
    return false;
  }

  // セッションハイジャック対策
  if (
    session.userAgent !== userAgent ||
    session.ipAddress !== ipAddress
  ) {
    await deleteSession(session.id);
    return false;
  }

  return true;
};

多要素認証(MFA)の実装

多要素認証により、セキュリティを大幅に向上させることができます:

typescript// TOTP(Time-based One-Time Password)の実装
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';

// MFA設定の型定義
interface MFASetup {
  secret: string;
  qrCode: string;
  backupCodes: string[];
}

// MFA設定の初期化
const setupMFA = async (user: User): Promise<MFASetup> => {
  const secret = speakeasy.generateSecret({
    name: `${user.email} (YourApp)`,
    issuer: 'YourApp',
  });

  const qrCode = await qrcode.toDataURL(
    secret.otpauth_url!
  );
  const backupCodes = generateBackupCodes();

  // ユーザーにMFA設定を保存
  await updateUserMFA(user.id, secret.base32, backupCodes);

  return {
    secret: secret.base32,
    qrCode,
    backupCodes,
  };
};

// MFAコードの検証
const verifyMFACode = (
  secret: string,
  token: string
): boolean => {
  return speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token,
    window: 2, // 前後2分の許容範囲
  });
};

認可システムの実装

ロールベースアクセス制御(RBAC)

RBAC は、ユーザーの役割に基づいてアクセス制御を行う仕組みです:

typescript// ロールと権限の定義
enum Role {
  ADMIN = 'admin',
  MODERATOR = 'moderator',
  USER = 'user',
  GUEST = 'guest',
}

enum Permission {
  READ_POSTS = 'read_posts',
  CREATE_POSTS = 'create_posts',
  EDIT_POSTS = 'edit_posts',
  DELETE_POSTS = 'delete_posts',
  MANAGE_USERS = 'manage_users',
  MANAGE_SYSTEM = 'manage_system',
}

// ロールと権限のマッピング
const rolePermissions: Record<Role, Permission[]> = {
  [Role.ADMIN]: Object.values(Permission),
  [Role.MODERATOR]: [
    Permission.READ_POSTS,
    Permission.CREATE_POSTS,
    Permission.EDIT_POSTS,
    Permission.MANAGE_USERS,
  ],
  [Role.USER]: [
    Permission.READ_POSTS,
    Permission.CREATE_POSTS,
    Permission.EDIT_POSTS,
  ],
  [Role.GUEST]: [Permission.READ_POSTS],
};

// 権限チェック関数
const hasPermission = (
  userRole: Role,
  requiredPermission: Permission
): boolean => {
  const userPermissions = rolePermissions[userRole];
  return userPermissions.includes(requiredPermission);
};

権限ベースアクセス制御(PBAC)

PBAC は、より細かい権限制御を可能にする仕組みです:

typescript// 権限の詳細定義
interface Permission {
  resource: string;
  action: string;
  conditions?: Record<string, any>;
}

// ユーザーの権限チェック
const checkPermission = (
  user: User,
  resource: string,
  action: string,
  context?: Record<string, any>
): boolean => {
  const userPermissions = user.permissions;

  return userPermissions.some((permission) => {
    // リソースとアクションの一致チェック
    if (
      permission.resource !== resource ||
      permission.action !== action
    ) {
      return false;
    }

    // 条件チェック
    if (permission.conditions && context) {
      return evaluateConditions(
        permission.conditions,
        context
      );
    }

    return true;
  });
};

// 条件評価関数
const evaluateConditions = (
  conditions: Record<string, any>,
  context: Record<string, any>
): boolean => {
  for (const [key, value] of Object.entries(conditions)) {
    if (context[key] !== value) {
      return false;
    }
  }
  return true;
};

ミドルウェアによる認可チェック

Express.js での認可ミドルウェアの実装例です:

typescriptimport { Request, Response, NextFunction } from 'express';

// 認可ミドルウェアの型定義
interface AuthRequest extends Request {
  user?: User;
  session?: Session;
}

// 認証ミドルウェア
const authenticate = async (
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const token = req.headers.authorization?.replace(
      'Bearer ',
      ''
    );
    if (!token) {
      res
        .status(401)
        .json({ error: 'Authentication required' });
      return;
    }

    const payload = verifyToken(token);
    if (!payload) {
      res.status(401).json({ error: 'Invalid token' });
      return;
    }

    const user = await findUserById(payload.userId);
    if (!user) {
      res.status(401).json({ error: 'User not found' });
      return;
    }

    req.user = user;
    next();
  } catch (error) {
    res
      .status(500)
      .json({ error: 'Authentication failed' });
  }
};

// 認可ミドルウェア
const authorize = (requiredPermission: Permission) => {
  return (
    req: AuthRequest,
    res: Response,
    next: NextFunction
  ): void => {
    if (!req.user) {
      res
        .status(401)
        .json({ error: 'Authentication required' });
      return;
    }

    const hasAccess = checkPermission(
      req.user,
      requiredPermission.resource,
      requiredPermission.action,
      req.body
    );

    if (!hasAccess) {
      res
        .status(403)
        .json({ error: 'Insufficient permissions' });
      return;
    }

    next();
  };
};

動的権限管理

動的権限管理により、実行時に権限を変更できます:

typescript// 動的権限管理の実装
class DynamicPermissionManager {
  private permissions: Map<string, Permission[]> =
    new Map();

  // 権限の追加
  addPermission(
    userId: string,
    permission: Permission
  ): void {
    const userPermissions =
      this.permissions.get(userId) || [];
    userPermissions.push(permission);
    this.permissions.set(userId, userPermissions);
  }

  // 権限の削除
  removePermission(
    userId: string,
    permission: Permission
  ): void {
    const userPermissions =
      this.permissions.get(userId) || [];
    const filteredPermissions = userPermissions.filter(
      (p) =>
        p.resource !== permission.resource ||
        p.action !== permission.action
    );
    this.permissions.set(userId, filteredPermissions);
  }

  // 権限のチェック
  hasPermission(
    userId: string,
    resource: string,
    action: string
  ): boolean {
    const userPermissions =
      this.permissions.get(userId) || [];
    return userPermissions.some(
      (p) => p.resource === resource && p.action === action
    );
  }
}

// 使用例
const permissionManager = new DynamicPermissionManager();
permissionManager.addPermission('user123', {
  resource: 'posts',
  action: 'edit',
  conditions: { ownerId: 'user123' },
});

セキュリティ強化の実装

CSRF 対策の実装

CSRF(Cross-Site Request Forgery)攻撃を防ぐための実装です:

typescriptimport crypto from 'crypto';

// CSRFトークンの生成
const generateCSRFToken = (): string => {
  return crypto.randomBytes(32).toString('hex');
};

// CSRFトークンの検証
const validateCSRFToken = (
  token: string,
  sessionToken: string
): boolean => {
  return token === sessionToken;
};

// CSRFミドルウェア
const csrfProtection = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
): void => {
  if (req.method === 'GET') {
    const csrfToken = generateCSRFToken();
    req.session!.csrfToken = csrfToken;
    res.locals.csrfToken = csrfToken;
    next();
  } else {
    const token =
      req.body._csrf || req.headers['x-csrf-token'];
    const sessionToken = req.session?.csrfToken;

    if (
      !token ||
      !sessionToken ||
      !validateCSRFToken(token, sessionToken)
    ) {
      res
        .status(403)
        .json({ error: 'CSRF token validation failed' });
      return;
    }

    next();
  }
};

XSS 対策の実装

XSS(Cross-Site Scripting)攻撃を防ぐための実装です:

typescriptimport DOMPurify from 'dompurify';

// 入力値のサニタイズ
const sanitizeInput = (input: string): string => {
  return DOMPurify.sanitize(input, {
    ALLOWED_TAGS: [],
    ALLOWED_ATTR: [],
  });
};

// HTMLエスケープ
const escapeHtml = (text: string): string => {
  const map: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;',
  };

  return text.replace(/[&<>"'/]/g, (char) => map[char]);
};

// 入力値検証
const validateInput = (
  input: any,
  schema: any
): boolean => {
  try {
    // スキーマに基づく検証
    return schema.validate(input);
  } catch (error) {
    return false;
  }
};

SQL インジェクション対策

SQL インジェクション攻撃を防ぐための実装です:

typescript// プリペアドステートメントの使用
const findUserByEmail = async (
  email: string
): Promise<User | null> => {
  const query = 'SELECT * FROM users WHERE email = ?';
  const [rows] = await db.execute(query, [email]);
  return rows[0] || null;
};

// パラメータ化クエリ
const createUser = async (
  user: Omit<User, 'id'>
): Promise<User> => {
  const query = `
    INSERT INTO users (email, password_hash, role, created_at)
    VALUES (?, ?, ?, NOW())
  `;

  const [result] = await db.execute(query, [
    user.email,
    user.passwordHash,
    user.role,
  ]);

  return { ...user, id: result.insertId };
};

// 入力値の型チェック
const validateEmail = (email: string): boolean => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
};

レート制限の実装

レート制限により、ブルートフォース攻撃を防ぎます:

typescript// レート制限の実装
class RateLimiter {
  private attempts: Map<
    string,
    { count: number; resetTime: number }
  > = new Map();

  // レート制限チェック
  isRateLimited(
    key: string,
    maxAttempts: number,
    windowMs: number
  ): boolean {
    const now = Date.now();
    const attempt = this.attempts.get(key);

    if (!attempt || now > attempt.resetTime) {
      this.attempts.set(key, {
        count: 1,
        resetTime: now + windowMs,
      });
      return false;
    }

    if (attempt.count >= maxAttempts) {
      return true;
    }

    attempt.count++;
    return false;
  }

  // リセット
  reset(key: string): void {
    this.attempts.delete(key);
  }
}

// レート制限ミドルウェア
const rateLimit = (
  maxAttempts: number,
  windowMs: number
) => {
  const limiter = new RateLimiter();

  return (
    req: AuthRequest,
    res: Response,
    next: NextFunction
  ): void => {
    const key =
      req.ip || req.connection.remoteAddress || 'unknown';

    if (limiter.isRateLimited(key, maxAttempts, windowMs)) {
      res.status(429).json({ error: 'Too many requests' });
      return;
    }

    next();
  };
};

エラーハンドリングとログ

セキュリティログの実装

セキュリティイベントを記録するログシステムの実装です:

typescript// ログレベルの定義
enum LogLevel {
  INFO = 'info',
  WARN = 'warn',
  ERROR = 'error',
  SECURITY = 'security',
}

// セキュリティログの型定義
interface SecurityLog {
  timestamp: Date;
  level: LogLevel;
  event: string;
  userId?: string;
  ipAddress: string;
  userAgent: string;
  details: Record<string, any>;
}

// セキュリティログの記録
const logSecurityEvent = async (
  log: SecurityLog
): Promise<void> => {
  try {
    await saveSecurityLog(log);

    // 重要なセキュリティイベントの場合はアラート
    if (log.level === LogLevel.SECURITY) {
      await sendSecurityAlert(log);
    }
  } catch (error) {
    console.error('Failed to log security event:', error);
  }
};

// ログイン試行の記録
const logLoginAttempt = async (
  email: string,
  success: boolean,
  ipAddress: string,
  userAgent: string
): Promise<void> => {
  await logSecurityEvent({
    timestamp: new Date(),
    level: success ? LogLevel.INFO : LogLevel.SECURITY,
    event: success ? 'login_success' : 'login_failed',
    ipAddress,
    userAgent,
    details: { email, success },
  });
};

エラーメッセージの適切な管理

セキュリティを考慮したエラーメッセージの管理です:

typescript// エラーメッセージの定義
const ErrorMessages = {
  AUTHENTICATION_FAILED: '認証に失敗しました',
  INVALID_CREDENTIALS:
    'メールアドレスまたはパスワードが正しくありません',
  ACCOUNT_LOCKED: 'アカウントが一時的にロックされています',
  SESSION_EXPIRED: 'セッションの有効期限が切れました',
  INSUFFICIENT_PERMISSIONS:
    'この操作を実行する権限がありません',
  RATE_LIMIT_EXCEEDED:
    'リクエストが多すぎます。しばらく待ってから再試行してください',
} as const;

// セキュアなエラーレスポンス
const createSecureErrorResponse = (
  errorType: keyof typeof ErrorMessages,
  includeDetails: boolean = false
): { error: string; details?: string } => {
  const response: { error: string; details?: string } = {
    error: ErrorMessages[errorType],
  };

  // 開発環境でのみ詳細情報を含める
  if (
    includeDetails &&
    process.env.NODE_ENV === 'development'
  ) {
    response.details = `Error type: ${errorType}`;
  }

  return response;
};

監査ログの実装

重要な操作を記録する監査ログシステムです:

typescript// 監査ログの型定義
interface AuditLog {
  id: string;
  timestamp: Date;
  userId: string;
  action: string;
  resource: string;
  resourceId?: string;
  oldValue?: any;
  newValue?: any;
  ipAddress: string;
  userAgent: string;
}

// 監査ログの記録
const logAuditEvent = async (
  log: Omit<AuditLog, 'id' | 'timestamp'>
): Promise<void> => {
  const auditLog: AuditLog = {
    ...log,
    id: crypto.randomUUID(),
    timestamp: new Date(),
  };

  await saveAuditLog(auditLog);
};

// 監査デコレータ
const audit = (action: string, resource: string) => {
  return (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) => {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      const result = await originalMethod.apply(this, args);

      // 監査ログの記録
      await logAuditEvent({
        userId: this.user?.id || 'anonymous',
        action,
        resource,
        resourceId: args[0]?.id,
        oldValue: args[0],
        newValue: result,
        ipAddress: this.ipAddress,
        userAgent: this.userAgent,
      });

      return result;
    };

    return descriptor;
  };
};

テストと検証

セキュリティテストの実装

セキュリティテストを自動化して、脆弱性を早期に発見します:

typescript// セキュリティテストの実装
describe('Authentication Security Tests', () => {
  test('should prevent SQL injection in login', async () => {
    const maliciousEmail = "'; DROP TABLE users; --";
    const result = await login(maliciousEmail, 'password');

    expect(result.success).toBe(false);
    expect(result.error).toBe('User not found');
  });

  test('should prevent brute force attacks', async () => {
    const email = 'test@example.com';

    // 複数回のログイン試行
    for (let i = 0; i < 10; i++) {
      await login(email, 'wrongpassword');
    }

    // 11回目の試行でレート制限がかかる
    const result = await login(email, 'wrongpassword');
    expect(result.error).toBe('Too many requests');
  });

  test('should validate JWT token properly', () => {
    const invalidToken = 'invalid.jwt.token';
    const result = verifyToken(invalidToken);

    expect(result).toBeNull();
  });
});

ペネトレーションテスト

実際の攻撃シナリオをテストします:

typescript// ペネトレーションテストの実装
describe('Penetration Tests', () => {
  test('should prevent session hijacking', async () => {
    // 正常なログイン
    const loginResult = await login(
      'user@example.com',
      'password'
    );
    const token = loginResult.token;

    // 異なるUser-Agentでアクセス
    const hijackedRequest = {
      headers: { authorization: `Bearer ${token}` },
      userAgent: 'Malicious Bot',
      ipAddress: '192.168.1.100',
    };

    const isValid = await validateSession(
      token,
      hijackedRequest.userAgent,
      hijackedRequest.ipAddress
    );

    expect(isValid).toBe(false);
  });

  test('should prevent privilege escalation', async () => {
    const user = await createUser({
      email: 'user@example.com',
      role: 'user',
      permissions: ['read_posts'],
    });

    // 管理者権限への不正な変更試行
    const maliciousUpdate = {
      ...user,
      role: 'admin',
      permissions: ['manage_system'],
    };

    const hasPermission = checkPermission(
      user,
      'users',
      'update_role',
      { targetRole: 'admin' }
    );

    expect(hasPermission).toBe(false);
  });
});

自動化されたセキュリティチェック

CI/CD パイプラインでの自動セキュリティチェックです:

typescript// セキュリティスキャンの実装
const securityScan = async (): Promise<SecurityReport> => {
  const report: SecurityReport = {
    vulnerabilities: [],
    recommendations: [],
    score: 100,
  };

  // 依存関係の脆弱性チェック
  const dependencyVulns = await checkDependencies();
  report.vulnerabilities.push(...dependencyVulns);

  // コードの静的解析
  const codeVulns = await staticAnalysis();
  report.vulnerabilities.push(...codeVulns);

  // セキュリティ設定のチェック
  const configVulns = await checkSecurityConfig();
  report.vulnerabilities.push(...configVulns);

  // スコアの計算
  report.score = Math.max(
    0,
    100 - report.vulnerabilities.length * 10
  );

  return report;
};

// GitHub Actionsでの自動実行
const githubAction = `
name: Security Scan
on: [push, pull_request]
jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Run security scan
        run: yarn security:scan
      - name: Upload results
        uses: actions/upload-artifact@v2
        with:
          name: security-report
          path: security-report.json
`;

まとめ

TypeScript でセキュアな認証・認可システムを実装する際は、型安全性を最大限に活用し、多層防御のアプローチを取ることが重要です。

この記事で紹介した実装方法を参考に、以下のポイントを意識してシステムを構築してください:

  1. 型安全性の活用: TypeScript の型システムを活用して、コンパイル時に多くのセキュリティ問題を防ぐ
  2. 多層防御: 認証、認可、セキュリティ強化、ログ記録を組み合わせた多層防御
  3. 継続的な改善: セキュリティテストと監視により、継続的にセキュリティを向上させる
  4. ベストプラクティスの適用: OWASP Top 10 などのセキュリティガイドラインに従う

セキュリティは一度実装すれば終わりではありません。新しい脅威に対応するため、定期的な見直しと更新が不可欠です。

関連リンク