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;
|
||||
|
||||
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 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
|
||||
* Retorna undefined se não conseguir obter
|
||||
*/
|
||||
@@ -32,31 +89,87 @@ export async function getLocalIP(): Promise<string | undefined> {
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
let foundIPs: string[] = [];
|
||||
let publicIP: string | undefined = undefined;
|
||||
let localIP: string | undefined = undefined;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
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) => {
|
||||
if (event.candidate && !resolved) {
|
||||
const candidate = event.candidate.candidate;
|
||||
// Regex para extrair IP local (IPv4)
|
||||
const ipMatch = candidate.match(/([0-9]{1,3}(\.[0-9]{1,3}){3})/);
|
||||
if (ipMatch && ipMatch[1]) {
|
||||
const ip = ipMatch[1];
|
||||
// Verificar se não é IP localhost (127.0.0.1 ou ::1)
|
||||
if (!ip.startsWith('127.') && !ip.startsWith('::1')) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
pc.close();
|
||||
resolve(ip);
|
||||
|
||||
// Regex mais rigorosa para IPv4 - deve ser um IP completo e válido
|
||||
// Formato: X.X.X.X onde X é 0-255
|
||||
const ipv4Match = candidate.match(/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\b/);
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
clearTimeout(timeout);
|
||||
pc.close();
|
||||
resolve(undefined);
|
||||
resolve(publicIP || localIP || undefined);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Erro ao obter IP via WebRTC:", error);
|
||||
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_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;
|
||||
|
||||
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 { 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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
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