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:
189
apps/web/src/lib/components/FuncionarioSelect.svelte
Normal file
189
apps/web/src/lib/components/FuncionarioSelect.svelte
Normal 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>
|
||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,31 +89,87 @@ 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
|
||||||
if (!resolved) {
|
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/);
|
||||||
resolved = true;
|
|
||||||
clearTimeout(timeout);
|
let ip: string | undefined = undefined;
|
||||||
pc.close();
|
|
||||||
resolve(ip);
|
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) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
pc.close();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
1048
packages/backend/convex/atestadosLicencas.ts
Normal file
1048
packages/backend/convex/atestadosLicencas.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
151
packages/backend/convex/utils/getClientIP.ts
Normal file
151
packages/backend/convex/utils/getClientIP.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user