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

@@ -0,0 +1,189 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
interface Props {
value?: string; // Id do funcionário selecionado
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
let {
value = $bindable(),
placeholder = "Selecione um funcionário",
disabled = false,
required = false,
}: Props = $props();
let busca = $state("");
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const funcionarios = $derived(
funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []
);
// Filtrar funcionários baseado na busca
const funcionariosFiltrados = $derived.by(() => {
if (!busca.trim()) return funcionarios;
const termo = busca.toLowerCase().trim();
return funcionarios.filter((f) => {
const nomeMatch = f.nome?.toLowerCase().includes(termo);
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
const cpfMatch = f.cpf?.replace(/\D/g, "").includes(termo.replace(/\D/g, ""));
return nomeMatch || matriculaMatch || cpfMatch;
});
});
// Funcionário selecionado
const funcionarioSelecionado = $derived.by(() => {
if (!value) return null;
return funcionarios.find((f) => f._id === value);
});
function selecionarFuncionario(funcionarioId: string) {
value = funcionarioId;
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
busca = funcionario?.nome || "";
mostrarDropdown = false;
}
function limpar() {
value = undefined;
busca = "";
mostrarDropdown = false;
}
// Atualizar busca quando funcionário selecionado mudar externamente
$effect(() => {
if (value && !busca) {
const funcionario = funcionarios.find((f) => f._id === value);
busca = funcionario?.nome || "";
}
});
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
</script>
<div class="form-control w-full relative">
<label class="label">
<span class="label-text font-medium">
Funcionário
{#if required}
<span class="text-error">*</span>
{/if}
</span>
</label>
<div class="relative">
<input
type="text"
bind:value={busca}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
{#if value}
<button
type="button"
onclick={limpar}
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
disabled={disabled}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{:else}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/if}
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto"
>
{#each funcionariosFiltrados as funcionario}
<button
type="button"
onclick={() => selecionarFuncionario(funcionario._id)}
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors border-b border-base-200 last:border-b-0"
>
<div class="font-medium">{funcionario.nome}</div>
<div class="text-sm text-base-content/60">
{#if funcionario.matricula}
Matrícula: {funcionario.matricula}
{/if}
{#if funcionario.descricaoCargo}
{funcionario.matricula ? " • " : ""}
{funcionario.descricaoCargo}
{/if}
</div>
</button>
{/each}
</div>
{/if}
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
<div
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg p-4 text-center text-base-content/60"
>
Nenhum funcionário encontrado
</div>
{/if}
</div>
{#if funcionarioSelecionado}
<div class="text-xs text-base-content/60 mt-1">
Selecionado: {funcionarioSelecionado.nome}
{#if funcionarioSelecionado.matricula}
- {funcionarioSelecionado.matricula}
{/if}
</div>
{/if}
</div>

View File

@@ -101,7 +101,8 @@
carregandoLogin = true; carregandoLogin = true;
try { try {
// Capturar informações do navegador // Usar mutation normal com WebRTC para capturar IP
// getBrowserInfo() tenta obter o IP local via WebRTC
const browserInfo = await getBrowserInfo(); const browserInfo = await getBrowserInfo();
const resultado = await convex.mutation(api.autenticacao.login, { const resultado = await convex.mutation(api.autenticacao.login, {

View File

@@ -14,7 +14,64 @@ export function getUserAgent(): string {
} }
/** /**
* Tenta obter o IP local usando WebRTC * Valida se uma string tem formato de IP válido
*/
function isValidIPFormat(ip: string): boolean {
if (!ip || ip.length < 7) return false; // 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('.');
return parts.length === 4 && parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
});
}
// Validar IPv6 básico (formato simplificado)
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 true;
}
return false;
}
/**
* Verifica se um IP é local/privado
*/
function isLocalIP(ip: string): boolean {
// IPs locais/privados
return (
ip.startsWith('127.') ||
ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('172.23.') ||
ip.startsWith('172.24.') ||
ip.startsWith('172.25.') ||
ip.startsWith('172.26.') ||
ip.startsWith('172.27.') ||
ip.startsWith('172.28.') ||
ip.startsWith('172.29.') ||
ip.startsWith('172.30.') ||
ip.startsWith('172.31.') ||
ip.startsWith('169.254.') || // Link-local
ip === '::1' ||
ip.startsWith('fe80:') // IPv6 link-local
);
}
/**
* Tenta obter o IP usando WebRTC
* Prioriza IP público, mas retorna IP local se não encontrar
* Esta função não usa API externa, mas pode falhar em alguns navegadores * Esta função não usa API externa, mas pode falhar em alguns navegadores
* Retorna undefined se não conseguir obter * Retorna undefined se não conseguir obter
*/ */
@@ -32,32 +89,88 @@ export async function getLocalIP(): Promise<string | undefined> {
}); });
let resolved = false; let resolved = false;
let foundIPs: string[] = [];
let publicIP: string | undefined = undefined;
let localIP: string | undefined = undefined;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
pc.close(); pc.close();
resolve(undefined); // Priorizar IP público, mas retornar local se não houver
resolve(publicIP || localIP || undefined);
} }
}, 3000); }, 5000); // Aumentar timeout para 5 segundos
pc.onicecandidate = (event) => { pc.onicecandidate = (event) => {
if (event.candidate && !resolved) { if (event.candidate && !resolved) {
const candidate = event.candidate.candidate; const candidate = event.candidate.candidate;
// Regex para extrair IP local (IPv4)
const ipMatch = candidate.match(/([0-9]{1,3}(\.[0-9]{1,3}){3})/); // Regex mais rigorosa para IPv4 - deve ser um IP completo e válido
if (ipMatch && ipMatch[1]) { // Formato: X.X.X.X onde X é 0-255
const ip = ipMatch[1]; const ipv4Match = candidate.match(/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\b/);
// Verificar se não é IP localhost (127.0.0.1 ou ::1)
if (!ip.startsWith('127.') && !ip.startsWith('::1')) { // Regex para IPv6 - mais específica
const ipv6Match = candidate.match(/\b([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){2,7}|::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6}|[0-9a-fA-F]{1,4}::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,5})\b/);
let ip: string | undefined = undefined;
if (ipv4Match && ipv4Match[1]) {
const candidateIP = ipv4Match[1];
// Validar se cada octeto está entre 0-255
const parts = candidateIP.split('.');
if (parts.length === 4 && parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
})) {
ip = candidateIP;
}
} else if (ipv6Match && ipv6Match[1]) {
// Validar formato básico de IPv6
const candidateIP = ipv6Match[1];
if (candidateIP.includes(':') && candidateIP.length >= 3) {
ip = candidateIP;
}
}
// Validar se o IP é válido antes de processar
if (ip && isValidIPFormat(ip) && !foundIPs.includes(ip)) {
foundIPs.push(ip);
// Ignorar localhost
if (ip.startsWith('127.') || ip === '::1') {
return;
}
// Separar IPs públicos e locais
if (isLocalIP(ip)) {
if (!localIP) {
localIP = ip;
}
} else {
// IP público encontrado!
if (!publicIP) {
publicIP = ip;
// Se encontrou IP público, podemos resolver mais cedo
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
clearTimeout(timeout); clearTimeout(timeout);
pc.close(); pc.close();
resolve(ip); resolve(publicIP);
} }
} }
} }
} }
} else if (event.candidate === null) {
// No more candidates
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
// Retornar IP público se encontrou, senão local
resolve(publicIP || localIP || undefined);
}
}
}; };
// Criar um data channel para forçar a criação de candidatos // Criar um data channel para forçar a criação de candidatos
@@ -69,10 +182,11 @@ export async function getLocalIP(): Promise<string | undefined> {
resolved = true; resolved = true;
clearTimeout(timeout); clearTimeout(timeout);
pc.close(); pc.close();
resolve(undefined); resolve(publicIP || localIP || undefined);
} }
}); });
} catch (error) { } catch (error) {
console.warn("Erro ao obter IP via WebRTC:", error);
resolve(undefined); resolve(undefined);
} }
}); });

View File

@@ -10,6 +10,7 @@
import type * as actions_email from "../actions/email.js"; import type * as actions_email from "../actions/email.js";
import type * as actions_smtp from "../actions/smtp.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 autenticacao from "../autenticacao.js";
import type * as auth_utils from "../auth/utils.js"; import type * as auth_utils from "../auth/utils.js";
import type * as chat from "../chat.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 times from "../times.js";
import type * as todos from "../todos.js"; import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.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 * as verificarMatriculas from "../verificarMatriculas.js";
import type { import type {
@@ -57,6 +59,7 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email; "actions/email": typeof actions_email;
"actions/smtp": typeof actions_smtp; "actions/smtp": typeof actions_smtp;
atestadosLicencas: typeof atestadosLicencas;
autenticacao: typeof autenticacao; autenticacao: typeof autenticacao;
"auth/utils": typeof auth_utils; "auth/utils": typeof auth_utils;
chat: typeof chat; chat: typeof chat;
@@ -85,6 +88,7 @@ declare const fullApi: ApiFromModules<{
times: typeof times; times: typeof times;
todos: typeof todos; todos: typeof todos;
usuarios: typeof usuarios; usuarios: typeof usuarios;
"utils/getClientIP": typeof utils_getClientIP;
verificarMatriculas: typeof verificarMatriculas; verificarMatriculas: typeof verificarMatriculas;
}>; }>;
declare const fullApiWithMounts: typeof fullApi; 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 { v } from "convex/values";
import { mutation, query } from "./_generated/server"; import { mutation, query, internalMutation } from "./_generated/server";
import { import {
hashPassword, hashPassword,
verifyPassword, verifyPassword,
@@ -9,7 +9,7 @@ import {
} from "./auth/utils"; } from "./auth/utils";
import { registrarLogin } from "./logsLogin"; import { registrarLogin } from "./logsLogin";
import { Id, Doc } from "./_generated/dataModel"; 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 * 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 * Logout do usuário
*/ */

View File

@@ -1,5 +1,150 @@
import { httpRouter } from "convex/server"; 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(); 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; export default http;

View File

@@ -5,6 +5,35 @@ import { Doc, Id } from "./_generated/dataModel";
/** /**
* Helper para registrar tentativas de login * 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( export async function registrarLogin(
ctx: MutationCtx, ctx: MutationCtx,
dados: { dados: {
@@ -21,12 +50,15 @@ export async function registrarLogin(
const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined; const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined;
const sistema = dados.userAgent ? extrairSistema(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", { await ctx.db.insert("logsLogin", {
usuarioId: dados.usuarioId, usuarioId: dados.usuarioId,
matriculaOuEmail: dados.matriculaOuEmail, matriculaOuEmail: dados.matriculaOuEmail,
sucesso: dados.sucesso, sucesso: dados.sucesso,
motivoFalha: dados.motivoFalha, motivoFalha: dados.motivoFalha,
ipAddress: dados.ipAddress, ipAddress: ipAddressValidado,
userAgent: dados.userAgent, userAgent: dados.userAgent,
device, device,
browser, browser,

View File

@@ -151,11 +151,43 @@ export default defineSchema({
atestados: defineTable({ atestados: defineTable({
funcionarioId: v.id("funcionarios"), funcionarioId: v.id("funcionarios"),
tipo: v.union(
v.literal("atestado_medico"),
v.literal("declaracao_comparecimento")
),
dataInicio: v.string(), dataInicio: v.string(),
dataFim: v.string(), dataFim: v.string(),
cid: v.string(), cid: v.optional(v.string()), // Apenas para atestado médico
descricao: v.string(), 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({ solicitacoesFerias: defineTable({
funcionarioId: v.id("funcionarios"), 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;
}