← back to Wine Finder Next

lib/twoFactorAuth.ts

472 lines

// Two-Factor Authentication (2FA) Implementation
// TOTP (Time-based One-Time Password) and backup codes support

import crypto from 'crypto';
import { logAudit } from './auth';
import { logSecurityEvent } from './securityMonitoring';

interface TOTPSecret {
  userId: string;
  secret: string;
  backupCodes: string[];
  usedBackupCodes: string[];
  createdAt: Date;
  lastUsed?: Date;
  verified: boolean;
}

interface TOTPVerification {
  userId: string;
  attempts: number;
  lastAttempt: Date;
  lockedUntil?: Date;
}

// Storage - in production use database
const totpSecrets = new Map<string, TOTPSecret>();
const verificationAttempts = new Map<string, TOTPVerification>();
const pendingSetup = new Map<string, { secret: string; qrCode: string; expiresAt: Date }>();

// Configuration
const TOTP_CONFIG = {
  algorithm: 'sha1',
  digits: 6,
  period: 30, // seconds
  window: 1, // accept codes from 1 period before/after
  maxAttempts: 5,
  lockoutDuration: 15 * 60 * 1000, // 15 minutes
  backupCodeCount: 10,
  backupCodeLength: 8,
  setupTimeout: 10 * 60 * 1000, // 10 minutes for setup
  issuer: 'Wine DAO Platform',
  label: 'WineDAO'
};

// Generate TOTP secret
export function generateTOTPSecret(userId: string): {
  secret: string;
  qrCode: string;
  backupCodes: string[];
} {
  // Generate secret
  const secret = base32Encode(crypto.randomBytes(20));

  // Generate backup codes
  const backupCodes = generateBackupCodes();

  // Generate QR code URL
  const otpauthUrl = `otpauth://totp/${TOTP_CONFIG.label}:${userId}?` +
    `secret=${secret}&issuer=${TOTP_CONFIG.issuer}&algorithm=${TOTP_CONFIG.algorithm.toUpperCase()}` +
    `&digits=${TOTP_CONFIG.digits}&period=${TOTP_CONFIG.period}`;

  // Store pending setup
  pendingSetup.set(userId, {
    secret,
    qrCode: otpauthUrl,
    expiresAt: new Date(Date.now() + TOTP_CONFIG.setupTimeout)
  });

  return {
    secret,
    qrCode: otpauthUrl,
    backupCodes
  };
}

// Enable TOTP for user
export function enableTOTP(
  userId: string,
  verificationCode: string
): { success: boolean; backupCodes?: string[]; error?: string } {
  const pending = pendingSetup.get(userId);

  if (!pending) {
    return { success: false, error: 'No pending 2FA setup found' };
  }

  if (new Date() > pending.expiresAt) {
    pendingSetup.delete(userId);
    return { success: false, error: '2FA setup expired' };
  }

  // Verify the code
  const valid = verifyTOTPCode(pending.secret, verificationCode);

  if (!valid) {
    return { success: false, error: 'Invalid verification code' };
  }

  // Generate and store secret with backup codes
  const backupCodes = generateBackupCodes();

  const totpSecret: TOTPSecret = {
    userId,
    secret: pending.secret,
    backupCodes: [...backupCodes], // Copy array
    usedBackupCodes: [],
    createdAt: new Date(),
    verified: true
  };

  totpSecrets.set(userId, totpSecret);
  pendingSetup.delete(userId);

  logAudit({
    userId,
    action: '2FA_ENABLE',
    resource: `user/${userId}/2fa`,
    ipAddress: 'unknown',
    userAgent: 'unknown',
    success: true
  });

  return { success: true, backupCodes };
}

// Verify TOTP code
export function verifyTOTP(
  userId: string,
  code: string,
  ipAddress?: string
): { valid: boolean; reason?: string } {
  const totpSecret = totpSecrets.get(userId);

  if (!totpSecret) {
    return { valid: false, reason: '2FA not enabled' };
  }

  // Check if account is locked
  const attempts = verificationAttempts.get(userId);
  if (attempts && attempts.lockedUntil && new Date() < attempts.lockedUntil) {
    const remainingTime = Math.ceil((attempts.lockedUntil.getTime() - Date.now()) / 1000 / 60);
    return { valid: false, reason: `Account locked. Try again in ${remainingTime} minutes` };
  }

  // Try TOTP code first
  const validTOTP = verifyTOTPCode(totpSecret.secret, code);

  if (validTOTP) {
    // Reset attempts
    verificationAttempts.delete(userId);
    totpSecret.lastUsed = new Date();

    logAudit({
      userId,
      action: '2FA_VERIFY_SUCCESS',
      resource: `user/${userId}/2fa`,
      ipAddress: ipAddress || 'unknown',
      userAgent: 'unknown',
      success: true
    });

    return { valid: true };
  }

  // Try backup code
  const validBackup = verifyBackupCode(userId, code);

  if (validBackup) {
    logAudit({
      userId,
      action: '2FA_BACKUP_USED',
      resource: `user/${userId}/2fa`,
      ipAddress: ipAddress || 'unknown',
      userAgent: 'unknown',
      success: true,
      metadata: { remainingBackupCodes: totpSecret.backupCodes.length - totpSecret.usedBackupCodes.length }
    });

    return { valid: true };
  }

  // Invalid code - track attempts
  trackFailedAttempt(userId, ipAddress);

  return { valid: false, reason: 'Invalid 2FA code' };
}

// Verify backup code
function verifyBackupCode(userId: string, code: string): boolean {
  const totpSecret = totpSecrets.get(userId);

  if (!totpSecret) {
    return false;
  }

  // Normalize code (remove spaces, dashes)
  const normalizedCode = code.replace(/[\s-]/g, '').toUpperCase();

  // Check if code exists and hasn't been used
  const codeIndex = totpSecret.backupCodes.findIndex(
    bc => bc === normalizedCode && !totpSecret.usedBackupCodes.includes(bc)
  );

  if (codeIndex === -1) {
    return false;
  }

  // Mark code as used
  totpSecret.usedBackupCodes.push(totpSecret.backupCodes[codeIndex]);

  // Alert if running low on backup codes
  const remainingCodes = totpSecret.backupCodes.length - totpSecret.usedBackupCodes.length;
  if (remainingCodes <= 2) {
    logSecurityEvent({
      type: 'SUSPICIOUS_ACTIVITY',
      severity: 'low',
      userId,
      ipAddress: 'unknown',
      userAgent: 'unknown',
      details: {
        reason: 'Low on backup codes',
        remaining: remainingCodes
      }
    });
  }

  return true;
}

// Track failed verification attempts
function trackFailedAttempt(userId: string, ipAddress?: string): void {
  let attempts = verificationAttempts.get(userId);

  if (!attempts) {
    attempts = {
      userId,
      attempts: 0,
      lastAttempt: new Date()
    };
    verificationAttempts.set(userId, attempts);
  }

  attempts.attempts++;
  attempts.lastAttempt = new Date();

  // Lock account after max attempts
  if (attempts.attempts >= TOTP_CONFIG.maxAttempts) {
    attempts.lockedUntil = new Date(Date.now() + TOTP_CONFIG.lockoutDuration);

    logSecurityEvent({
      type: 'BRUTE_FORCE_ATTEMPT',
      severity: 'high',
      userId,
      ipAddress: ipAddress || 'unknown',
      userAgent: 'unknown',
      details: {
        reason: '2FA brute force detected',
        attempts: attempts.attempts
      }
    });
  }

  logAudit({
    userId,
    action: '2FA_VERIFY_FAIL',
    resource: `user/${userId}/2fa`,
    ipAddress: ipAddress || 'unknown',
    userAgent: 'unknown',
    success: false,
    metadata: { attempt: attempts.attempts }
  });
}

// Generate new backup codes
export function regenerateBackupCodes(userId: string): {
  success: boolean;
  backupCodes?: string[];
  error?: string;
} {
  const totpSecret = totpSecrets.get(userId);

  if (!totpSecret) {
    return { success: false, error: '2FA not enabled' };
  }

  const newBackupCodes = generateBackupCodes();
  totpSecret.backupCodes = [...newBackupCodes];
  totpSecret.usedBackupCodes = [];

  logAudit({
    userId,
    action: '2FA_BACKUP_REGENERATE',
    resource: `user/${userId}/2fa`,
    ipAddress: 'unknown',
    userAgent: 'unknown',
    success: true
  });

  return { success: true, backupCodes: newBackupCodes };
}

// Disable TOTP
export function disableTOTP(userId: string): { success: boolean } {
  const had2FA = totpSecrets.has(userId);

  totpSecrets.delete(userId);
  verificationAttempts.delete(userId);
  pendingSetup.delete(userId);

  if (had2FA) {
    logAudit({
      userId,
      action: '2FA_DISABLE',
      resource: `user/${userId}/2fa`,
      ipAddress: 'unknown',
      userAgent: 'unknown',
      success: true
    });
  }

  return { success: true };
}

// Check if user has 2FA enabled
export function has2FAEnabled(userId: string): boolean {
  return totpSecrets.has(userId);
}

// Get 2FA status
export function get2FAStatus(userId: string): {
  enabled: boolean;
  verified: boolean;
  backupCodesRemaining: number;
  lastUsed?: Date;
} {
  const totpSecret = totpSecrets.get(userId);

  if (!totpSecret) {
    return {
      enabled: false,
      verified: false,
      backupCodesRemaining: 0
    };
  }

  return {
    enabled: true,
    verified: totpSecret.verified,
    backupCodesRemaining: totpSecret.backupCodes.length - totpSecret.usedBackupCodes.length,
    lastUsed: totpSecret.lastUsed
  };
}

// Helper functions

// Generate backup codes
function generateBackupCodes(): string[] {
  const codes: string[] = [];

  for (let i = 0; i < TOTP_CONFIG.backupCodeCount; i++) {
    const code = crypto.randomBytes(TOTP_CONFIG.backupCodeLength)
      .toString('hex')
      .toUpperCase()
      .substring(0, TOTP_CONFIG.backupCodeLength);

    // Format as XXXX-XXXX for readability
    const formatted = code.match(/.{1,4}/g)?.join('-') || code;
    codes.push(formatted);
  }

  return codes;
}

// Verify TOTP code
function verifyTOTPCode(secret: string, code: string): boolean {
  const now = Math.floor(Date.now() / 1000);
  const period = TOTP_CONFIG.period;

  // Check current period and window
  for (let i = -TOTP_CONFIG.window; i <= TOTP_CONFIG.window; i++) {
    const time = now + (i * period);
    const counter = Math.floor(time / period);
    const generatedCode = generateTOTPCode(secret, counter);

    if (generatedCode === code) {
      return true;
    }
  }

  return false;
}

// Generate TOTP code
function generateTOTPCode(secret: string, counter: number): string {
  const decodedSecret = base32Decode(secret);
  const buffer = Buffer.alloc(8);
  buffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
  buffer.writeUInt32BE(counter & 0xffffffff, 4);

  const hmac = crypto.createHmac(TOTP_CONFIG.algorithm, decodedSecret);
  hmac.update(buffer);
  const digest = hmac.digest();

  const offset = digest[digest.length - 1] & 0x0f;
  const code = (
    ((digest[offset] & 0x7f) << 24) |
    ((digest[offset + 1] & 0xff) << 16) |
    ((digest[offset + 2] & 0xff) << 8) |
    (digest[offset + 3] & 0xff)
  ) % Math.pow(10, TOTP_CONFIG.digits);

  return code.toString().padStart(TOTP_CONFIG.digits, '0');
}

// Base32 encoding/decoding
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

function base32Encode(buffer: Buffer): string {
  let result = '';
  let bits = 0;
  let value = 0;

  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;

    while (bits >= 5) {
      result += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
      bits -= 5;
    }
  }

  if (bits > 0) {
    result += BASE32_ALPHABET[(value << (5 - bits)) & 31];
  }

  return result;
}

function base32Decode(str: string): Buffer {
  const buffer: number[] = [];
  let bits = 0;
  let value = 0;

  for (const char of str.toUpperCase()) {
    const index = BASE32_ALPHABET.indexOf(char);
    if (index === -1) continue;

    value = (value << 5) | index;
    bits += 5;

    if (bits >= 8) {
      buffer.push((value >>> (bits - 8)) & 255);
      bits -= 8;
    }
  }

  return Buffer.from(buffer);
}

// Cleanup old attempts
setInterval(() => {
  const now = new Date();
  for (const [userId, attempts] of verificationAttempts.entries()) {
    if (attempts.lockedUntil && now > attempts.lockedUntil) {
      verificationAttempts.delete(userId);
    }
  }
}, 60000); // Every minute

// Export configuration for testing
export { TOTP_CONFIG };