import { v } from "convex/values"; import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; /** * Helper para registrar tentativas de login */ /** * Valida se uma string é um IP válido */ function validarIP(ip: string | undefined): string | undefined { if (!ip || ip.length < 7) return undefined; // IP mínimo: "1.1.1.1" = 7 chars // Validar IPv4 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; if (ipv4Regex.test(ip)) { const parts = ip.split('.'); if (parts.length === 4 && parts.every(part => { const num = parseInt(part, 10); return !isNaN(num) && num >= 0 && num <= 255; })) { return ip; } } // Validar IPv6 básico const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,6}$|^[0-9a-fA-F]{0,4}::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,5}$/; if (ipv6Regex.test(ip)) { return ip; } // IP inválido - não salvar console.warn(`IP inválido detectado e ignorado: "${ip}"`); return undefined; } export async function registrarLogin( ctx: MutationCtx, dados: { usuarioId?: Id<"usuarios">; matriculaOuEmail: string; sucesso: boolean; motivoFalha?: string; ipAddress?: string; userAgent?: string; } ) { // Extrair informações do userAgent const device = dados.userAgent ? extrairDevice(dados.userAgent) : undefined; const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined; const sistema = dados.userAgent ? extrairSistema(dados.userAgent) : undefined; // Validar e sanitizar IP antes de salvar const ipAddressValidado = validarIP(dados.ipAddress); await ctx.db.insert("logsLogin", { usuarioId: dados.usuarioId, matriculaOuEmail: dados.matriculaOuEmail, sucesso: dados.sucesso, motivoFalha: dados.motivoFalha, ipAddress: ipAddressValidado, userAgent: dados.userAgent, device, browser, sistema, timestamp: Date.now(), }); // Detecção automática de brute force após login falho // Verificar se há múltiplas tentativas falhas do mesmo IP if (!dados.sucesso && ipAddressValidado) { const minutosAtras = 15; const dataLimite = Date.now() - minutosAtras * 60 * 1000; // Contar tentativas falhas recentes do mesmo IP const tentativasFalhas = await ctx.db .query("logsLogin") .withIndex("by_ip", (q) => q.eq("ipAddress", ipAddressValidado)) .filter((q) => q.gte(q.field("timestamp"), dataLimite) && q.eq(q.field("sucesso"), false) ) .collect(); // Se houver 5 ou mais tentativas falhas, registrar evento de segurança if (tentativasFalhas.length >= 5) { // Importar função de segurança dinamicamente para evitar dependência circular const { internal } = await import("./_generated/api"); try { await ctx.scheduler.runAfter(0, internal.security.detectarBruteForce, { ipAddress: ipAddressValidado, janelaMinutos: minutosAtras }); } catch (error) { // Log erro mas não bloqueia o registro de login console.error("Erro ao agendar detecção de brute force:", error); } } } } // Helpers para extrair informações do userAgent function extrairDevice(userAgent: string): string { const ua = userAgent.toLowerCase(); // Detectar dispositivos móveis primeiro if (/mobile|android|iphone|ipod|blackberry|opera mini|iemobile|wpdesktop/i.test(ua)) { // Verificar se é tablet if (/ipad|tablet|playbook|silk|(android(?!.*mobile))/i.test(ua)) { return "Tablet"; } return "Mobile"; } // Detectar outros dispositivos if (/smart-tv|smarttv|googletv|appletv|roku|chromecast/i.test(ua)) { return "Smart TV"; } if (/watch|wear/i.test(ua)) { return "Smart Watch"; } // Padrão: Desktop return "Desktop"; } function extrairBrowser(userAgent: string): string { const ua = userAgent.toLowerCase(); // Ordem de detecção é importante (Edge deve vir antes de Chrome) if (/edgios/i.test(ua)) { return "Edge iOS"; } if (/edg/i.test(ua)) { // Extrair versão do Edge const match = ua.match(/edg[e\/]([\d.]+)/i); return match ? `Edge ${match[1]}` : "Edge"; } if (/opr|opera/i.test(ua)) { const match = ua.match(/(?:opr|opera)[\/\s]([\d.]+)/i); return match ? `Opera ${match[1]}` : "Opera"; } if (/chrome|crios/i.test(ua) && !/edg|opr|opera/i.test(ua)) { const match = ua.match(/chrome[/\s]([\d.]+)/i); return match ? `Chrome ${match[1]}` : "Chrome"; } if (/firefox|fxios/i.test(ua)) { const match = ua.match(/firefox[/\s]([\d.]+)/i); return match ? `Firefox ${match[1]}` : "Firefox"; } if (/safari/i.test(ua) && !/chrome|crios|android/i.test(ua)) { const match = ua.match(/version[/\s]([\d.]+)/i); return match ? `Safari ${match[1]}` : "Safari"; } if (/msie|trident/i.test(ua)) { const match = ua.match(/(?:msie |rv:)([\d.]+)/i); return match ? `Internet Explorer ${match[1]}` : "Internet Explorer"; } if (/samsungbrowser/i.test(ua)) { return "Samsung Internet"; } if (/ucbrowser/i.test(ua)) { return "UC Browser"; } if (/micromessenger/i.test(ua)) { return "WeChat"; } if (/baiduboxapp/i.test(ua)) { return "Baidu Browser"; } return "Desconhecido"; } function extrairSistema(userAgent: string): string { const ua = userAgent.toLowerCase(); // Windows if (/windows nt 10.0/i.test(ua)) { return "Windows 10/11"; } if (/windows nt 6.3/i.test(ua)) { return "Windows 8.1"; } if (/windows nt 6.2/i.test(ua)) { return "Windows 8"; } if (/windows nt 6.1/i.test(ua)) { return "Windows 7"; } if (/windows nt 6.0/i.test(ua)) { return "Windows Vista"; } if (/windows nt 5.1/i.test(ua)) { return "Windows XP"; } if (/windows/i.test(ua)) { return "Windows"; } // macOS if (/macintosh|mac os x/i.test(ua)) { const match = ua.match(/mac os x ([\d_]+)/i); if (match) { const version = match[1].replace(/_/g, '.'); return `macOS ${version}`; } return "macOS"; } // iOS if (/iphone|ipad|ipod/i.test(ua)) { const match = ua.match(/os ([\d_]+)/i); if (match) { const version = match[1].replace(/_/g, '.'); return `iOS ${version}`; } return "iOS"; } // Android if (/android/i.test(ua)) { const match = ua.match(/android ([\d.]+)/i); if (match) { return `Android ${match[1]}`; } return "Android"; } // Linux if (/linux/i.test(ua)) { // Tentar identificar distribuição if (/ubuntu/i.test(ua)) { return "Ubuntu"; } if (/debian/i.test(ua)) { return "Debian"; } if (/fedora/i.test(ua)) { return "Fedora"; } if (/centos/i.test(ua)) { return "CentOS"; } if (/redhat/i.test(ua)) { return "Red Hat"; } if (/suse/i.test(ua)) { return "SUSE"; } return "Linux"; } // Chrome OS if (/cros/i.test(ua)) { return "Chrome OS"; } // BlackBerry if (/blackberry/i.test(ua)) { return "BlackBerry OS"; } // Windows Phone if (/windows phone/i.test(ua)) { return "Windows Phone"; } return "Desconhecido"; } /** * Lista histórico de logins de um usuário */ export const listarLoginsUsuario = query({ args: { usuarioId: v.id("usuarios"), limite: v.optional(v.number()), }, handler: async (ctx, args) => { const logs = await ctx.db .query("logsLogin") .withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId)) .order("desc") .take(args.limite || 50); return logs; }, }); /** * Lista todos os logins do sistema */ export const listarTodosLogins = query({ args: { limite: v.optional(v.number()), }, handler: async (ctx, args) => { const logs = await ctx.db .query("logsLogin") .withIndex("by_timestamp") .order("desc") .take(args.limite || 50); return logs; }, }); /** * Lista tentativas de login falhadas */ export const listarTentativasFalhas = query({ args: { horasAtras: v.optional(v.number()), // padrão 24h limite: v.optional(v.number()), }, handler: async (ctx, args) => { const horasAtras = args.horasAtras || 24; const dataLimite = Date.now() - horasAtras * 60 * 60 * 1000; const logs = await ctx.db .query("logsLogin") .withIndex("by_sucesso", (q) => q.eq("sucesso", false)) .filter((q) => q.gte(q.field("timestamp"), dataLimite)) .order("desc") .take(args.limite || 100); // Agrupar por IP para detectar possíveis ataques const porIP: Record = {}; logs.forEach((log) => { if (log.ipAddress) { porIP[log.ipAddress] = (porIP[log.ipAddress] || 0) + 1; } }); return { logs, tentativasPorIP: porIP, total: logs.length, }; }, }); /** * Obtém estatísticas de login */ export const obterEstatisticasLogin = query({ args: { dias: v.optional(v.number()), // padrão 30 dias }, handler: async (ctx, args) => { const dias = args.dias || 30; const dataInicio = Date.now() - dias * 24 * 60 * 60 * 1000; const logs = await ctx.db .query("logsLogin") .withIndex("by_timestamp") .filter((q) => q.gte(q.field("timestamp"), dataInicio)) .collect(); // Total de logins bem-sucedidos vs falhos const sucessos = logs.filter((l) => l.sucesso).length; const falhas = logs.filter((l) => !l.sucesso).length; // Logins por dia const porDia: Record = {}; logs.forEach((log) => { const data = new Date(log.timestamp); const dia = data.toISOString().split("T")[0]; if (!porDia[dia]) { porDia[dia] = { sucesso: 0, falha: 0 }; } if (log.sucesso) { porDia[dia].sucesso++; } else { porDia[dia].falha++; } }); // Logins por horário (hora do dia) const porHorario: Record = {}; logs .filter((l) => l.sucesso) .forEach((log) => { const hora = new Date(log.timestamp).getHours(); porHorario[hora] = (porHorario[hora] || 0) + 1; }); // Browser mais usado const porBrowser: Record = {}; logs .filter((l) => l.sucesso) .forEach((log) => { if (log.browser) { porBrowser[log.browser] = (porBrowser[log.browser] || 0) + 1; } }); // Dispositivos mais usados const porDevice: Record = {}; logs .filter((l) => l.sucesso) .forEach((log) => { if (log.device) { porDevice[log.device] = (porDevice[log.device] || 0) + 1; } }); return { total: logs.length, sucessos, falhas, taxaSucesso: logs.length > 0 ? (sucessos / logs.length) * 100 : 0, porDia, porHorario, porBrowser, porDevice, }; }, }); /** * Verifica se um IP está sendo suspeito (muitas tentativas falhas) */ export const verificarIPSuspeito = query({ args: { ipAddress: v.string(), minutosAtras: v.optional(v.number()), // padrão 15 minutos }, handler: async (ctx, args) => { const minutosAtras = args.minutosAtras || 15; const dataLimite = Date.now() - minutosAtras * 60 * 1000; const tentativas = await ctx.db .query("logsLogin") .withIndex("by_ip", (q) => q.eq("ipAddress", args.ipAddress)) .filter((q) => q.gte(q.field("timestamp"), dataLimite)) .collect(); const falhas = tentativas.filter((t) => !t.sucesso).length; return { tentativasTotal: tentativas.length, tentativasFalhas: falhas, suspeito: falhas >= 5, // 5 ou mais tentativas falhas em 15 minutos }; }, });