568 lines
15 KiB
TypeScript
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
|
|
};
|
|
},
|
|
});
|