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

548 lines
13 KiB
TypeScript

import { v } from 'convex/values';
import { mutation, query, MutationCtx } from './_generated/server';
import { 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;
latitudeGPS?: number;
longitudeGPS?: number;
precisaoGPS?: number;
enderecoGPS?: string;
cidadeGPS?: string;
estadoGPS?: string;
paisGPS?: 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);
// Nota: Geolocalização por IP removida porque fetch() não pode ser usado em mutations do Convex
// A localização GPS já é coletada no frontend e enviada diretamente
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 por IP (removido - usar GPS do frontend)
latitude: undefined,
longitude: undefined,
cidade: undefined,
estado: undefined,
pais: undefined,
endereco: undefined,
// Informações de Localização (GPS do navegador)
latitudeGPS: dados.latitudeGPS,
longitudeGPS: dados.longitudeGPS,
precisaoGPS: dados.precisaoGPS,
enderecoGPS: dados.enderecoGPS,
cidadeGPS: dados.cidadeGPS,
estadoGPS: dados.estadoGPS,
paisGPS: dados.paisGPS,
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()),
latitudeGPS: v.optional(v.number()),
longitudeGPS: v.optional(v.number()),
precisaoGPS: v.optional(v.number()),
enderecoGPS: v.optional(v.string()),
cidadeGPS: v.optional(v.string()),
estadoGPS: v.optional(v.string()),
paisGPS: 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,
latitudeGPS: args.latitudeGPS,
longitudeGPS: args.longitudeGPS,
precisaoGPS: args.precisaoGPS,
enderecoGPS: args.enderecoGPS,
cidadeGPS: args.cidadeGPS,
estadoGPS: args.estadoGPS,
paisGPS: args.paisGPS
});
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);
// Buscar informações dos usuários quando disponível
const logsComUsuarios = await Promise.all(
logs.map(async (log) => {
let usuarioNome: string | undefined = undefined;
let usuarioEmail: string | undefined = undefined;
if (log.usuarioId) {
const usuario = await ctx.db.get(log.usuarioId);
if (usuario) {
usuarioNome = usuario.nome;
usuarioEmail = usuario.email;
}
}
return {
...log,
usuarioNome,
usuarioEmail
};
})
);
return logsComUsuarios;
}
});
/**
* 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
};
}
});