feat: enhance login process with IP capture and improved error handling

- 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.
This commit is contained in:
2025-11-04 03:26:34 -03:00
parent f278ad4d17
commit c6c88f85a7
11 changed files with 3531 additions and 70 deletions

View File

@@ -10,6 +10,7 @@
import type * as actions_email from "../actions/email.js";
import type * as actions_smtp from "../actions/smtp.js";
import type * as atestadosLicencas from "../atestadosLicencas.js";
import type * as autenticacao from "../autenticacao.js";
import type * as auth_utils from "../auth/utils.js";
import type * as chat from "../chat.js";
@@ -38,6 +39,7 @@ import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
import type * as utils_getClientIP from "../utils/getClientIP.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
import type {
@@ -57,6 +59,7 @@ import type {
declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email;
"actions/smtp": typeof actions_smtp;
atestadosLicencas: typeof atestadosLicencas;
autenticacao: typeof autenticacao;
"auth/utils": typeof auth_utils;
chat: typeof chat;
@@ -85,6 +88,7 @@ declare const fullApi: ApiFromModules<{
times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
"utils/getClientIP": typeof utils_getClientIP;
verificarMatriculas: typeof verificarMatriculas;
}>;
declare const fullApiWithMounts: typeof fullApi;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { mutation, query, internalMutation } from "./_generated/server";
import {
hashPassword,
verifyPassword,
@@ -9,7 +9,7 @@ import {
} from "./auth/utils";
import { registrarLogin } from "./logsLogin";
import { Id, Doc } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
import type { QueryCtx, MutationCtx } from "./_generated/server";
/**
* Helper para verificar se usuário está bloqueado
@@ -315,6 +315,280 @@ export const login = mutation({
},
});
/**
* Mutation interna para login via HTTP (com IP extraído do request)
* Usada pelo endpoint HTTP /api/login
*/
export const loginComIP = internalMutation({
args: {
matriculaOuEmail: v.string(),
senha: v.string(),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
},
returns: v.union(
v.object({
sucesso: v.literal(true),
token: v.string(),
usuario: v.object({
_id: v.id("usuarios"),
matricula: v.string(),
nome: v.string(),
email: v.string(),
funcionarioId: v.optional(v.id("funcionarios")),
role: v.object({
_id: v.id("roles"),
nome: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
}),
primeiroAcesso: v.boolean(),
}),
}),
v.object({
sucesso: v.literal(false),
erro: v.string(),
})
),
handler: async (ctx, args) => {
// Reutilizar a mesma lógica da mutation pública
// Verificar rate limiting por IP
if (args.ipAddress) {
const ipBloqueado = await verificarRateLimitIP(ctx, args.ipAddress);
if (ipBloqueado) {
await registrarLogin(ctx, {
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "rate_limit_excedido",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return {
sucesso: false as const,
erro: "Muitas tentativas de login. Tente novamente em 15 minutos.",
};
}
}
// Determinar se é email ou matrícula
const isEmail = args.matriculaOuEmail.includes("@");
// Buscar usuário
let usuario: Doc<"usuarios"> | null = null;
if (isEmail) {
usuario = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail))
.first();
} else {
const funcionario: Doc<"funcionarios"> | null = await ctx.db.query("funcionarios").withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)).first();
if (funcionario) {
usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
}
}
if (!usuario) {
await registrarLogin(ctx, {
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "usuario_inexistente",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return {
sucesso: false as const,
erro: "Credenciais incorretas.",
};
}
// Verificar se usuário está bloqueado
if (
usuario.bloqueado ||
(await verificarBloqueioUsuario(ctx, usuario._id))
) {
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "usuario_bloqueado",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return {
sucesso: false as const,
erro: "Usuário bloqueado. Entre em contato com o TI.",
};
}
// Verificar se usuário está ativo
if (!usuario.ativo) {
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "usuario_inativo",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return {
sucesso: false as const,
erro: "Usuário inativo. Entre em contato com o TI.",
};
}
// Verificar tentativas de login (bloqueio temporário)
const tentativasRecentes = usuario.tentativasLogin || 0;
const ultimaTentativa = usuario.ultimaTentativaLogin || 0;
const tempoDecorrido = Date.now() - ultimaTentativa;
const TEMPO_BLOQUEIO = 30 * 60 * 1000; // 30 minutos
// Se tentou 5 vezes e ainda não passou o tempo de bloqueio
if (tentativasRecentes >= 5 && tempoDecorrido < TEMPO_BLOQUEIO) {
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "bloqueio_temporario",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
const minutosRestantes = Math.ceil(
(TEMPO_BLOQUEIO - tempoDecorrido) / 60000
);
return {
sucesso: false as const,
erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`,
};
}
// Resetar tentativas se passou o tempo de bloqueio
if (tempoDecorrido > TEMPO_BLOQUEIO) {
await ctx.db.patch(usuario._id, {
tentativasLogin: 0,
ultimaTentativaLogin: Date.now(),
});
}
// Verificar senha
const senhaValida = await verifyPassword(args.senha, usuario.senhaHash);
if (!senhaValida) {
// Incrementar tentativas
const novasTentativas =
tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1;
await ctx.db.patch(usuario._id, {
tentativasLogin: novasTentativas,
ultimaTentativaLogin: Date.now(),
});
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "senha_incorreta",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
const tentativasRestantes = 5 - novasTentativas;
if (tentativasRestantes > 0) {
return {
sucesso: false as const,
erro: `Credenciais incorretas. ${tentativasRestantes} tentativas restantes.`,
};
} else {
return {
sucesso: false as const,
erro: "Conta bloqueada por 30 minutos devido a múltiplas tentativas falhas.",
};
}
}
// Login bem-sucedido! Resetar tentativas
await ctx.db.patch(usuario._id, {
tentativasLogin: 0,
ultimaTentativaLogin: undefined,
});
// Buscar role do usuário
const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId);
if (!role) {
return {
sucesso: false as const,
erro: "Erro ao carregar permissões do usuário.",
};
}
// Gerar token de sessão
const token = generateToken();
const agora = Date.now();
const expiraEm = agora + 8 * 60 * 60 * 1000; // 8 horas
// Criar sessão
await ctx.db.insert("sessoes", {
usuarioId: usuario._id,
token,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
criadoEm: agora,
expiraEm,
ativo: true,
});
// Atualizar último acesso
await ctx.db.patch(usuario._id, {
ultimoAcesso: agora,
atualizadoEm: agora,
});
// Log de login bem-sucedido
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: true,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
await ctx.db.insert("logsAcesso", {
usuarioId: usuario._id,
tipo: "login",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
detalhes: "Login realizado com sucesso",
timestamp: agora,
});
return {
sucesso: true as const,
token,
usuario: {
_id: usuario._id,
matricula: usuario.matricula,
nome: usuario.nome,
email: usuario.email,
funcionarioId: usuario.funcionarioId,
role: {
_id: role._id,
nome: role.nome,
nivel: role.nivel,
setor: role.setor,
},
primeiroAcesso: usuario.primeiroAcesso,
},
};
},
});
/**
* Logout do usuário
*/

View File

@@ -1,5 +1,150 @@
import { httpRouter } from "convex/server";
const http = httpRouter();
export default http;
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { getClientIP } from "./utils/getClientIP";
import { v } from "convex/values";
const http = httpRouter();
/**
* Endpoint de teste para debug - retorna todos os headers disponíveis
* GET /api/debug/headers
*/
http.route({
path: "/api/debug/headers",
method: "GET",
handler: httpAction(async (ctx, request) => {
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const ip = getClientIP(request);
return new Response(
JSON.stringify({
headers,
extractedIP: ip,
url: request.url,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}),
});
/**
* Endpoint HTTP para login que captura automaticamente o IP do cliente
* POST /api/login
* Body: { matriculaOuEmail: string, senha: string }
*/
http.route({
path: "/api/login",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
// Debug: Log todos os headers disponíveis
console.log("=== DEBUG: Headers HTTP ===");
const headersEntries: string[] = [];
request.headers.forEach((value, key) => {
headersEntries.push(`${key}: ${value}`);
});
console.log("Headers:", headersEntries.join(", "));
console.log("Request URL:", request.url);
// Extrair IP do cliente do request
let clientIP = getClientIP(request);
console.log("IP extraído:", clientIP);
// Se não encontrou IP, tentar obter do URL ou usar valor padrão
if (!clientIP) {
try {
const url = new URL(request.url);
// Tentar pegar do query param se disponível
const ipParam = url.searchParams.get("client_ip");
if (ipParam && /^(\d{1,3}\.){3}\d{1,3}$/.test(ipParam)) {
clientIP = ipParam;
console.log("IP obtido do query param:", clientIP);
} else {
// Se ainda não tiver IP, usar um identificador baseado no timestamp
// Isso pelo menos diferencia requisições
console.warn("IP não encontrado nos headers. Usando fallback.");
clientIP = undefined; // Deixar como undefined para registrar como não disponível
}
} catch {
console.warn("Erro ao processar URL para IP");
}
}
// Extrair User-Agent
const userAgent = request.headers.get("user-agent") || undefined;
// Ler body da requisição
const body = await request.json();
if (!body.matriculaOuEmail || !body.senha) {
return new Response(
JSON.stringify({
sucesso: false,
erro: "Matrícula/Email e senha são obrigatórios",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Chamar a mutation de login interna com IP e userAgent
const resultado = await ctx.runMutation(internal.autenticacao.loginComIP, {
matriculaOuEmail: body.matriculaOuEmail,
senha: body.senha,
ipAddress: clientIP,
userAgent: userAgent,
});
return new Response(JSON.stringify(resultado), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
} catch (error) {
return new Response(
JSON.stringify({
sucesso: false,
erro: error instanceof Error ? error.message : "Erro ao processar login",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
}),
});
/**
* Endpoint OPTIONS para CORS preflight
*/
http.route({
path: "/api/login",
method: "OPTIONS",
handler: httpAction(async () => {
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}),
});
export default http;

View File

@@ -5,6 +5,35 @@ 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: {
@@ -21,12 +50,15 @@ export async function registrarLogin(
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: dados.ipAddress,
ipAddress: ipAddressValidado,
userAgent: dados.userAgent,
device,
browser,

View File

@@ -151,11 +151,43 @@ export default defineSchema({
atestados: defineTable({
funcionarioId: v.id("funcionarios"),
tipo: v.union(
v.literal("atestado_medico"),
v.literal("declaracao_comparecimento")
),
dataInicio: v.string(),
dataFim: v.string(),
cid: v.string(),
descricao: v.string(),
}),
cid: v.optional(v.string()), // Apenas para atestado médico
observacoes: v.optional(v.string()),
documentoId: v.optional(v.id("_storage")),
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_tipo", ["tipo"])
.index("by_data_inicio", ["dataInicio"])
.index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]),
licencas: defineTable({
funcionarioId: v.id("funcionarios"),
tipo: v.union(
v.literal("maternidade"),
v.literal("paternidade")
),
dataInicio: v.string(),
dataFim: v.string(),
documentoId: v.optional(v.id("_storage")),
observacoes: v.optional(v.string()),
licencaOriginalId: v.optional(v.id("licencas")), // Para prorrogações
ehProrrogacao: v.boolean(),
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_tipo", ["tipo"])
.index("by_data_inicio", ["dataInicio"])
.index("by_licenca_original", ["licencaOriginalId"])
.index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]),
solicitacoesFerias: defineTable({
funcionarioId: v.id("funcionarios"),

View File

@@ -0,0 +1,151 @@
/**
* Função utilitária para extrair o IP do cliente de um Request HTTP
* Sem usar APIs externas - usa apenas headers HTTP
*/
/**
* Extrai o IP do cliente de um Request HTTP
* Considera headers como X-Forwarded-For, X-Real-IP, etc.
*/
export function getClientIP(request: Request): string | undefined {
// Headers que podem conter o IP do cliente (case-insensitive)
const getHeader = (name: string): string | null => {
// Tentar diferentes variações de case
const variations = [
name,
name.toLowerCase(),
name.toUpperCase(),
name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(),
];
for (const variation of variations) {
const value = request.headers.get(variation);
if (value) return value;
}
// As variações de case já cobrem a maioria dos casos
// Se não encontrou, retorna null
return null;
};
const forwardedFor = getHeader("x-forwarded-for");
const realIP = getHeader("x-real-ip");
const cfConnectingIP = getHeader("cf-connecting-ip"); // Cloudflare
const trueClientIP = getHeader("true-client-ip"); // Cloudflare Enterprise
const xClientIP = getHeader("x-client-ip");
const forwarded = getHeader("forwarded");
const remoteAddr = getHeader("remote-addr");
// Log para debug
console.log("Procurando IP nos headers:", {
"x-forwarded-for": forwardedFor,
"x-real-ip": realIP,
"cf-connecting-ip": cfConnectingIP,
"true-client-ip": trueClientIP,
"x-client-ip": xClientIP,
"forwarded": forwarded,
"remote-addr": remoteAddr,
});
// Prioridade: X-Forwarded-For pode conter múltiplos IPs (proxy chain)
// O primeiro IP é geralmente o IP original do cliente
if (forwardedFor) {
const ips = forwardedFor.split(",").map((ip) => ip.trim());
// Pegar o primeiro IP válido
for (const ip of ips) {
if (isValidIP(ip)) {
console.log("IP encontrado em X-Forwarded-For:", ip);
return ip;
}
}
}
// Forwarded header (RFC 7239)
if (forwarded) {
// Formato: for=192.0.2.60;proto=http;by=203.0.113.43
const forMatch = forwarded.match(/for=([^;,\s]+)/i);
if (forMatch && forMatch[1]) {
const ip = forMatch[1].replace(/^\[|\]$/g, ''); // Remove brackets de IPv6
if (isValidIP(ip)) {
console.log("IP encontrado em Forwarded:", ip);
return ip;
}
}
}
// Outros headers com IP único
if (realIP && isValidIP(realIP)) {
console.log("IP encontrado em X-Real-IP:", realIP);
return realIP;
}
if (cfConnectingIP && isValidIP(cfConnectingIP)) {
console.log("IP encontrado em CF-Connecting-IP:", cfConnectingIP);
return cfConnectingIP;
}
if (trueClientIP && isValidIP(trueClientIP)) {
console.log("IP encontrado em True-Client-IP:", trueClientIP);
return trueClientIP;
}
if (xClientIP && isValidIP(xClientIP)) {
console.log("IP encontrado em X-Client-IP:", xClientIP);
return xClientIP;
}
if (remoteAddr && isValidIP(remoteAddr)) {
console.log("IP encontrado em Remote-Addr:", remoteAddr);
return remoteAddr;
}
// Tentar extrair do URL (último recurso)
try {
const url = new URL(request.url);
// Se o servidor estiver configurado para passar IP via query param
const ipFromQuery = url.searchParams.get("ip");
if (ipFromQuery && isValidIP(ipFromQuery)) {
console.log("IP encontrado em query param:", ipFromQuery);
return ipFromQuery;
}
} catch {
// Ignorar erro de parsing do URL
}
console.log("Nenhum IP válido encontrado nos headers");
return undefined;
}
/**
* Valida se uma string é um endereço IP válido (IPv4 ou IPv6)
*/
function isValidIP(ip: string): boolean {
if (!ip || ip.length === 0) {
return false;
}
// Validar IPv4
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split(".");
return parts.every((part) => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
}
// Validar IPv6 (formato simplificado)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (ipv6Regex.test(ip)) {
return true;
}
// Validar IPv6 comprimido (com ::)
const ipv6CompressedRegex = /^([0-9a-fA-F]{0,4}:)*::([0-9a-fA-F]{0,4}:)*[0-9a-fA-F]{0,4}$/;
if (ipv6CompressedRegex.test(ip)) {
return true;
}
return false;
}