← back to Wine Finder Next

lib/sessionManagement.ts

455 lines

// Session Management System
// Secure session handling with Redis-like in-memory store

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

interface Session {
  id: string;
  userId: string;
  ipAddress: string;
  userAgent: string;
  fingerprint: string;
  createdAt: Date;
  lastActivity: Date;
  expiresAt: Date;
  refreshToken?: string;
  metadata: {
    deviceType?: string;
    browser?: string;
    os?: string;
    location?: string;
  };
  flags: {
    suspicious: boolean;
    requiresReauth: boolean;
    mfaVerified: boolean;
  };
}

interface SessionActivity {
  sessionId: string;
  timestamp: Date;
  action: string;
  ipAddress: string;
  userAgent: string;
}

// Session storage - in production use Redis
const sessionStore = new Map<string, Session>();
const sessionActivities = new Map<string, SessionActivity[]>();
const userSessions = new Map<string, Set<string>>(); // userId -> sessionIds

// Configuration
const SESSION_CONFIG = {
  sessionTimeout: 30 * 60 * 1000, // 30 minutes inactivity
  absoluteTimeout: 24 * 60 * 60 * 1000, // 24 hours absolute
  maxConcurrentSessions: 5,
  requireConsistentIP: false, // Can be strict for high-security
  requireConsistentUA: true,
  refreshTokenLifetime: 7 * 24 * 60 * 60 * 1000, // 7 days
  cleanupInterval: 5 * 60 * 1000, // 5 minutes
  suspiciousActivityThreshold: 10 // actions per minute
};

// Create new session
export function createSession(
  userId: string,
  ipAddress: string,
  userAgent: string,
  fingerprint?: string
): { session: Session; token: string } {
  // Check concurrent sessions limit
  const existingSessions = userSessions.get(userId) || new Set();
  if (existingSessions.size >= SESSION_CONFIG.maxConcurrentSessions) {
    // Revoke oldest session
    const oldestSession = Array.from(existingSessions)[0];
    revokeSession(oldestSession);
  }

  const sessionId = crypto.randomBytes(32).toString('hex');
  const token = crypto.randomBytes(32).toString('hex');
  const now = new Date();

  const session: Session = {
    id: sessionId,
    userId,
    ipAddress,
    userAgent,
    fingerprint: fingerprint || generateFingerprint(userAgent, ipAddress),
    createdAt: now,
    lastActivity: now,
    expiresAt: new Date(now.getTime() + SESSION_CONFIG.absoluteTimeout),
    refreshToken: crypto.randomBytes(32).toString('hex'),
    metadata: parseUserAgent(userAgent),
    flags: {
      suspicious: false,
      requiresReauth: false,
      mfaVerified: false
    }
  };

  // Store session
  sessionStore.set(token, session);

  // Track user sessions
  if (!userSessions.has(userId)) {
    userSessions.set(userId, new Set());
  }
  userSessions.get(userId)!.add(token);

  // Log session creation
  logAudit({
    userId,
    action: 'SESSION_CREATE',
    resource: `session/${sessionId}`,
    ipAddress,
    userAgent,
    success: true,
    metadata: { fingerprint: session.fingerprint }
  });

  return { session, token };
}

// Verify session
export function verifySession(
  token: string,
  ipAddress?: string,
  userAgent?: string
): { valid: boolean; session?: Session; reason?: string } {
  const session = sessionStore.get(token);

  if (!session) {
    return { valid: false, reason: 'Invalid session token' };
  }

  const now = new Date();

  // Check absolute expiration
  if (now > session.expiresAt) {
    revokeSession(token);
    return { valid: false, reason: 'Session expired (absolute timeout)' };
  }

  // Check inactivity timeout
  const inactivityTime = now.getTime() - session.lastActivity.getTime();
  if (inactivityTime > SESSION_CONFIG.sessionTimeout) {
    revokeSession(token);
    return { valid: false, reason: 'Session expired (inactivity)' };
  }

  // Verify consistency checks
  if (SESSION_CONFIG.requireConsistentIP && ipAddress && session.ipAddress !== ipAddress) {
    session.flags.suspicious = true;
    logSecurityEvent({
      type: 'ANOMALOUS_BEHAVIOR',
      severity: 'high',
      userId: session.userId,
      ipAddress: ipAddress,
      userAgent: userAgent || 'unknown',
      details: {
        reason: 'IP address changed during session',
        originalIP: session.ipAddress,
        newIP: ipAddress
      }
    });

    // Require re-authentication
    session.flags.requiresReauth = true;
    return { valid: false, reason: 'Security check failed - IP mismatch' };
  }

  if (SESSION_CONFIG.requireConsistentUA && userAgent && session.userAgent !== userAgent) {
    session.flags.suspicious = true;
    logSecurityEvent({
      type: 'ANOMALOUS_BEHAVIOR',
      severity: 'medium',
      userId: session.userId,
      ipAddress: ipAddress || session.ipAddress,
      userAgent: userAgent,
      details: {
        reason: 'User agent changed during session',
        originalUA: session.userAgent,
        newUA: userAgent
      }
    });
  }

  // Update last activity
  session.lastActivity = now;

  // Track activity
  trackSessionActivity(token, 'SESSION_VERIFY', ipAddress || session.ipAddress, userAgent || session.userAgent);

  return { valid: true, session };
}

// Refresh session
export function refreshSession(
  token: string,
  refreshToken: string
): { success: boolean; newToken?: string; reason?: string } {
  const session = sessionStore.get(token);

  if (!session) {
    return { success: false, reason: 'Invalid session' };
  }

  if (session.refreshToken !== refreshToken) {
    logSecurityEvent({
      type: 'INVALID_TOKEN',
      severity: 'high',
      userId: session.userId,
      ipAddress: session.ipAddress,
      userAgent: session.userAgent,
      details: { reason: 'Invalid refresh token attempt' }
    });
    return { success: false, reason: 'Invalid refresh token' };
  }

  // Generate new tokens
  const newToken = crypto.randomBytes(32).toString('hex');
  const newRefreshToken = crypto.randomBytes(32).toString('hex');

  // Update session
  session.refreshToken = newRefreshToken;
  session.lastActivity = new Date();

  // Move session to new token
  sessionStore.delete(token);
  sessionStore.set(newToken, session);

  // Update user sessions tracking
  const userSessionSet = userSessions.get(session.userId);
  if (userSessionSet) {
    userSessionSet.delete(token);
    userSessionSet.add(newToken);
  }

  trackSessionActivity(newToken, 'SESSION_REFRESH', session.ipAddress, session.userAgent);

  return { success: true, newToken };
}

// Revoke session
export function revokeSession(token: string): void {
  const session = sessionStore.get(token);

  if (session) {
    // Remove from user sessions
    const userSessionSet = userSessions.get(session.userId);
    if (userSessionSet) {
      userSessionSet.delete(token);
      if (userSessionSet.size === 0) {
        userSessions.delete(session.userId);
      }
    }

    // Log revocation
    logAudit({
      userId: session.userId,
      action: 'SESSION_REVOKE',
      resource: `session/${session.id}`,
      ipAddress: session.ipAddress,
      userAgent: session.userAgent,
      success: true
    });

    // Remove session
    sessionStore.delete(token);
    sessionActivities.delete(token);
  }
}

// Revoke all user sessions
export function revokeUserSessions(userId: string): number {
  const sessions = userSessions.get(userId) || new Set();
  const count = sessions.size;

  sessions.forEach(token => revokeSession(token));

  logAudit({
    userId,
    action: 'SESSION_REVOKE_ALL',
    resource: `user/${userId}/sessions`,
    ipAddress: 'system',
    userAgent: 'system',
    success: true,
    metadata: { revokedCount: count }
  });

  return count;
}

// Track session activity
function trackSessionActivity(
  sessionToken: string,
  action: string,
  ipAddress: string,
  userAgent: string
): void {
  const activity: SessionActivity = {
    sessionId: sessionToken,
    timestamp: new Date(),
    action,
    ipAddress,
    userAgent
  };

  if (!sessionActivities.has(sessionToken)) {
    sessionActivities.set(sessionToken, []);
  }

  const activities = sessionActivities.get(sessionToken)!;
  activities.push(activity);

  // Keep only last 100 activities
  if (activities.length > 100) {
    activities.shift();
  }

  // Check for suspicious activity
  const recentActivities = activities.filter(
    a => new Date().getTime() - a.timestamp.getTime() < 60000
  );

  if (recentActivities.length > SESSION_CONFIG.suspiciousActivityThreshold) {
    const session = sessionStore.get(sessionToken);
    if (session) {
      session.flags.suspicious = true;
      logSecurityEvent({
        type: 'ANOMALOUS_BEHAVIOR',
        severity: 'medium',
        userId: session.userId,
        ipAddress,
        userAgent,
        details: {
          reason: 'Excessive session activity',
          actionsPerMinute: recentActivities.length
        }
      });
    }
  }
}

// Generate device fingerprint
function generateFingerprint(userAgent: string, ipAddress: string): string {
  const data = `${userAgent}::${ipAddress}`;
  return crypto.createHash('sha256').update(data).digest('hex').substring(0, 16);
}

// Parse user agent for metadata
function parseUserAgent(userAgent: string): Session['metadata'] {
  const metadata: Session['metadata'] = {};

  // Simple parsing - in production use ua-parser-js or similar
  if (/mobile/i.test(userAgent)) {
    metadata.deviceType = 'mobile';
  } else if (/tablet/i.test(userAgent)) {
    metadata.deviceType = 'tablet';
  } else {
    metadata.deviceType = 'desktop';
  }

  if (/chrome/i.test(userAgent)) {
    metadata.browser = 'Chrome';
  } else if (/firefox/i.test(userAgent)) {
    metadata.browser = 'Firefox';
  } else if (/safari/i.test(userAgent)) {
    metadata.browser = 'Safari';
  } else if (/edge/i.test(userAgent)) {
    metadata.browser = 'Edge';
  }

  if (/windows/i.test(userAgent)) {
    metadata.os = 'Windows';
  } else if (/mac/i.test(userAgent)) {
    metadata.os = 'macOS';
  } else if (/linux/i.test(userAgent)) {
    metadata.os = 'Linux';
  } else if (/android/i.test(userAgent)) {
    metadata.os = 'Android';
  } else if (/ios/i.test(userAgent)) {
    metadata.os = 'iOS';
  }

  return metadata;
}

// Get session info
export function getSessionInfo(token: string): Session | undefined {
  return sessionStore.get(token);
}

// Get user sessions
export function getUserSessions(userId: string): Session[] {
  const sessionTokens = userSessions.get(userId) || new Set();
  const sessions: Session[] = [];

  sessionTokens.forEach(token => {
    const session = sessionStore.get(token);
    if (session) {
      sessions.push(session);
    }
  });

  return sessions.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
}

// Get session statistics
export function getSessionStats(): {
  totalSessions: number;
  activeSessions: number;
  suspiciousSessions: number;
  uniqueUsers: number;
  averageSessionDuration: number;
} {
  const now = new Date();
  let activeSessions = 0;
  let suspiciousSessions = 0;
  let totalDuration = 0;

  for (const session of sessionStore.values()) {
    const inactivityTime = now.getTime() - session.lastActivity.getTime();
    if (inactivityTime <= SESSION_CONFIG.sessionTimeout) {
      activeSessions++;
    }

    if (session.flags.suspicious) {
      suspiciousSessions++;
    }

    totalDuration += session.lastActivity.getTime() - session.createdAt.getTime();
  }

  return {
    totalSessions: sessionStore.size,
    activeSessions,
    suspiciousSessions,
    uniqueUsers: userSessions.size,
    averageSessionDuration: sessionStore.size > 0 ? totalDuration / sessionStore.size : 0
  };
}

// Cleanup expired sessions
function cleanupExpiredSessions(): void {
  const now = new Date();
  const tokensToDelete: string[] = [];

  for (const [token, session] of sessionStore.entries()) {
    if (now > session.expiresAt ||
        now.getTime() - session.lastActivity.getTime() > SESSION_CONFIG.sessionTimeout) {
      tokensToDelete.push(token);
    }
  }

  tokensToDelete.forEach(token => revokeSession(token));
}

// Run cleanup periodically
setInterval(cleanupExpiredSessions, SESSION_CONFIG.cleanupInterval);

// Export configuration for testing
export { SESSION_CONFIG };