- Implemented an internal mutation for login that captures the user's IP address and user agent for better security and tracking. - Enhanced the HTTP login endpoint to extract and log client IP, improving the overall authentication process. - Added validation for IP addresses to ensure only valid formats are recorded, enhancing data integrity. - Updated the login mutation to handle rate limiting and user status checks more effectively, providing clearer feedback on login attempts.
428 lines
10 KiB
TypeScript
428 lines
10 KiB
TypeScript
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(),
|
|
});
|
|
}
|
|
|
|
// 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<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
|
|
};
|
|
},
|
|
});
|