Files
sgse-app/packages/backend/convex/logsLogin.ts

568 lines
15 KiB
TypeScript

import { v } from "convex/values";
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
/**
* Obtém geolocalização aproximada por IP usando serviço externo
* Similar ao sistema de ponto
*/
async function obterGeoPorIP(ipAddress: string): Promise<{
latitude: number;
longitude: number;
cidade?: string;
estado?: string;
pais?: string;
endereco?: string;
} | null> {
try {
// Usar ipapi.co (gratuito, sem chave para uso limitado)
const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, {
headers: {
'User-Agent': 'SGSE-App/1.0'
}
});
if (response.ok) {
const data = (await response.json()) as {
latitude?: number;
longitude?: number;
city?: string;
region?: string;
country_name?: string;
error?: boolean;
};
if (!data.error && data.latitude && data.longitude) {
// Montar endereço completo
const partesEndereco: string[] = [];
if (data.city) partesEndereco.push(data.city);
if (data.region) partesEndereco.push(data.region);
if (data.country_name) partesEndereco.push(data.country_name);
const endereco = partesEndereco.length > 0 ? partesEndereco.join(', ') : undefined;
return {
latitude: data.latitude,
longitude: data.longitude,
cidade: data.city,
estado: data.region,
pais: data.country_name,
endereco
};
}
}
} catch (error) {
console.warn('Erro ao obter geolocalização por IP:', error);
}
return null;
}
/**
* 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);
// Obter geolocalização por IP se disponível (de forma assíncrona para não bloquear)
let geolocalizacao: {
latitude?: number;
longitude?: number;
cidade?: string;
estado?: string;
pais?: string;
endereco?: string;
} | null = null;
if (ipAddressValidado) {
// Obter geolocalização por IP (não bloquear se falhar)
try {
geolocalizacao = await obterGeoPorIP(ipAddressValidado);
} catch (error) {
console.warn('Erro ao obter geolocalização por IP:', error);
// Continuar sem localização se houver erro
}
}
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,
// Informações de Localização
latitude: geolocalizacao?.latitude,
longitude: geolocalizacao?.longitude,
cidade: geolocalizacao?.cidade,
estado: geolocalizacao?.estado,
pais: geolocalizacao?.pais,
endereco: geolocalizacao?.endereco,
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";
}
/**
* Mutation pública para registrar tentativa de login
* Pode ser chamada do frontend após login bem-sucedido ou falho
*/
export const registrarTentativaLogin = mutation({
args: {
usuarioId: v.optional(v.id("usuarios")),
matriculaOuEmail: v.string(),
sucesso: v.boolean(),
motivoFalha: v.optional(v.string()),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
},
handler: async (ctx, args) => {
await registrarLogin(ctx, {
usuarioId: args.usuarioId,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: args.sucesso,
motivoFalha: args.motivoFalha,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return { success: true };
},
});
/**
* 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<string, number> = {};
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<string, { sucesso: number; falha: number }> = {};
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<number, number> = {};
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<string, number> = {};
logs
.filter((l) => l.sucesso)
.forEach((log) => {
if (log.browser) {
porBrowser[log.browser] = (porBrowser[log.browser] || 0) + 1;
}
});
// Dispositivos mais usados
const porDevice: Record<string, number> = {};
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
};
},
});