/** * Audit Logging System * Tracks security-relevant events for incident response and forensics */ import { ConnectionFactory } from "./database"; import { v4 as uuid } from "uuid"; /** * Audit event types for security tracking */ export type AuditEventType = // Authentication events | "auth.login.success" | "auth.login.failed" | "auth.logout" | "auth.register.success" | "auth.register.failed" // Password events | "auth.password.change" | "auth.password.reset.request" | "auth.password.reset.complete" // Email verification | "auth.email.verify.request" | "auth.email.verify.complete" // OAuth events | "auth.oauth.github.success" | "auth.oauth.github.failed" | "auth.oauth.google.success" | "auth.oauth.google.failed" // Session management | "auth.session.revoke" | "auth.session.revokeAll" // Security events | "security.rate_limit.exceeded" | "security.csrf.failed" | "security.suspicious.activity" // Admin actions | "admin.action"; /** * Audit log entry structure */ export interface AuditLogEntry { userId?: string; eventType: AuditEventType; eventData?: Record; ipAddress?: string; userAgent?: string; success: boolean; } /** * Log security/audit event to database * Fire-and-forget - failures are logged to console but don't block operations * * @param entry - Audit log entry to record * @returns Promise that resolves when log is written (or fails silently) */ export async function logAuditEvent(entry: AuditLogEntry): Promise { try { const conn = ConnectionFactory(); await conn.execute({ sql: `INSERT INTO AuditLog (id, user_id, event_type, event_data, ip_address, user_agent, success) VALUES (?, ?, ?, ?, ?, ?, ?)`, args: [ uuid(), entry.userId || null, entry.eventType, entry.eventData ? JSON.stringify(entry.eventData) : null, entry.ipAddress || null, entry.userAgent || null, entry.success ? 1 : 0 ] }); } catch (error) { // Never throw - logging failures shouldn't break auth flows console.error("Failed to write audit log:", error, entry); } } /** * Query parameters for audit log searches */ export interface AuditLogQuery { userId?: string; eventType?: AuditEventType; success?: boolean; ipAddress?: string; startDate?: Date; endDate?: Date; limit?: number; offset?: number; } /** * Query audit logs for security analysis * * @param query - Search parameters * @returns Array of audit log entries */ export async function queryAuditLogs( query: AuditLogQuery ): Promise>> { const conn = ConnectionFactory(); let sql = "SELECT * FROM AuditLog WHERE 1=1"; const args: any[] = []; if (query.userId) { sql += " AND user_id = ?"; args.push(query.userId); } if (query.eventType) { sql += " AND event_type = ?"; args.push(query.eventType); } if (query.success !== undefined) { sql += " AND success = ?"; args.push(query.success ? 1 : 0); } if (query.ipAddress) { sql += " AND ip_address = ?"; args.push(query.ipAddress); } if (query.startDate) { sql += " AND created_at >= ?"; args.push( typeof query.startDate === "string" ? query.startDate : query.startDate.toISOString() ); } if (query.endDate) { sql += " AND created_at <= ?"; args.push( typeof query.endDate === "string" ? query.endDate : query.endDate.toISOString() ); } sql += " ORDER BY created_at DESC"; if (query.limit) { sql += " LIMIT ?"; args.push(query.limit); } if (query.offset) { sql += " OFFSET ?"; args.push(query.offset); } const result = await conn.execute({ sql, args }); return result.rows.map((row) => ({ id: row.id, userId: row.user_id, eventType: row.event_type, eventData: row.event_data ? JSON.parse(row.event_data as string) : null, ipAddress: row.ip_address, userAgent: row.user_agent, success: row.success === 1, createdAt: row.created_at })); } /** * Get recent failed login attempts for a user or IP address * Can also be used to query all recent failed login attempts * * @param identifierOrHours - User ID, IP address, or number of hours to look back * @param identifierTypeOrLimit - Type of identifier ('user_id' or 'ip_address'), or limit for aggregate query * @param withinMinutes - Time window to check (default: 15 minutes) - only used for specific identifier queries * @returns Count of failed login attempts, or array of attempts for aggregate queries */ export async function getFailedLoginAttempts( identifierOrHours: string | number, identifierTypeOrLimit?: "user_id" | "ip_address" | number, withinMinutes: number = 15 ): Promise>> { const conn = ConnectionFactory(); // Aggregate query: getFailedLoginAttempts(24, 100) - get all failed logins in last 24 hours if ( typeof identifierOrHours === "number" && typeof identifierTypeOrLimit === "number" ) { const hours = identifierOrHours; const limit = identifierTypeOrLimit; const result = await conn.execute({ sql: `SELECT * FROM AuditLog WHERE event_type = 'auth.login.failed' AND success = 0 AND created_at >= datetime('now', '-${hours} hours') ORDER BY created_at DESC LIMIT ?`, args: [limit] }); return result.rows.map((row) => ({ id: row.id, user_id: row.user_id, event_type: row.event_type, event_data: row.event_data ? JSON.parse(row.event_data as string) : null, ip_address: row.ip_address, user_agent: row.user_agent, success: row.success, created_at: row.created_at })); } // Specific identifier query: getFailedLoginAttempts("user-123", "user_id", 15) const identifier = identifierOrHours as string; const identifierType = identifierTypeOrLimit as "user_id" | "ip_address"; const column = identifierType === "user_id" ? "user_id" : "ip_address"; const result = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE ${column} = ? AND event_type = 'auth.login.failed' AND success = 0 AND created_at >= datetime('now', '-${withinMinutes} minutes')`, args: [identifier] }); return (result.rows[0]?.count as number) || 0; } /** * Get security summary for a user * * @param userId - User ID to get summary for * @param days - Number of days to look back (default: 30) * @returns Security metrics for the user */ export async function getUserSecuritySummary( userId: string, days: number = 30 ): Promise<{ totalEvents: number; successfulEvents: number; failedEvents: number; eventTypes: string[]; uniqueIPs: string[]; totalLogins: number; failedLogins: number; lastLoginAt: string | null; lastLoginIp: string | null; uniqueIpCount: number; recentSessions: number; }> { const conn = ConnectionFactory(); // Get total events for user in time period const totalEventsResult = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0; // Get successful events const successfulEventsResult = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND success = 1 AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const successfulEvents = (successfulEventsResult.rows[0]?.count as number) || 0; // Get failed events const failedEventsResult = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND success = 0 AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0; // Get unique event types const eventTypesResult = await conn.execute({ sql: `SELECT DISTINCT event_type FROM AuditLog WHERE user_id = ? AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const eventTypes = eventTypesResult.rows.map( (row) => row.event_type as string ); // Get unique IPs const uniqueIPsResult = await conn.execute({ sql: `SELECT DISTINCT ip_address FROM AuditLog WHERE user_id = ? AND ip_address IS NOT NULL AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string); // Get total successful logins const loginResult = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.success' AND success = 1`, args: [userId] }); const totalLogins = (loginResult.rows[0]?.count as number) || 0; // Get failed login attempts const failedResult = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.failed' AND success = 0 AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const failedLogins = (failedResult.rows[0]?.count as number) || 0; // Get last login info const lastLoginResult = await conn.execute({ sql: `SELECT created_at, ip_address FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.success' AND success = 1 ORDER BY created_at DESC LIMIT 1`, args: [userId] }); const lastLogin = lastLoginResult.rows[0]; // Get unique IP count const ipResult = await conn.execute({ sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.success' AND success = 1 AND created_at >= datetime('now', '-${days} days')`, args: [userId] }); const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0; // Get recent sessions (last 24 hours) const sessionResult = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.success' AND success = 1 AND created_at >= datetime('now', '-1 day')`, args: [userId] }); const recentSessions = (sessionResult.rows[0]?.count as number) || 0; return { totalEvents, successfulEvents, failedEvents, eventTypes, uniqueIPs, totalLogins, failedLogins, lastLoginAt: lastLogin?.created_at as string | null, lastLoginIp: lastLogin?.ip_address as string | null, uniqueIpCount, recentSessions }; } /** * Detect suspicious activity patterns * Can detect for a specific user or aggregate suspicious IPs * * @param userIdOrHours - User ID or number of hours to look back for aggregate query * @param currentIpOrMinAttempts - Current IP address or minimum attempts threshold for aggregate query * @returns Suspicion result for user, or array of suspicious IPs for aggregate query */ export async function detectSuspiciousActivity( userIdOrHours: string | number, currentIpOrMinAttempts?: string | number ): Promise< | { isSuspicious: boolean; reasons: string[]; } | Array<{ ipAddress: string; failedAttempts: number; uniqueEmails: number; }> > { const conn = ConnectionFactory(); // Aggregate query: detectSuspiciousActivity(24, 5) - find IPs with 5+ failed attempts in 24 hours if ( typeof userIdOrHours === "number" && typeof currentIpOrMinAttempts === "number" ) { const hours = userIdOrHours; const minAttempts = currentIpOrMinAttempts; const result = await conn.execute({ sql: `SELECT ip_address, COUNT(*) as failed_attempts, COUNT(DISTINCT json_extract(event_data, '$.email')) as unique_emails FROM AuditLog WHERE event_type = 'auth.login.failed' AND success = 0 AND ip_address IS NOT NULL AND created_at >= datetime('now', '-${hours} hours') GROUP BY ip_address HAVING COUNT(*) >= ? ORDER BY failed_attempts DESC`, args: [minAttempts] }); return result.rows.map((row) => ({ ipAddress: row.ip_address as string, failedAttempts: row.failed_attempts as number, uniqueEmails: row.unique_emails as number })); } // User-specific query: detectSuspiciousActivity("user-123", "192.168.1.1") const userId = userIdOrHours as string; const currentIp = currentIpOrMinAttempts as string; const reasons: string[] = []; // Check for excessive failed logins const failedAttempts = (await getFailedLoginAttempts( userId, "user_id", 15 )) as number; if (failedAttempts >= 3) { reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`); } // Check for rapid location changes (different IPs in short time) const recentIps = await conn.execute({ sql: `SELECT DISTINCT ip_address FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.success' AND success = 1 AND created_at >= datetime('now', '-1 hour')`, args: [userId] }); if (recentIps.rows.length >= 3) { reasons.push( `Logins from ${recentIps.rows.length} different IPs in last hour` ); } // Check for new IP if user has login history const ipHistory = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND ip_address = ? AND event_type = 'auth.login.success' AND success = 1`, args: [userId, currentIp] }); const hasUsedIpBefore = (ipHistory.rows[0]?.count as number) > 0; if (!hasUsedIpBefore) { const totalLogins = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? AND event_type = 'auth.login.success' AND success = 1`, args: [userId] }); if ((totalLogins.rows[0]?.count as number) > 0) { reasons.push("Login from new IP address"); } } return { isSuspicious: reasons.length > 0, reasons }; } /** * Clean up old audit logs (for maintenance/GDPR compliance) * * @param olderThanDays - Delete logs older than this many days * @returns Number of logs deleted */ export async function cleanupOldLogs(olderThanDays: number): Promise { const conn = ConnectionFactory(); const result = await conn.execute({ sql: `DELETE FROM AuditLog WHERE created_at < datetime('now', '-${olderThanDays} days') RETURNING id`, args: [] }); return result.rows.length; }